fixed designs and flow
This commit is contained in:
141
lib/widgets/motion.dart
Normal file
141
lib/widgets/motion.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user