Skip to content

Instantly share code, notes, and snippets.

@hawkkiller
Last active December 14, 2025 20:04
Show Gist options
  • Select an option

  • Save hawkkiller/925cbe4d385156366dd92e63d717758b to your computer and use it in GitHub Desktop.

Select an option

Save hawkkiller/925cbe4d385156366dd92e63d717758b to your computer and use it in GitHub Desktop.
A widget that slightly scales down the pressed widget. Useful for tappable elements, such as buttons to make them feel more alive.
/// Notification sent when a [PressScaleTransition] handles a press.
/// Used to prevent ancestor [PressScaleTransition]s from animating.
class _PressScaleNotification extends Notification {
const _PressScaleNotification();
}
/// A widget that adds a subtle scale down effect when pressed.
///
/// When nested, only the innermost [PressScaleTransition] will animate,
/// preventing unwanted visual effects on parent buttons.
class PressScaleTransition extends StatefulWidget {
const PressScaleTransition({required this.child, this.enabled = true, super.key});
final bool enabled;
final Widget child;
@override
State<PressScaleTransition> createState() => _PressScaleTransitionState();
}
class _PressScaleTransitionState extends State<PressScaleTransition> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
bool _childHandledPress = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.97).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
if (!widget.enabled) return;
// Notify ancestors that this press is being handled
const _PressScaleNotification().dispatch(context);
// Use microtask to check if a child notification arrived first
Future.microtask(() {
if (!_childHandledPress && mounted) {
_controller.forward();
}
// Reset flag for next interaction
_childHandledPress = false;
});
}
Future<void> _handleTapUp(TapUpDetails details) async {
if (!widget.enabled) return;
await Future<void>.delayed(const Duration(milliseconds: 50));
if (!mounted) return;
await _controller.reverse();
}
Future<void> _handleTapCancel() async {
if (!widget.enabled) return;
await Future<void>.delayed(const Duration(milliseconds: 50));
if (!mounted) return;
await _controller.reverse();
}
@override
Widget build(BuildContext context) {
return NotificationListener<_PressScaleNotification>(
onNotification: (notification) {
// A descendant PressScaleTransition is handling the press
_childHandledPress = true;
return false; // Continue propagating to further ancestors
},
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
child: ScaleTransition(scale: _scaleAnimation, child: widget.child),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment