enhanced route transition gate because of a syncfusion viewer bug
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<RouteTransitionGate> {
|
||||
bool _ready = false;
|
||||
Animation<double>? _routeAnimation;
|
||||
Animation<double>? _animation;
|
||||
Animation<double>? _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<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() {
|
||||
_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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user