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 createState() => _RouteTransitionGateState(); } class _RouteTransitionGateState extends State { Animation? _animation; Animation? _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? next, Animation? current, void Function(Animation?) 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()); } }