import 'package:flutter/material.dart'; /// Shared motion primitives. /// /// Both of these are deliberately drop-in: [Entrance.stagger] takes the list a /// screen already builds and hands back the same list, and [PressScale] lives /// inside the shared cards rather than in any screen. Adding motion to a /// screen should not mean rewiring it. /// Fades and lifts its child into place, once, when it first appears. /// /// A screen that arrives fully drawn tells you nothing about how it is put /// together. Letting the rows land in order — a short distance, quickly — /// shows the reading order before the worker has to find it, which matters /// most on the screens that are a long column of similar cards. class Entrance extends StatefulWidget { const Entrance({super.key, required this.child, this.delay = Duration.zero}); final Widget child; /// How long to wait before starting. [stagger] fills this in. final Duration delay; /// Wraps each child with an increasing delay. /// /// The stagger stops counting after [_maxSteps] so a long list does not end /// up with a row that waits a second and a half to appear — past the first /// handful the effect has already done its job. static List stagger(List children, {Duration step = _step}) { return [ for (final (i, child) in children.indexed) Entrance(delay: step * (i > _maxSteps ? _maxSteps : i), child: child), ]; } static const _step = Duration(milliseconds: 55); static const _maxSteps = 6; @override State createState() => _EntranceState(); } class _EntranceState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _curve; static const _travel = Duration(milliseconds: 380); @override void initState() { super.initState(); // The delay is folded into the controller as a leading Interval rather // than run as a Future.delayed. A standalone timer outlives the widget it // was armed for — a `mounted` guard protects the callback but not the // timer, which then shows up as a pending timer at teardown and keeps a // disposed screen's work alive. The controller goes away with the state. final total = widget.delay + _travel; _controller = AnimationController(vsync: this, duration: total); _curve = CurvedAnimation( parent: _controller, curve: Interval( widget.delay.inMicroseconds / total.inMicroseconds, 1, curve: Curves.easeOutCubic, ), ); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _curve, // The child is passed through untouched, so it is built once rather // than rebuilt on every frame of the entrance. child: widget.child, builder: (context, child) => Opacity( opacity: _curve.value, child: Transform.translate( offset: Offset(0, (1 - _curve.value) * 14), child: child, ), ), ); } } /// Shrinks its child slightly while a finger is down on it. /// /// Material's ink ripple answers "the app heard you" after the fact, and on a /// white card on a white page it is nearly invisible. A card that gives under /// the thumb answers during the press instead, which is the half-second that /// actually feels unresponsive. class PressScale extends StatefulWidget { const PressScale({super.key, required this.child, this.enabled = true}); final Widget child; /// False for a card with no tap handler, which should not pretend to react. final bool enabled; @override State createState() => _PressScaleState(); } class _PressScaleState extends State { bool _down = false; void _set(bool down) { if (!widget.enabled || _down == down) return; setState(() => _down = down); } @override Widget build(BuildContext context) { if (!widget.enabled) return widget.child; return Listener( // Listener rather than GestureDetector: this has to read the press // without competing for the gesture, so the InkWell underneath still // gets its tap and a scroll that starts on a card still scrolls. onPointerDown: (_) => _set(true), onPointerUp: (_) => _set(false), onPointerCancel: (_) => _set(false), child: AnimatedScale( scale: _down ? 0.975 : 1, duration: const Duration(milliseconds: 130), curve: Curves.easeOut, child: widget.child, ), ); } }