diff --git a/lib/widget/file_viewer/deferred_pdf_viewer.dart b/lib/widget/file_viewer/deferred_pdf_viewer.dart index 5d63a40..d2fe359 100644 --- a/lib/widget/file_viewer/deferred_pdf_viewer.dart +++ b/lib/widget/file_viewer/deferred_pdf_viewer.dart @@ -5,8 +5,9 @@ import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import '../route_transition_gate.dart'; -/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push -/// animation. Defer until the route enter animation completes. +/// SfPdfViewer asserts on `localToGlobal` if laid out while a route +/// transition's fresh `RenderTransform` has no size yet. Mount it only while +/// the route is at rest (see [RouteTransitionGate]). class DeferredPdfViewer extends StatelessWidget { const DeferredPdfViewer({super.key, required this.path}); final String path; diff --git a/lib/widget/route_transition_gate.dart b/lib/widget/route_transition_gate.dart index f1648a9..b606893 100644 --- a/lib/widget/route_transition_gate.dart +++ b/lib/widget/route_transition_gate.dart @@ -2,18 +2,25 @@ import 'package:flutter/material.dart'; import 'app_progress_indicator.dart'; -/// Delays building [builder] until the enclosing route's enter animation has -/// finished. Some widgets (notably `SfPdfViewer`) call `localToGlobal` during -/// their first layout and assert with `RenderBox was not laid out` when an -/// ancestor page-transition `RenderTransform` still has no size mid-push. -/// Gating the mount behind the settled animation avoids that race. +/// 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. class RouteTransitionGate extends StatefulWidget { const RouteTransitionGate({super.key, required this.builder, this.placeholder}); final WidgetBuilder builder; - /// Shown while the route is still animating in. Defaults to a centered - /// large progress indicator. + /// Shown while the route is transitioning. Defaults to a centered large + /// progress indicator. final Widget? placeholder; @override @@ -21,36 +28,52 @@ class RouteTransitionGate extends StatefulWidget { } class _RouteTransitionGateState extends State { - bool _ready = false; - Animation? _routeAnimation; + Animation? _animation; + Animation? _secondaryAnimation; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_ready || _routeAnimation != null) return; - final animation = ModalRoute.of(context)?.animation; - if (animation == null || animation.isCompleted) { - _ready = true; - return; - } - _routeAnimation = animation..addStatusListener(_onAnimationStatus); + final route = ModalRoute.of(context); + _swapListener(route?.animation, _animation, (a) => _animation = a); + _swapListener( + route?.secondaryAnimation, + _secondaryAnimation, + (a) => _secondaryAnimation = a, + ); } - void _onAnimationStatus(AnimationStatus status) { - if (status == AnimationStatus.completed && mounted) { - setState(() => _ready = true); - } + 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() { - _routeAnimation?.removeStatusListener(_onAnimationStatus); + _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) setState(() {}); + } + @override Widget build(BuildContext context) { - if (!_ready) { + if (_transitioning) { return widget.placeholder ?? const Center(child: AppProgressIndicator.large()); } diff --git a/test/widget/route_transition_gate_test.dart b/test/widget/route_transition_gate_test.dart new file mode 100644 index 0000000..1799723 --- /dev/null +++ b/test/widget/route_transition_gate_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/widget/route_transition_gate.dart'; + +void main() { + const gated = Key('gated-child'); + const placeholder = Key('gate-placeholder'); + + Widget gatedPage() => Scaffold( + body: RouteTransitionGate( + placeholder: const SizedBox(key: placeholder), + builder: (_) => const SizedBox(key: gated), + ), + ); + + Future pumpApp(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp(home: Builder(builder: (_) => const Scaffold())), + ); + } + + NavigatorState navigator(WidgetTester tester) => + tester.state(find.byType(Navigator)); + + testWidgets('shows placeholder during push, child once settled', ( + tester, + ) async { + await pumpApp(tester); + unawaited( + navigator(tester).push(MaterialPageRoute(builder: (_) => gatedPage())), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byKey(placeholder), findsOneWidget); + expect(find.byKey(gated), findsNothing); + + await tester.pumpAndSettle(); + expect(find.byKey(gated), findsOneWidget); + expect(find.byKey(placeholder), findsNothing); + }); + + testWidgets('swaps back to placeholder while the route pops', (tester) async { + await pumpApp(tester); + unawaited( + navigator(tester).push(MaterialPageRoute(builder: (_) => gatedPage())), + ); + await tester.pumpAndSettle(); + expect(find.byKey(gated), findsOneWidget); + + navigator(tester).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byKey(gated), findsNothing); + expect(find.byKey(placeholder), findsOneWidget); + }); + + testWidgets('gates during a secondary transition and recovers after pop', ( + tester, + ) async { + await pumpApp(tester); + unawaited( + navigator(tester).push(MaterialPageRoute(builder: (_) => gatedPage())), + ); + await tester.pumpAndSettle(); + expect(find.byKey(gated), findsOneWidget); + + unawaited( + navigator(tester).push(MaterialPageRoute(builder: (_) => const Scaffold())), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + // Mid secondary transition the fragile subtree must be unmounted. + expect(find.byKey(gated), findsNothing); + + await tester.pumpAndSettle(); + navigator(tester).pop(); + await tester.pumpAndSettle(); + // Back at rest on the gated route: child is mounted again. + expect(find.byKey(gated), findsOneWidget); + expect(find.byKey(placeholder), findsNothing); + }); +}