Files
Client/lib/widget/route_transition_gate.dart
T

186 lines
6.5 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'app_progress_indicator.dart';
/// Builds [builder]'s subtree only while the enclosing route is at rest — i.e.
/// neither its own enter/exit animation nor its secondary (route-pushed-on-top)
/// transition is running.
///
/// Some widgets (notably `SfPdfViewer`) call `localToGlobal` during layout and
/// crash with `RenderBox was not laid out` when an ancestor page-transition
/// `RenderTransform` is mid-first-layout. Those transforms are inserted freshly
/// whenever a transition *starts* — not only on the initial push, but also on
/// pop and when another page is pushed on top. The gate therefore swaps the
/// subtree for [placeholder] for the duration of any transition; the status
/// listener fires before that frame's layout, so the fragile subtree is gone
/// before the new transform lays out.
///
/// On top of that the gate always yields one idle post-frame (`_settled`)
/// before mounting [builder], whether or not a route animation ran. A
/// `RenderTransform` is a `RenderProxyBox` and only takes its size *after*
/// laying out its child, so during that first pass `size` throws — and route
/// transitions are not the only source of a fresh transform: `PersistentTabView`
/// wraps every tab screen in an animated `Transform.translate` and builds a tab
/// lazily on first activation, which is how the ticker's in-place PDF pages hit
/// the same window with no route animation in sight.
///
/// Neither covers a subtree that is *moved* (GlobalKey) under a fresh
/// transform while already settled — Android's predictive back does exactly
/// that: on gesture start it swaps the route's transition widgets, and the
/// route content (`_subtreeKey`) lands under a brand-new `Transform.scale`
/// with no animation status change. Flutter rebuilds every stateful widget in
/// a moved subtree, so the gate re-checks its ancestors on each build and
/// re-gates while any of them has not been laid out yet.
///
/// The one thing the gate must *not* do is unmount the subtree while a pointer
/// is down on it: pointer events keep going to the hit-test path captured at
/// pointer-down, and `SfPdfViewer` dereferences its `context` in the handler
/// after dispose. iOS' back swipe hits both the edge detector and the page, so
/// the pop starts with the finger still on the viewer — the subtree is held
/// until the pointer lifts. An unlaid-out ancestor still wins, since that
/// crash is certain in the very same frame.
class RouteTransitionGate extends StatefulWidget {
const RouteTransitionGate({
super.key,
required this.builder,
this.placeholder,
});
final WidgetBuilder builder;
/// Shown while the route is transitioning. Defaults to a centered large
/// progress indicator.
final Widget? placeholder;
@override
State<RouteTransitionGate> createState() => _RouteTransitionGateState();
}
class _RouteTransitionGateState extends State<RouteTransitionGate> {
Animation<double>? _animation;
Animation<double>? _secondaryAnimation;
/// Becomes true one idle post-frame after the last transition ended; only
/// then is [RouteTransitionGate.builder] safe to mount (see class doc).
bool _settled = false;
bool _settleScheduled = false;
int _activePointers = 0;
bool _childMounted = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
_swapListener(route?.animation, _animation, (a) => _animation = a);
_swapListener(
route?.secondaryAnimation,
_secondaryAnimation,
(a) => _secondaryAnimation = a,
);
}
void _swapListener(
Animation<double>? next,
Animation<double>? current,
void Function(Animation<double>?) assign,
) {
if (identical(next, current)) return;
current?.removeStatusListener(_onAnimationStatus);
assign(next?..addStatusListener(_onAnimationStatus));
}
@override
void dispose() {
_animation?.removeStatusListener(_onAnimationStatus);
_secondaryAnimation?.removeStatusListener(_onAnimationStatus);
super.dispose();
}
bool get _transitioning =>
_isAnimating(_animation?.status) ||
_isAnimating(_secondaryAnimation?.status);
static bool _isAnimating(AnimationStatus? status) =>
status == AnimationStatus.forward || status == AnimationStatus.reverse;
void _onAnimationStatus(AnimationStatus status) {
if (!mounted) return;
// A (re)starting transition invalidates the settled state; it has to be
// re-earned once the transition finishes.
setState(() {
if (_transitioning) _settled = false;
});
}
void _scheduleSettle() {
if (_settleScheduled) return;
_settleScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_settleScheduled = false;
if (mounted && !_transitioning && !_settled) {
setState(() => _settled = true);
}
});
}
@override
void deactivate() {
// About to be moved (or disposed); whatever we end up under must earn
// the settled state again.
_settled = false;
super.deactivate();
}
static bool _hasUnlaidOutAncestor(BuildContext context) {
var found = false;
context.visitAncestorElements((element) {
final renderObject = element is RenderObjectElement
? element.renderObject
: null;
if (renderObject is RenderBox && !renderObject.hasSize) {
found = true;
return false;
}
return true;
});
return found;
}
void _onPointerUp(PointerEvent _) {
_activePointers = math.max(0, _activePointers - 1);
if (_activePointers > 0 || !mounted) return;
// Held through a transition that has since finished: the subtree is
// mounted and laid out, nothing to re-earn. Otherwise let go now.
if (_transitioning) {
setState(() {});
} else {
_settled = true;
}
}
@override
Widget build(BuildContext context) {
final blocked = _hasUnlaidOutAncestor(context);
if (blocked) _settled = false;
final hold = _childMounted && _activePointers > 0 && !blocked;
if (hold && !_transitioning) _settled = true;
if ((_settled && !_transitioning) || hold) {
_childMounted = true;
return Listener(
onPointerDown: (_) => _activePointers++,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerUp,
child: widget.builder(context),
);
}
_childMounted = false;
_activePointers = 0;
if (!_transitioning) _scheduleSettle();
return widget.placeholder ??
const Center(child: AppProgressIndicator.large());
}
}