Skip to content

Instantly share code, notes, and snippets.

@mercen-lee
Created June 18, 2026 01:36
Show Gist options
  • Select an option

  • Save mercen-lee/61f7c748710a4dd51f8000c6fc177b46 to your computer and use it in GitHub Desktop.

Select an option

Save mercen-lee/61f7c748710a4dd51f8000c6fc177b46 to your computer and use it in GitHub Desktop.
iOS style button interaction animation implementation for Flutter
/*
How to use TapButton
TapButton is a small iOS-style press effect wrapper that replaces Flutter's
default Material ripple.
It does not use:
- InkWell
- InkResponse
- Material splash / ripple
Instead, it uses:
- Listener
- RawGestureDetector
- FadeTransition
1) Basic usage
```dart
TapButton(
onTap: () {
print('pressed');
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(999),
),
alignment: Alignment.center,
child: const Text(
'Continue',
style: TextStyle(color: Colors.white),
),
),
)
```
2) Async tap
`asyncOnTap` prevents duplicate taps while the async callback is running.
```dart
TapButton(
asyncOnTap: () async {
await save();
},
child: const Text('Save'),
)
```
3) Disabled state
```dart
TapButton(
enabled: false,
onTap: () {},
child: const Text('Disabled'),
)
```
4) Loading state
When `isLoading` is true, taps are blocked and the opacity gently pulses.
```dart
TapButton(
isLoading: true,
onTap: () {},
child: const Text('Loading'),
)
```
5) Prevent parent TapButton from receiving a nested pointer
Use `TapButtonPointerBlocker` around children that should not trigger the
parent TapButton.
```dart
TapButton(
onTap: () {
print('parent');
},
child: Row(
children: [
const Text('Parent row'),
TapButtonPointerBlocker(
child: Slider(
value: 0.5,
onChanged: (_) {},
),
),
],
),
)
```
*/
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
typedef AsyncTapCallback = FutureOr<void> Function();
class _PointerClaimRegistry {
static final Map<int, Object> _claims = <int, Object>{};
static bool claim(int pointer, Object owner) {
final existing = _claims[pointer];
if (existing != null && existing != owner) {
return false;
}
_claims[pointer] = owner;
return true;
}
static void release(int pointer, Object owner) {
final existing = _claims[pointer];
if (existing == owner) {
_claims.remove(pointer);
}
}
}
/// iOS-style tap wrapper.
///
/// This replaces Flutter's default ripple effect with a simple opacity-based
/// press effect.
///
/// Features:
/// - No Material ripple
/// - Immediate opacity feedback on pointer down
/// - Smooth opacity recovery on pointer up / cancel
/// - Automatically cancels press state while scrolling
/// - Prevents nested TapButton pointer conflicts
/// - Blocks duplicate taps while asyncOnTap is running
class TapButton extends StatefulWidget {
const TapButton({
super.key,
required this.child,
this.onTap,
this.asyncOnTap,
this.enabled = true,
this.isLoading = false,
this.pressedOpacity = 0.4,
this.disabledOpacity = 0.5,
this.duration = const Duration(milliseconds: 200),
this.loadingDuration = const Duration(milliseconds: 500),
this.hitTestBehavior = HitTestBehavior.opaque,
}) : assert(
onTap == null || asyncOnTap == null,
'onTap and asyncOnTap cannot be provided at the same time.',
);
final Widget child;
final VoidCallback? onTap;
final AsyncTapCallback? asyncOnTap;
final bool enabled;
final bool isLoading;
final double pressedOpacity;
final double disabledOpacity;
final Duration duration;
final Duration loadingDuration;
final HitTestBehavior hitTestBehavior;
@override
State<TapButton> createState() => _TapButtonState();
}
class _TapButtonState extends State<TapButton> with TickerProviderStateMixin {
final Set<int> _activePointers = <int>{};
final Set<int> _insidePointers = <int>{};
ScrollPosition? _scrollPosition;
bool _isScrolling = false;
bool _isAsyncBusy = false;
late final AnimationController _pressController;
late Animation<double> _pressOpacity;
late final AnimationController _loadingController;
late Animation<double> _loadingOpacity;
bool get _isInteractive {
return widget.enabled &&
!widget.isLoading &&
!_isAsyncBusy &&
(widget.onTap != null || widget.asyncOnTap != null);
}
@override
void initState() {
super.initState();
_pressController = AnimationController(
duration: widget.duration,
vsync: this,
);
_loadingController = AnimationController(
duration: widget.loadingDuration,
vsync: this,
);
_rebuildAnimations();
if (widget.isLoading) {
_loadingController.value = 1.0;
_loadingController.repeat(reverse: true);
}
}
@override
void didUpdateWidget(covariant TapButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pressedOpacity != widget.pressedOpacity ||
oldWidget.disabledOpacity != widget.disabledOpacity ||
oldWidget.duration != widget.duration ||
oldWidget.loadingDuration != widget.loadingDuration) {
_pressController.duration = widget.duration;
_loadingController.duration = widget.loadingDuration;
_rebuildAnimations();
}
if (oldWidget.isLoading != widget.isLoading) {
_cancelAllPointers();
_pressController.value = 0.0;
if (widget.isLoading) {
_loadingController.value = 1.0;
_loadingController.repeat(reverse: true);
} else {
_loadingController.stop();
_loadingController.value = 0.0;
}
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateScrollPosition();
}
void _rebuildAnimations() {
_pressOpacity = Tween<double>(
begin: 1.0,
end: widget.pressedOpacity,
).animate(
CurvedAnimation(
parent: _pressController,
curve: Curves.easeOut,
),
);
_loadingOpacity = Tween<double>(
begin: 0.8,
end: widget.disabledOpacity,
).animate(
CurvedAnimation(
parent: _loadingController,
curve: Curves.easeInOut,
),
);
}
void _updateScrollPosition() {
final position = Scrollable.maybeOf(context)?.position;
if (_scrollPosition == position) return;
_scrollPosition?.isScrollingNotifier.removeListener(_handleScrollState);
_scrollPosition = position;
_scrollPosition?.isScrollingNotifier.addListener(_handleScrollState);
}
void _handleScrollState() {
final isScrolling = _scrollPosition?.isScrollingNotifier.value ?? false;
if (_isScrolling == isScrolling) return;
_isScrolling = isScrolling;
if (_isScrolling) {
_cancelAllPointers();
}
}
void _handlePointerDown(PointerDownEvent event) {
if (!mounted || !_isInteractive || _isScrolling) return;
if (!_PointerClaimRegistry.claim(event.pointer, this)) return;
_activePointers.add(event.pointer);
_insidePointers.add(event.pointer);
_pressController.value = 1.0;
}
void _handlePointerMove(PointerMoveEvent event) {
if (!mounted || !_isInteractive) return;
if (!_activePointers.contains(event.pointer)) return;
final isInside = _isInsideGlobal(event.position);
final wasInside = _insidePointers.contains(event.pointer);
if (isInside == wasInside) return;
if (isInside) {
_insidePointers.add(event.pointer);
} else {
_insidePointers.remove(event.pointer);
}
_updatePressedOpacity();
}
void _handlePointerUp(PointerUpEvent event) {
if (!mounted || !_isInteractive) return;
if (!_activePointers.remove(event.pointer)) return;
_insidePointers.remove(event.pointer);
_PointerClaimRegistry.release(event.pointer, this);
_updatePressedOpacity();
}
void _handlePointerCancel(PointerCancelEvent event) {
_cancelPointer(event.pointer);
}
bool _isInsideGlobal(Offset globalPosition) {
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox) return false;
if (!renderObject.attached || !renderObject.hasSize) return false;
final local = renderObject.globalToLocal(globalPosition);
final size = renderObject.size;
return local.dx >= 0 &&
local.dy >= 0 &&
local.dx <= size.width &&
local.dy <= size.height;
}
void _updatePressedOpacity() {
if (_insidePointers.isNotEmpty) {
_pressController.forward();
} else {
_pressController.reverse();
}
}
void _cancelAllPointers() {
if (!mounted) return;
for (final pointer in _activePointers) {
_PointerClaimRegistry.release(pointer, this);
}
_activePointers.clear();
_insidePointers.clear();
_updatePressedOpacity();
}
void _cancelPointer(int pointer) {
if (!_activePointers.remove(pointer)) return;
_insidePointers.remove(pointer);
_PointerClaimRegistry.release(pointer, this);
_updatePressedOpacity();
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification is ScrollStartNotification) {
_isScrolling = true;
_cancelAllPointers();
} else if (notification is ScrollEndNotification) {
_isScrolling = false;
}
return false;
}
void _handleTapUp(TapUpDetails details) {
if (!mounted || !_isInteractive) return;
if (!_isInsideGlobal(details.globalPosition)) return;
if (widget.asyncOnTap != null) {
unawaited(_handleAsyncTap());
} else {
widget.onTap?.call();
}
}
Future<void> _handleAsyncTap() async {
if (_isAsyncBusy) return;
setState(() {
_isAsyncBusy = true;
});
try {
await widget.asyncOnTap?.call();
} finally {
if (mounted) {
setState(() {
_isAsyncBusy = false;
});
}
}
}
@override
void dispose() {
_scrollPosition?.isScrollingNotifier.removeListener(_handleScrollState);
for (final pointer in _activePointers) {
_PointerClaimRegistry.release(pointer, this);
}
_activePointers.clear();
_insidePointers.clear();
_pressController.dispose();
_loadingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final opacity = widget.isLoading ? _loadingOpacity : _pressOpacity;
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Listener(
onPointerDown: _handlePointerDown,
onPointerMove: _handlePointerMove,
onPointerUp: _handlePointerUp,
onPointerCancel: _handlePointerCancel,
behavior: widget.hitTestBehavior,
child: _TapCancelScope(
enabled: _isInteractive,
onTapCancel: _cancelAllPointers,
onTapUp: _handleTapUp,
child: RepaintBoundary(
child: Semantics(
button: true,
enabled: _isInteractive,
onTap: _isInteractive
? widget.onTap ?? () => unawaited(_handleAsyncTap())
: null,
child: FadeTransition(
opacity: widget.enabled
? opacity
: AlwaysStoppedAnimation<double>(
widget.disabledOpacity,
),
child: widget.child,
),
),
),
),
),
);
}
}
class _TapCancelScope extends StatelessWidget {
const _TapCancelScope({
required this.enabled,
required this.onTapCancel,
required this.onTapUp,
required this.child,
});
static final DeviceGestureSettings _gestureSettings = DeviceGestureSettings(
touchSlop: 1000000,
);
final bool enabled;
final VoidCallback onTapCancel;
final GestureTapUpCallback onTapUp;
final Widget child;
@override
Widget build(BuildContext context) {
if (!enabled) return child;
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: <Type, GestureRecognizerFactory>{
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(instance) {
instance
..onTapCancel = onTapCancel
..onTapUp = onTapUp
..gestureSettings = _gestureSettings;
},
),
},
child: child,
);
}
}
/// Blocks a parent TapButton from claiming pointers inside this subtree.
///
/// Useful for sliders, scrollable regions, embedded controls, or any child
/// widget that should handle its own gestures.
class TapButtonPointerBlocker extends StatefulWidget {
const TapButtonPointerBlocker({
super.key,
required this.child,
this.enabled = true,
});
final Widget child;
final bool enabled;
@override
State<TapButtonPointerBlocker> createState() =>
_TapButtonPointerBlockerState();
}
class _TapButtonPointerBlockerState extends State<TapButtonPointerBlocker> {
final Set<int> _activePointers = <int>{};
@override
void dispose() {
for (final pointer in _activePointers) {
_PointerClaimRegistry.release(pointer, this);
}
_activePointers.clear();
super.dispose();
}
void _handlePointerDown(PointerDownEvent event) {
if (!widget.enabled) return;
if (!_PointerClaimRegistry.claim(event.pointer, this)) return;
_activePointers.add(event.pointer);
}
void _handlePointerUp(PointerUpEvent event) {
_releasePointer(event.pointer);
}
void _handlePointerCancel(PointerCancelEvent event) {
_releasePointer(event.pointer);
}
void _releasePointer(int pointer) {
if (!_activePointers.remove(pointer)) return;
_PointerClaimRegistry.release(pointer, this);
}
@override
Widget build(BuildContext context) {
if (!widget.enabled) return widget.child;
return Listener(
onPointerDown: _handlePointerDown,
onPointerUp: _handlePointerUp,
onPointerCancel: _handlePointerCancel,
behavior: HitTestBehavior.opaque,
child: widget.child,
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment