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';
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user