enhanced route transition gate because of a syncfusion viewer bug

This commit is contained in:
2026-08-08 22:13:19 +02:00
parent b9cb1df473
commit 39c16bd4ea
3 changed files with 134 additions and 24 deletions
@@ -5,8 +5,9 @@ import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../route_transition_gate.dart'; import '../route_transition_gate.dart';
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push /// SfPdfViewer asserts on `localToGlobal` if laid out while a route
/// animation. Defer until the route enter animation completes. /// transition's fresh `RenderTransform` has no size yet. Mount it only while
/// the route is at rest (see [RouteTransitionGate]).
class DeferredPdfViewer extends StatelessWidget { class DeferredPdfViewer extends StatelessWidget {
const DeferredPdfViewer({super.key, required this.path}); const DeferredPdfViewer({super.key, required this.path});
final String path; final String path;
+45 -22
View File
@@ -2,18 +2,25 @@ import 'package:flutter/material.dart';
import 'app_progress_indicator.dart'; import 'app_progress_indicator.dart';
/// Delays building [builder] until the enclosing route's enter animation has /// Builds [builder]'s subtree only while the enclosing route is at rest — i.e.
/// finished. Some widgets (notably `SfPdfViewer`) call `localToGlobal` during /// neither its own enter/exit animation nor its secondary (route-pushed-on-top)
/// their first layout and assert with `RenderBox was not laid out` when an /// transition is running.
/// ancestor page-transition `RenderTransform` still has no size mid-push. ///
/// Gating the mount behind the settled animation avoids that race. /// 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 { class RouteTransitionGate extends StatefulWidget {
const RouteTransitionGate({super.key, required this.builder, this.placeholder}); const RouteTransitionGate({super.key, required this.builder, this.placeholder});
final WidgetBuilder builder; final WidgetBuilder builder;
/// Shown while the route is still animating in. Defaults to a centered /// Shown while the route is transitioning. Defaults to a centered large
/// large progress indicator. /// progress indicator.
final Widget? placeholder; final Widget? placeholder;
@override @override
@@ -21,36 +28,52 @@ class RouteTransitionGate extends StatefulWidget {
} }
class _RouteTransitionGateState extends State<RouteTransitionGate> { class _RouteTransitionGateState extends State<RouteTransitionGate> {
bool _ready = false; Animation<double>? _animation;
Animation<double>? _routeAnimation; Animation<double>? _secondaryAnimation;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (_ready || _routeAnimation != null) return; final route = ModalRoute.of(context);
final animation = ModalRoute.of(context)?.animation; _swapListener(route?.animation, _animation, (a) => _animation = a);
if (animation == null || animation.isCompleted) { _swapListener(
_ready = true; route?.secondaryAnimation,
return; _secondaryAnimation,
} (a) => _secondaryAnimation = a,
_routeAnimation = animation..addStatusListener(_onAnimationStatus); );
} }
void _onAnimationStatus(AnimationStatus status) { void _swapListener(
if (status == AnimationStatus.completed && mounted) { Animation<double>? next,
setState(() => _ready = true); Animation<double>? current,
} void Function(Animation<double>?) assign,
) {
if (identical(next, current)) return;
current?.removeStatusListener(_onAnimationStatus);
assign(next?..addStatusListener(_onAnimationStatus));
} }
@override @override
void dispose() { void dispose() {
_routeAnimation?.removeStatusListener(_onAnimationStatus); _animation?.removeStatusListener(_onAnimationStatus);
_secondaryAnimation?.removeStatusListener(_onAnimationStatus);
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_ready) { if (_transitioning) {
return widget.placeholder ?? return widget.placeholder ??
const Center(child: AppProgressIndicator.large()); const Center(child: AppProgressIndicator.large());
} }
@@ -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<void> pumpApp(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(home: Builder(builder: (_) => const Scaffold())),
);
}
NavigatorState navigator(WidgetTester tester) =>
tester.state<NavigatorState>(find.byType(Navigator));
testWidgets('shows placeholder during push, child once settled', (
tester,
) async {
await pumpApp(tester);
unawaited(
navigator(tester).push(MaterialPageRoute<void>(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<void>(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<void>(builder: (_) => gatedPage())),
);
await tester.pumpAndSettle();
expect(find.byKey(gated), findsOneWidget);
unawaited(
navigator(tester).push(MaterialPageRoute<void>(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);
});
}