Files
TPSchedule/mobile/lib/widgets/directional_week_transition.dart

138 lines
3.8 KiB
Dart

import 'package:flutter/material.dart';
/// Moves the week body without adding a second horizontal scroll surface.
/// Rapid requests are coalesced to the latest week after the current movement.
class DirectionalWeekTransition extends StatefulWidget {
const DirectionalWeekTransition({
super.key,
required this.week,
required this.child,
});
final int week;
final Widget child;
@override
State<DirectionalWeekTransition> createState() =>
_DirectionalWeekTransitionState();
}
class _DirectionalWeekTransitionState extends State<DirectionalWeekTransition>
with SingleTickerProviderStateMixin {
late final AnimationController _motion = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 280),
value: 1,
)..addStatusListener(_onStatus);
late Widget _incoming;
Widget? _outgoing;
late int _incomingWeek;
double _direction = 1;
bool _reduceMotion = false;
@override
void initState() {
super.initState();
_incoming = widget.child;
_incomingWeek = widget.week;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_reduceMotion = MediaQuery.disableAnimationsOf(context);
if (_reduceMotion) _snap();
}
void _snap() {
_motion.stop();
_outgoing = null;
_incoming = widget.child;
_incomingWeek = widget.week;
_motion.value = 1;
}
void _start() {
_outgoing = _incoming;
_direction = widget.week > _incomingWeek ? 1 : -1;
_incoming = widget.child;
_incomingWeek = widget.week;
_motion.forward(from: 0);
}
@override
void didUpdateWidget(DirectionalWeekTransition oldWidget) {
super.didUpdateWidget(oldWidget);
if (_reduceMotion) {
_snap();
} else if (widget.week == _incomingWeek) {
_incoming = widget.child;
} else if (!_motion.isAnimating) {
_start();
}
// While moving, widget holds the latest request. Do not restart a tween or
// accumulate outgoing grids every time another swipe/arrow arrives.
}
void _onStatus(AnimationStatus status) {
if (status != AnimationStatus.completed ||
!mounted ||
_reduceMotion ||
_outgoing == null) {
return;
}
setState(() {
_outgoing = null;
if (widget.week != _incomingWeek) _start();
});
}
@override
void dispose() {
_motion.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ClipRect(
child: AnimatedBuilder(
animation: _motion,
builder: (context, _) {
final progress = Curves.easeInOutCubic.transform(_motion.value);
final stale = widget.week != _incomingWeek;
return Stack(
alignment: Alignment.topLeft,
children: [
if (_outgoing != null)
ExcludeSemantics(
child: ExcludeFocus(
child: IgnorePointer(
child: FractionalTranslation(
key: const Key('week-transition-outgoing'),
translation: Offset(-_direction * 0.08 * progress, 0),
child: Opacity(opacity: 1 - progress, child: _outgoing),
),
),
),
),
ExcludeSemantics(
excluding: stale,
child: ExcludeFocus(
excluding: stale,
child: IgnorePointer(
ignoring: stale,
child: FractionalTranslation(
key: const Key('week-transition-incoming'),
translation: Offset(_direction * 0.08 * (1 - progress), 0),
child: Opacity(opacity: progress, child: _incoming),
),
),
),
),
],
);
},
),
);
}