# iOS style button interaction animation implementation for Flutter

> Source: <https://gist.github.com/mercen-lee/61f7c748710a4dd51f8000c6fc177b46>
> Published: 2026-06-18 01:36:18+00:00

| /* | |
| 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, | |
| ); | |
| } | |
| } |
