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