Files
krow-worker-app/lib/widgets/motion.dart
2026-10-05 22:14:58 +05:30

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,
),
);
}
}