Feat: Add opaque, isActive, isFirst, popDisposition aspects for ModalRoute (#167324)

Feat: Add opaque, isActive, isFirst, popDisposition aspects for
ModalRoute
fixes: #167058 
fixes: #162009 

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
This commit is contained in:
Kishan Rathore 2025-05-30 01:33:21 +05:30 committed by GitHub
parent 09c7f4fa5b
commit df29894372
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 195 additions and 3 deletions

View File

@ -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<dynamic> 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<T> extends State<_ModalScope<T>> {
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<T> extends TransitionRoute<T> with LocalHistoryRoute<T
///
/// Returns null if the given context is not associated with a modal route.
///
/// Use of this method will cause the given [context] to rebuild any time that
/// the [ModalRoute.settings] property of the ancestor [_ModalScopeStatus] changes.
/// 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.settings] changes.
static RouteSettings? settingsOf(BuildContext context) =>
_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

View File

@ -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<NavigatorState> navigator = GlobalKey<NavigatorState>();
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<void>(
MaterialPageRoute<void>(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<NavigatorState> navigator = GlobalKey<NavigatorState>();
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<void>(
MaterialPageRoute<void>(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<NavigatorState> navigator = GlobalKey<NavigatorState>();
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<void>(
MaterialPageRoute<void>(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<NavigatorState> navigator = GlobalKey<NavigatorState>();
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<void>(
MaterialPageRoute<void>(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 {