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. 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; @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 Widget build(BuildContext context) { if (!_transitioning) { if (_settled) return widget.builder(context); _scheduleSettle(); } return widget.placeholder ?? const Center(child: AppProgressIndicator.large()); } }