Instantly share code, notes, and snippets.
Created
June 18, 2026 01:36
-
Star
1
(1)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save mercen-lee/61f7c748710a4dd51f8000c6fc177b46 to your computer and use it in GitHub Desktop.
iOS style button interaction animation implementation for Flutter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| 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