cd /news/developer-tools/ios-style-button-interaction-animati… · home topics developer-tools article
[ARTICLE · art-31996] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

iOS style button interaction animation implementation for Flutter

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.

read13 min views1 publishedJun 18, 2026

| /* | | | 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) state | | | When is is true, taps are blocked and the opacity gently pulses. | | | dart | | | TapButton( | | | is: true, | | | onTap: () {}, | | | child: const Text(''), | | | ) | | | | | | 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.is = false, | | | this.pressedOpacity = 0.4, | | | this.disabledOpacity = 0.5, | | | this.duration = const Duration(milliseconds: 200), | | | this.Duration = 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 is; | | | final double pressedOpacity; | | | final double disabledOpacity; | | | final Duration duration; | | | final Duration Duration; | | | 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 _Controller; | | | late Animation<double> _Opacity; | | | bool get _isInteractive { | | | return widget.enabled && | | | !widget.is && | | | !_isAsyncBusy && | | | (widget.onTap != null || widget.asyncOnTap != null); | | | } | | | @override | | | void initState() { | | | super.initState(); | | | _pressController = AnimationController( | | | duration: widget.duration, | | | vsync: this, | | | ); | | | _Controller = AnimationController( | | | duration: widget.Duration, | | | vsync: this, | | | ); | | | _rebuildAnimations(); | | | if (widget.is) { | | | _Controller.value = 1.0; | | | _Controller.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.Duration != widget.Duration) { | | | _pressController.duration = widget.duration; | | | _Controller.duration = widget.Duration; | | | _rebuildAnimations(); | | | } | | | if (oldWidget.is != widget.is) { | | | _cancelAllPointers(); | | | _pressController.value = 0.0; | | | if (widget.is) { | | | _Controller.value = 1.0; | | | _Controller.repeat(reverse: true); | | | } else { | | | _Controller.stop(); | | | _Controller.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, | | | ), | | | ); | | | _Opacity = Tween<double>( | | | begin: 0.8, | | | end: widget.disabledOpacity, | | | ).animate( | | | CurvedAnimation( | | | parent: _Controller, | | | 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(); | | | _Controller.dispose(); | | | super.dispose(); | | | } | | | @override | | | Widget build(BuildContext context) { | | | final opacity = widget.is ? _Opacity : _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, | | | ); | | | } | | | } |

── more in #developer-tools 4 stories · sorted by recency
── more on @flutter 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/ios-style-button-int…] indexed:0 read:13min 2026-06-18 ·