diff --git a/packages/cupertino_ui/lib/src/route.dart b/packages/cupertino_ui/lib/src/route.dart index 2645b62300ca..838855dc17fa 100644 --- a/packages/cupertino_ui/lib/src/route.dart +++ b/packages/cupertino_ui/lib/src/route.dart @@ -97,6 +97,18 @@ mixin CupertinoRouteTransitionMixin on PageRoute { @protected Widget buildContent(BuildContext context); + /// {@template cupertino_ui.CupertinoRouteTransitionMixin.includeRouteSemantics} + /// Whether this route introduces a route scope in the semantics tree. + /// + /// Defaults to true. When true, screen readers can treat pushes and pops of + /// this route as navigation to a new screen and announce the change to users. + /// + /// Set this to false for routes that update only part of the screen, such as + /// tab or shell content in a nested navigator. This prevents screen readers + /// from treating the route as a new screen. + /// {@endtemplate} + bool get includeRouteSemantics => true; + /// {@template cupertino_ui.CupertinoRouteTransitionMixin.title} /// A title string for this route. /// @@ -194,6 +206,9 @@ mixin CupertinoRouteTransitionMixin on PageRoute { Animation secondaryAnimation, ) { final Widget child = buildContent(context); + if (!includeRouteSemantics) { + return child; + } return Semantics(scopesRoute: true, explicitChildNodes: true, child: child); } @@ -309,6 +324,7 @@ class CupertinoPageRoute extends PageRoute with CupertinoRouteTransitionMi this.maintainState = true, super.fullscreenDialog, super.allowSnapshotting = true, + this.includeRouteSemantics = true, super.barrierDismissible = false, }) { assert(opaque); @@ -330,6 +346,10 @@ class CupertinoPageRoute extends PageRoute with CupertinoRouteTransitionMi @override final bool maintainState; + /// {@macro cupertino_ui.CupertinoRouteTransitionMixin.includeRouteSemantics} + @override + final bool includeRouteSemantics; + @override String get debugLabel => '${super.debugLabel}(${settings.name})'; } @@ -362,6 +382,9 @@ class _PageBasedCupertinoPageRoute extends PageRoute with CupertinoRouteTr @override bool get fullscreenDialog => _page.fullscreenDialog; + @override + bool get includeRouteSemantics => _page.includeRouteSemantics; + @override String get debugLabel => '${super.debugLabel}(${_page.name})'; } @@ -390,6 +413,7 @@ class CupertinoPage extends Page { this.title, this.fullscreenDialog = false, this.allowSnapshotting = true, + this.includeRouteSemantics = true, super.canPop, super.onPopInvoked, super.key, @@ -413,6 +437,9 @@ class CupertinoPage extends Page { /// {@macro flutter.widgets.TransitionRoute.allowSnapshotting} final bool allowSnapshotting; + /// {@macro cupertino_ui.CupertinoRouteTransitionMixin.includeRouteSemantics} + final bool includeRouteSemantics; + @override Route createRoute(BuildContext context) { return _PageBasedCupertinoPageRoute(page: this, allowSnapshotting: allowSnapshotting); diff --git a/packages/cupertino_ui/pending_changelogs/change_2026_08_27_route_semantics.yaml b/packages/cupertino_ui/pending_changelogs/change_2026_08_27_route_semantics.yaml new file mode 100644 index 000000000000..03eb1b65a2b5 --- /dev/null +++ b/packages/cupertino_ui/pending_changelogs/change_2026_08_27_route_semantics.yaml @@ -0,0 +1,3 @@ +changelog: | + - Adds an option for `CupertinoPageRoute` and `CupertinoPage` to opt out of introducing a semantics route scope. +version: minor diff --git a/packages/cupertino_ui/test/route_test.dart b/packages/cupertino_ui/test/route_test.dart index ebd17dcd5f79..125e8e21ef0e 100644 --- a/packages/cupertino_ui/test/route_test.dart +++ b/packages/cupertino_ui/test/route_test.dart @@ -2340,6 +2340,44 @@ void main() { expect(find.text('Visible'), findsOneWidget); }); + testWidgets('CupertinoPageRoute can opt out of route semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + CupertinoApp( + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute( + includeRouteSemantics: false, + builder: (BuildContext context) => const Text('Page'), + ); + }, + ), + ); + + expect(find.semantics.byFlag(SemanticsFlag.scopesRoute), findsNothing); + handle.dispose(); + }); + + testWidgets('CupertinoPage can opt out of route semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: const >[ + CupertinoPage(includeRouteSemantics: false, child: Text('Page')), + ], + onPopPage: (Route route, dynamic result) { + assert(false); // The test shouldn't call this. + return true; + }, + ), + ); + + expect(find.semantics.byFlag(SemanticsFlag.scopesRoute), findsNothing); + handle.dispose(); + }); + testWidgets('CupertinoPage works', (WidgetTester tester) async { final LocalKey pageKey = UniqueKey(); final detector = TransitionDetector(); diff --git a/packages/material_ui/lib/src/page.dart b/packages/material_ui/lib/src/page.dart index 6aee37edc010..6574955c991f 100644 --- a/packages/material_ui/lib/src/page.dart +++ b/packages/material_ui/lib/src/page.dart @@ -41,6 +41,7 @@ class MaterialPageRoute extends PageRoute with MaterialRouteTransitionMixi this.maintainState = true, super.fullscreenDialog, super.allowSnapshotting = true, + this.includeRouteSemantics = true, super.barrierDismissible = false, super.traversalEdgeBehavior, super.directionalTraversalEdgeBehavior, @@ -57,6 +58,10 @@ class MaterialPageRoute extends PageRoute with MaterialRouteTransitionMixi @override final bool maintainState; + /// {@macro material_ui.MaterialRouteTransitionMixin.includeRouteSemantics} + @override + final bool includeRouteSemantics; + @override String get debugLabel => '${super.debugLabel}(${settings.name})'; } @@ -87,6 +92,18 @@ mixin MaterialRouteTransitionMixin on PageRoute { @protected Widget buildContent(BuildContext context); + /// {@template material_ui.MaterialRouteTransitionMixin.includeRouteSemantics} + /// Whether this route introduces a route scope in the semantics tree. + /// + /// Defaults to true. When true, screen readers can treat pushes and pops of + /// this route as navigation to a new screen and announce the change to users. + /// + /// Set this to false for routes that update only part of the screen, such as + /// tab or shell content in a nested navigator. This prevents screen readers + /// from treating the route as a new screen. + /// {@endtemplate} + bool get includeRouteSemantics => true; + @override Duration get transitionDuration => _getPageTransitionBuilder(navigator!.context)?.transitionDuration ?? @@ -191,6 +208,9 @@ mixin MaterialRouteTransitionMixin on PageRoute { Animation secondaryAnimation, ) { final Widget result = buildContent(context); + if (!includeRouteSemantics) { + return result; + } return Semantics(scopesRoute: true, explicitChildNodes: true, child: result); } @@ -233,6 +253,7 @@ class MaterialPage extends Page { this.maintainState = true, this.fullscreenDialog = false, this.allowSnapshotting = true, + this.includeRouteSemantics = true, super.key, super.canPop, super.onPopInvoked, @@ -253,6 +274,9 @@ class MaterialPage extends Page { /// {@macro flutter.widgets.TransitionRoute.allowSnapshotting} final bool allowSnapshotting; + /// {@macro material_ui.MaterialRouteTransitionMixin.includeRouteSemantics} + final bool includeRouteSemantics; + @override Route createRoute(BuildContext context) { return _PageBasedMaterialPageRoute(page: this, allowSnapshotting: allowSnapshotting); @@ -282,6 +306,9 @@ class _PageBasedMaterialPageRoute extends PageRoute with MaterialRouteTran @override bool get fullscreenDialog => _page.fullscreenDialog; + @override + bool get includeRouteSemantics => _page.includeRouteSemantics; + @override String get debugLabel => '${super.debugLabel}(${_page.name})'; } diff --git a/packages/material_ui/pending_changelogs/change_2026_08_27_route_semantics.yaml b/packages/material_ui/pending_changelogs/change_2026_08_27_route_semantics.yaml new file mode 100644 index 000000000000..1fcd9dd6e871 --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_08_27_route_semantics.yaml @@ -0,0 +1,3 @@ +changelog: | + - Adds an option for `MaterialPageRoute` and `MaterialPage` to opt out of introducing a semantics route scope. +version: minor diff --git a/packages/material_ui/test/page_test.dart b/packages/material_ui/test/page_test.dart index 77aa9594a7b9..ed70ad94fe7d 100644 --- a/packages/material_ui/test/page_test.dart +++ b/packages/material_ui/test/page_test.dart @@ -1196,6 +1196,44 @@ void main() { }), ); + testWidgets('MaterialPageRoute can opt out of route semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + includeRouteSemantics: false, + builder: (BuildContext context) => const Text('Page'), + ); + }, + ), + ); + + expect(find.semantics.byFlag(SemanticsFlag.scopesRoute), findsNothing); + handle.dispose(); + }); + + testWidgets('MaterialPage can opt out of route semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: const >[ + MaterialPage(includeRouteSemantics: false, child: Text('Page')), + ], + onPopPage: (Route route, dynamic result) { + assert(false); // The test shouldn't call this. + return true; + }, + ), + ); + + expect(find.semantics.byFlag(SemanticsFlag.scopesRoute), findsNothing); + handle.dispose(); + }); + testWidgets('MaterialPage works', (WidgetTester tester) async { final LocalKey pageKey = UniqueKey(); final detector = TransitionDetector();