142 lines
4.6 KiB
Dart
142 lines
4.6 KiB
Dart
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<Widget> stagger(List<Widget> 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<Entrance> createState() => _EntranceState();
|
|
}
|
|
|
|
class _EntranceState extends State<Entrance>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
late final Animation<double> _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<PressScale> createState() => _PressScaleState();
|
|
}
|
|
|
|
class _PressScaleState extends State<PressScale> {
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|