| /* | |
| 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, | |
| ); | |
| } | |
| } |
Show HN: I built a spelling app for kids with my 7-year-old