diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index c61dd7a862a..80c2f371366 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -971,6 +971,18 @@ enum _ModalRouteAspect { /// Specifies the aspect corresponding to [ModalRoute.settings]. settings, + + /// Specifies the aspect corresponding to [ModalRoute.isActive]. + isActive, + + /// Specifies the aspect corresponding to [ModalRoute.isFirst]. + isFirst, + + /// Specifies the aspect corresponding to [ModalRoute.opaque]. + opaque, + + /// Specifies the aspect corresponding to [ModalRoute.popDisposition]. + popDisposition, } class _ModalScopeStatus extends InheritedModel<_ModalRouteAspect> { @@ -979,12 +991,14 @@ class _ModalScopeStatus extends InheritedModel<_ModalRouteAspect> { required this.canPop, required this.impliesAppBarDismissal, required this.route, + required this.opaque, required super.child, }); final bool isCurrent; final bool canPop; final bool impliesAppBarDismissal; + final bool opaque; final Route route; @override @@ -992,7 +1006,8 @@ class _ModalScopeStatus extends InheritedModel<_ModalRouteAspect> { return isCurrent != old.isCurrent || canPop != old.canPop || impliesAppBarDismissal != old.impliesAppBarDismissal || - route != old.route; + route != old.route || + opaque != old.opaque; } @override @@ -1021,6 +1036,10 @@ class _ModalScopeStatus extends InheritedModel<_ModalRouteAspect> { _ModalRouteAspect.isCurrent => isCurrent != oldWidget.isCurrent, _ModalRouteAspect.canPop => canPop != oldWidget.canPop, _ModalRouteAspect.settings => route.settings != oldWidget.route.settings, + _ModalRouteAspect.isActive => route.isActive != oldWidget.route.isActive, + _ModalRouteAspect.isFirst => route.isFirst != oldWidget.route.isFirst, + _ModalRouteAspect.opaque => opaque != oldWidget.opaque, + _ModalRouteAspect.popDisposition => route.popDisposition != oldWidget.route.popDisposition, }, ); } @@ -1143,6 +1162,7 @@ class _ModalScopeState extends State<_ModalScope> { route: widget.route, isCurrent: widget.route.isCurrent, // _routeSetState is called if this updates canPop: widget.route.canPop, // _routeSetState is called if this updates + opaque: widget.route.opaque, // _routeSetState is called if this updates impliesAppBarDismissal: widget.route.impliesAppBarDismissal, child: Offstage( offstage: widget.route.offstage, // _routeSetState is called if this updates @@ -1299,11 +1319,54 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute _of(context, _ModalRouteAspect.settings)?.settings; + /// Returns [ModalRoute.isActive] for the modal route most closely associated + /// with the given context. + /// + /// Returns null if the given context is not associated with a modal route. + /// + /// Calling this method creates a dependency on the [ModalRoute] associated + /// with the given [context]. As a result, the widget corresponding to [context] + /// will be rebuilt whenever the route's [ModalRoute.isActive] changes. + static bool? isActiveOf(BuildContext context) => + _of(context, _ModalRouteAspect.isActive)?.isActive; + + /// Returns [ModalRoute.isFirst] for the modal route most closely associated + /// with the given context. + /// + /// Returns null if the given context is not associated with a modal route. + /// + /// Calling this method creates a dependency on the [ModalRoute] associated + /// with the given [context]. As a result, the widget corresponding to [context] + /// will be rebuilt whenever the route's [ModalRoute.isFirst] changes. + static bool? isFirstOf(BuildContext context) => _of(context, _ModalRouteAspect.isFirst)?.isFirst; + + /// Returns [ModalRoute.opaque] for the modal route most closely associated + /// with the given context. + /// + /// Returns null if the given context is not associated with a modal route. + /// + /// Calling this method creates a dependency on the [ModalRoute] associated + /// with the given [context]. As a result, the widget corresponding to [context] + /// will be rebuilt whenever the route's [ModalRoute.opaque] changes. + static bool? opaqueOf(BuildContext context) => _of(context, _ModalRouteAspect.opaque)?.opaque; + + /// Returns [ModalRoute.popDisposition] for the modal route most closely associated + /// with the given context. + /// + /// Returns null if the given context is not associated with a modal route. + /// + /// Calling this method creates a dependency on the [ModalRoute] associated + /// with the given [context]. As a result, the widget corresponding to [context] + /// will be rebuilt whenever the route's [ModalRoute.popDisposition] changes. + static RoutePopDisposition? popDispositionOf(BuildContext context) => + _of(context, _ModalRouteAspect.popDisposition)?.popDisposition; + /// Schedule a call to [buildTransitions]. /// /// Whenever you need to change internal state for a [ModalRoute] object, make diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart index 435ef8492e3..4c7afb8c785 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -2512,6 +2512,135 @@ void main() { moreOrLessEquals(xLocationIntervalTwelve, epsilon: 0.1), ); }); + + testWidgets('ModalRoute.isFirstOf only rebuilds when first route state changes', ( + WidgetTester tester, + ) async { + int buildCount = 0; + final GlobalKey navigator = GlobalKey(); + + Widget buildCounter(BuildContext context) { + buildCount++; + final bool isFirst = ModalRoute.isFirstOf(context) ?? false; + return Text('isFirst: $isFirst'); + } + + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: Builder(builder: buildCounter)), + ); + + expect(buildCount, 1); + expect(find.text('isFirst: true'), findsOneWidget); + + // Push a new route - first route should remain first + navigator.currentState!.push( + MaterialPageRoute(builder: (BuildContext context) => const Text('New Route')), + ); + await tester.pumpAndSettle(); + + // Should not rebuild because isFirst hasn't changed + expect(buildCount, 1); + }); + + testWidgets('ModalRoute.isActiveOf only rebuilds when route active state changes', ( + WidgetTester tester, + ) async { + int buildCount = 0; + final GlobalKey navigator = GlobalKey(); + + Widget buildCounter(BuildContext context) { + buildCount++; + final bool isActive = ModalRoute.isActiveOf(context) ?? false; + return Text('isActive: $isActive'); + } + + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: Builder(builder: buildCounter)), + ); + + expect(buildCount, 1); + expect(find.text('isActive: true'), findsOneWidget); + + // Push a new route - first route should remain active + navigator.currentState!.push( + MaterialPageRoute(builder: (BuildContext context) => const Text('New Route')), + ); + await tester.pumpAndSettle(); + + // Should not rebuild because isActive hasn't changed + expect(buildCount, 1); + }); + + testWidgets('ModalRoute.opaqueOf only rebuilds when route opaque state changes', ( + WidgetTester tester, + ) async { + int buildCount = 0; + final GlobalKey navigator = GlobalKey(); + + Widget buildCounter(BuildContext context) { + buildCount++; + final bool isOpaque = ModalRoute.opaqueOf(context) ?? false; + return Text('isOpaque: $isOpaque'); + } + + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: Builder(builder: buildCounter)), + ); + + expect(buildCount, 1); + expect(find.text('isOpaque: true'), findsOneWidget); + + // Push a new route - first route should remain opaque + navigator.currentState!.push( + MaterialPageRoute(builder: (BuildContext context) => const Text('New Route')), + ); + await tester.pumpAndSettle(); + + // Should not rebuild because isOpaque hasn't changed + expect(buildCount, 1); + }); + + testWidgets('ModalRoute.popDispositionOf rebuilds when PopEntry affects pop disposition', ( + WidgetTester tester, + ) async { + int buildCount = 0; + final GlobalKey navigator = GlobalKey(); + + Widget buildCounter(BuildContext context) { + buildCount++; + final RoutePopDisposition? popDisposition = ModalRoute.popDispositionOf(context); + return Text('popDisposition: ${popDisposition?.name}'); + } + + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: Builder(builder: buildCounter)), + ); + + expect(buildCount, 1); + expect(find.text('popDisposition: bubble'), findsOneWidget); + + // Change PopScope's canPop to false + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: PopScope(canPop: false, child: Builder(builder: buildCounter)), + ), + ); + await tester.pumpAndSettle(); + + // Should rebuild because popDisposition changed to doNotPop + expect(buildCount, 2); + expect(find.text('popDisposition: doNotPop'), findsOneWidget); + + // Push a new route - should change from bubble to pop + navigator.currentState!.push( + MaterialPageRoute(builder: (BuildContext context) => const Text('New Route')), + ); + await tester.pumpAndSettle(); + + // Shouldn't rebuild because popDisposition hasn't changed + expect(buildCount, 2); + }); }); testWidgets('can be dismissed with escape keyboard shortcut', (WidgetTester tester) async {