diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index 7594513e8264..13a4f06ba6c6 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'configuration.dart'; @@ -135,6 +136,7 @@ class _CustomNavigator extends StatefulWidget { required this.errorBuilder, required this.errorPageBuilder, required this.requestFocus, + this.navigatorActive, }); final GlobalKey navigatorKey; @@ -153,6 +155,7 @@ class _CustomNavigator extends StatefulWidget { final GoRouterWidgetBuilder? errorBuilder; final GoRouterPageBuilder? errorPageBuilder; final bool requestFocus; + final ValueListenable? navigatorActive; @override State createState() => _CustomNavigatorState(); @@ -276,8 +279,9 @@ class _CustomNavigatorState extends State<_CustomNavigator> { ShellRouteMatch match, RouteMatchList matchList, List? observers, - String? restorationScopeId, - ) { + String? restorationScopeId, { + ValueListenable? navigatorActive, + }) { return PopScope( // Prevent ShellRoute from being popped, for example // by an iOS back gesture, when the route has active sub-routes. @@ -294,6 +298,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> { configuration: widget.configuration, observers: observers ?? const [], onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch, + navigatorActive: navigatorActive, // This is used to recursively build pages under this shell route. errorBuilder: widget.errorBuilder, errorPageBuilder: widget.errorPageBuilder, @@ -368,12 +373,15 @@ class _CustomNavigatorState extends State<_CustomNavigator> { Page _buildPlatformAdapterPage(BuildContext context, GoRouterState state, Widget child) { // build the page based on app type _cacheAppType(context); + final Widget pageChild = widget.navigatorActive == null + ? child + : _BranchNavigatorPopScope(navigatorActive: widget.navigatorActive!, child: child); return _pageBuilderForAppType!( key: state.pageKey, name: state.name ?? state.path, arguments: {...state.pathParameters, ...state.uri.queryParameters}, restorationId: state.pageKey.value, - child: child, + child: pageChild, ); } @@ -441,3 +449,21 @@ class _CustomNavigatorState extends State<_CustomNavigator> { ); } } + +class _BranchNavigatorPopScope extends StatelessWidget { + const _BranchNavigatorPopScope({required this.navigatorActive, required this.child}); + + final ValueListenable navigatorActive; + final Widget child; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: navigatorActive, + child: child, + builder: (BuildContext context, bool isActive, Widget? child) { + return PopScope(canPop: isActive, child: child!); + }, + ); + } +} diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 6d75560a93cd..ae79bbdd603b 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -54,8 +54,9 @@ typedef NavigatorBuilder = ShellRouteMatch match, RouteMatchList matchList, List? observers, - String? restorationScopeId, - ); + String? restorationScopeId, { + ValueListenable? navigatorActive, + }); /// Signature for function used in [RouteBase.onExit]. /// @@ -566,8 +567,9 @@ class ShellRouteContext { BuildContext context, List? observers, bool notifyRootObserver, - String? restorationScopeId, - ) { + String? restorationScopeId, { + ValueListenable? navigatorActive, + }) { final effectiveObservers = [...?observers]; if (notifyRootObserver) { @@ -583,6 +585,7 @@ class ShellRouteContext { routeMatchList, effectiveObservers, restorationScopeId, + navigatorActive: navigatorActive, ); } } @@ -1375,12 +1378,21 @@ class StatefulNavigationShellState extends State with R branch.observers, route.notifyRootObserver, branch.restorationScopeId, + navigatorActive: branchState.navigatorActive, ); } + _updateActiveBranchNavigatorFlags(); _cleanUpObsoleteBranches(); } + void _updateActiveBranchNavigatorFlags() { + for (var i = 0; i < route.branches.length; i++) { + final _StatefulShellBranchState? branchState = _branchState[route.branches[i]]; + branchState?.navigatorActive.value = i == currentIndex; + } + } + void _preloadBranches() { for (var i = 0; i < route.branches.length; i++) { final StatefulShellBranch branch = route.branches[i]; @@ -1398,15 +1410,17 @@ class StatefulNavigationShellState extends State with R }); assert(match != null); + final _StatefulShellBranchState branchState = _branchStateFor(branch, false); + branchState.navigatorActive.value = false; final Widget navigator = widget.shellRouteContext.navigatorBuilder( branch.navigatorKey, match!, matchList, branch.observers, branch.restorationScopeId, + navigatorActive: branchState.navigatorActive, ); - final _StatefulShellBranchState branchState = _branchStateFor(branch, false); branchState.location.value = matchList; branchState.navigator = navigator; } @@ -1491,9 +1505,11 @@ class _StatefulShellBranchState { Widget? navigator; final _RestorableRouteMatchList location; + final ValueNotifier navigatorActive = ValueNotifier(false); void dispose() { location.dispose(); + navigatorActive.dispose(); } } diff --git a/packages/go_router/pending_changelogs/change_2026_06_16_1781632636645.yaml b/packages/go_router/pending_changelogs/change_2026_06_16_1781632636645.yaml new file mode 100644 index 000000000000..d96867104747 --- /dev/null +++ b/packages/go_router/pending_changelogs/change_2026_06_16_1781632636645.yaml @@ -0,0 +1,3 @@ +changelog: | + - Fixes Android system/predictive back popping inactive StatefulShellBranch navigators. +version: patch diff --git a/packages/go_router/test/stateful_shell_route_system_back_test.dart b/packages/go_router/test/stateful_shell_route_system_back_test.dart index 7e66d58827a1..8389fe5fda4e 100644 --- a/packages/go_router/test/stateful_shell_route_system_back_test.dart +++ b/packages/go_router/test/stateful_shell_route_system_back_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -87,6 +88,93 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Home'), findsOneWidget); }); + + testWidgets('does not pop inactive StatefulShellRoute branches', (WidgetTester tester) async { + final pops = []; + StatefulNavigationShell? navigationShell; + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + + final GoRouter router = await createRouter( + [ + StatefulShellRoute.indexedStack( + builder: (BuildContext context, GoRouterState state, StatefulNavigationShell shell) { + navigationShell = shell; + return shell; + }, + branches: [ + StatefulShellBranch( + observers: [_RecordingNavigatorObserver('/A', pops)], + routes: [ + GoRoute( + path: '/A1', + builder: (_, _) => const _BranchScreen(title: 'Stack A - 1', canPop: false), + routes: [ + GoRoute( + path: '/A2', + builder: (_, _) => const _BranchScreen(title: 'Stack A - 2'), + routes: [ + GoRoute( + path: '/A3', + builder: (_, _) => const _BranchScreen(title: 'Stack A - 3'), + ), + ], + ), + ], + ), + ], + ), + StatefulShellBranch( + observers: [_RecordingNavigatorObserver('/B', pops)], + routes: [ + GoRoute( + path: '/B1', + builder: (_, _) => const _BranchScreen(title: 'Stack B - 1', canPop: false), + ), + ], + ), + StatefulShellBranch( + observers: [_RecordingNavigatorObserver('/C', pops)], + routes: [ + GoRoute( + path: '/C1', + builder: (_, _) => const _BranchScreen(title: 'Stack C - 1', canPop: false), + routes: [ + GoRoute( + path: '/C2', + builder: (_, _) => const _BranchScreen(title: 'Stack C - 2'), + ), + ], + ), + ], + ), + ], + ), + ], + tester, + initialLocation: '/A1', + ); + + router.go('/A1/A2/A3'); + await tester.pumpAndSettle(); + expect(find.text('Stack A - 3'), findsOneWidget); + + router.go('/C1/C2'); + await tester.pumpAndSettle(); + expect(find.text('Stack C - 2'), findsOneWidget); + + navigationShell!.goBranch(1); + await tester.pumpAndSettle(); + expect(find.text('Stack B - 1'), findsOneWidget); + + await simulateAndroidPredictiveBackGesture(tester); + await tester.pump(); + + expect(find.text('Stack B - 1'), findsOneWidget); + expect(pops, isEmpty); + }); }); } @@ -172,3 +260,64 @@ class _TestAppState extends State<_TestApp> { return MaterialApp.router(routerConfig: _router); } } + +class _BranchScreen extends StatelessWidget { + const _BranchScreen({required this.title, this.canPop = true}); + + final String title; + final bool canPop; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: canPop, + child: Scaffold(body: Center(child: Text(title))), + ); + } +} + +class _RecordingNavigatorObserver extends NavigatorObserver { + _RecordingNavigatorObserver(this.branch, this.pops); + + final String branch; + final List pops; + + @override + void didPop(Route route, Route? previousRoute) { + pops.add('$branch ${route.settings.name} -> ${previousRoute?.settings.name}'); + } +} + +Future simulateAndroidPredictiveBackGesture(WidgetTester tester) async { + await _handleAndroidPredictiveBackMessage( + tester, + const MethodCall('startBackGesture', { + 'touchOffset': [5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, + }), + ); + await tester.pump(); + + await _handleAndroidPredictiveBackMessage( + tester, + const MethodCall('updateBackGestureProgress', { + 'x': 100.0, + 'y': 300.0, + 'progress': 0.35, + 'swipeEdge': 0, + }), + ); + await tester.pump(); + + await _handleAndroidPredictiveBackMessage(tester, const MethodCall('commitBackGesture')); +} + +Future _handleAndroidPredictiveBackMessage(WidgetTester tester, MethodCall methodCall) async { + final ByteData message = const StandardMethodCodec().encodeMethodCall(methodCall); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + message, + (ByteData? _) {}, + ); +}