
This PR aims to support Android's predictive back gesture when popping the entire Flutter app. Predictive route transitions between routes inside of a Flutter app will come later. <img width="200" src="https://user-images.githubusercontent.com/389558/217918109-945febaa-9086-41cc-a476-1a189c7831d8.gif" /> ### Trying it out If you want to try this feature yourself, here are the necessary steps: 1. Run Android 33 or above. 1. Enable the feature flag for predictive back on the device under "Developer options". 1. Create a Flutter project, or clone [my example project](https://github.com/justinmc/flutter_predictive_back_examples). 1. Set `android:enableOnBackInvokedCallback="true"` in android/app/src/main/AndroidManifest.xml (already done in the example project). 1. Check out this branch. 1. Run the app. Perform a back gesture (swipe from the left side of the screen). You should see the predictive back animation like in the animation above and be able to commit or cancel it. ### go_router support go_router works with predictive back out of the box because it uses a Navigator internally that dispatches NavigationNotifications! ~~go_router can be supported by adding a listener to the router and updating SystemNavigator.setFrameworkHandlesBack.~~ Similar to with nested Navigators, nested go_routers is supported by using a PopScope widget. <details> <summary>Full example of nested go_routers</summary> ```dart // Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:go_router/go_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; void main() => runApp(_MyApp()); class _MyApp extends StatelessWidget { final GoRouter router = GoRouter( routes: <RouteBase>[ GoRoute( path: '/', builder: (BuildContext context, GoRouterState state) => _HomePage(), ), GoRoute( path: '/nested_navigators', builder: (BuildContext context, GoRouterState state) => _NestedGoRoutersPage(), ), ], ); @override Widget build(BuildContext context) { return MaterialApp.router( routerConfig: router, ); } } class _HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Nested Navigators Example'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text('Home Page'), const Text('A system back gesture here will exit the app.'), const SizedBox(height: 20.0), ListTile( title: const Text('Nested go_router route'), subtitle: const Text('This route has another go_router in addition to the one used with MaterialApp above.'), onTap: () { context.push('/nested_navigators'); }, ), ], ), ), ); } } class _NestedGoRoutersPage extends StatefulWidget { @override State<_NestedGoRoutersPage> createState() => _NestedGoRoutersPageState(); } class _NestedGoRoutersPageState extends State<_NestedGoRoutersPage> { late final GoRouter _router; final GlobalKey<NavigatorState> _nestedNavigatorKey = GlobalKey<NavigatorState>(); // If the nested navigator has routes that can be popped, then we want to // block the root navigator from handling the pop so that the nested navigator // can handle it instead. bool get _popEnabled { // canPop will throw an error if called before build. Is this the best way // to avoid that? return _nestedNavigatorKey.currentState == null ? true : !_router.canPop(); } void _onRouterChanged() { // Here the _router reports the location correctly, but canPop is still out // of date. Hence the post frame callback. SchedulerBinding.instance.addPostFrameCallback((Duration duration) { setState(() {}); }); } @override void initState() { super.initState(); final BuildContext rootContext = context; _router = GoRouter( navigatorKey: _nestedNavigatorKey, routes: [ GoRoute( path: '/', builder: (BuildContext context, GoRouterState state) => _LinksPage( title: 'Nested once - home route', backgroundColor: Colors.indigo, onBack: () { rootContext.pop(); }, buttons: <Widget>[ TextButton( onPressed: () { context.push('/two'); }, child: const Text('Go to another route in this nested Navigator'), ), ], ), ), GoRoute( path: '/two', builder: (BuildContext context, GoRouterState state) => _LinksPage( backgroundColor: Colors.indigo.withBlue(255), title: 'Nested once - page two', ), ), ], ); _router.addListener(_onRouterChanged); } @override void dispose() { _router.removeListener(_onRouterChanged); super.dispose(); } @override Widget build(BuildContext context) { return PopScope( popEnabled: _popEnabled, onPopped: (bool success) { if (success) { return; } _router.pop(); }, child: Router<Object>.withConfig( restorationScopeId: 'router-2', config: _router, ), ); } } class _LinksPage extends StatelessWidget { const _LinksPage ({ required this.backgroundColor, this.buttons = const <Widget>[], this.onBack, required this.title, }); final Color backgroundColor; final List<Widget> buttons; final VoidCallback? onBack; final String title; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: backgroundColor, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text(title), //const Text('A system back here will go back to Nested Navigators Page One'), ...buttons, TextButton( onPressed: onBack ?? () { context.pop(); }, child: const Text('Go back'), ), ], ), ), ); } } ``` </details> ### Resources Fixes https://github.com/flutter/flutter/issues/109513 Depends on engine PR https://github.com/flutter/engine/pull/39208 ✔️ Design doc: https://docs.google.com/document/d/1BGCWy1_LRrXEB6qeqTAKlk-U2CZlKJ5xI97g45U7azk/edit# Migration guide: https://github.com/flutter/website/pull/8952
369 lines
11 KiB
Dart
369 lines
11 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// Flutter code sample for [NavigationBar] with nested [Navigator] destinations.
|
|
|
|
void main() {
|
|
runApp(const MaterialApp(home: Home()));
|
|
}
|
|
|
|
class Home extends StatefulWidget {
|
|
const Home({super.key});
|
|
|
|
@override
|
|
State<Home> createState() => _HomeState();
|
|
}
|
|
|
|
class _HomeState extends State<Home> with TickerProviderStateMixin<Home> {
|
|
static const List<Destination> allDestinations = <Destination>[
|
|
Destination(0, 'Teal', Icons.home, Colors.teal),
|
|
Destination(1, 'Cyan', Icons.business, Colors.cyan),
|
|
Destination(2, 'Orange', Icons.school, Colors.orange),
|
|
Destination(3, 'Blue', Icons.flight, Colors.blue),
|
|
];
|
|
|
|
late final List<GlobalKey<NavigatorState>> navigatorKeys;
|
|
late final List<GlobalKey> destinationKeys;
|
|
late final List<AnimationController> destinationFaders;
|
|
late final List<Widget> destinationViews;
|
|
int selectedIndex = 0;
|
|
|
|
AnimationController buildFaderController() {
|
|
final AnimationController controller =
|
|
AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
|
|
controller.addStatusListener((AnimationStatus status) {
|
|
if (status == AnimationStatus.dismissed) {
|
|
setState(() {}); // Rebuild unselected destinations offstage.
|
|
}
|
|
});
|
|
return controller;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
navigatorKeys =
|
|
List<GlobalKey<NavigatorState>>.generate(allDestinations.length, (int index) => GlobalKey()).toList();
|
|
destinationFaders =
|
|
List<AnimationController>.generate(allDestinations.length, (int index) => buildFaderController()).toList();
|
|
destinationFaders[selectedIndex].value = 1.0;
|
|
destinationViews = allDestinations.map((Destination destination) {
|
|
return FadeTransition(
|
|
opacity: destinationFaders[destination.index].drive(CurveTween(curve: Curves.fastOutSlowIn)),
|
|
child: DestinationView(
|
|
destination: destination,
|
|
navigatorKey: navigatorKeys[destination.index],
|
|
),
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final AnimationController controller in destinationFaders) {
|
|
controller.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return NavigatorPopHandler(
|
|
onPop: () {
|
|
final NavigatorState navigator = navigatorKeys[selectedIndex].currentState!;
|
|
navigator.pop();
|
|
},
|
|
child: Scaffold(
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: allDestinations.map((Destination destination) {
|
|
final int index = destination.index;
|
|
final Widget view = destinationViews[index];
|
|
if (index == selectedIndex) {
|
|
destinationFaders[index].forward();
|
|
return Offstage(offstage: false, child: view);
|
|
} else {
|
|
destinationFaders[index].reverse();
|
|
if (destinationFaders[index].isAnimating) {
|
|
return IgnorePointer(child: view);
|
|
}
|
|
return Offstage(child: view);
|
|
}
|
|
}).toList(),
|
|
),
|
|
),
|
|
bottomNavigationBar: NavigationBar(
|
|
selectedIndex: selectedIndex,
|
|
onDestinationSelected: (int index) {
|
|
setState(() {
|
|
selectedIndex = index;
|
|
});
|
|
},
|
|
destinations: allDestinations.map((Destination destination) {
|
|
return NavigationDestination(
|
|
icon: Icon(destination.icon, color: destination.color),
|
|
label: destination.title,
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class Destination {
|
|
const Destination(this.index, this.title, this.icon, this.color);
|
|
final int index;
|
|
final String title;
|
|
final IconData icon;
|
|
final MaterialColor color;
|
|
}
|
|
|
|
class RootPage extends StatelessWidget {
|
|
const RootPage({super.key, required this.destination});
|
|
|
|
final Destination destination;
|
|
|
|
Widget _buildDialog(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text('${destination.title} AlertDialog'),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
},
|
|
child: const Text('OK'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final TextStyle headlineSmall = Theme.of(context).textTheme.headlineSmall!;
|
|
final ButtonStyle buttonStyle = ElevatedButton.styleFrom(
|
|
backgroundColor: destination.color,
|
|
visualDensity: VisualDensity.comfortable,
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
textStyle: headlineSmall,
|
|
);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('${destination.title} RootPage - /'),
|
|
backgroundColor: destination.color,
|
|
),
|
|
backgroundColor: destination.color[50],
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
ElevatedButton(
|
|
style: buttonStyle,
|
|
onPressed: () {
|
|
Navigator.pushNamed(context, '/list');
|
|
},
|
|
child: const Text('Push /list'),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
style: buttonStyle,
|
|
onPressed: () {
|
|
showDialog(
|
|
context: context,
|
|
useRootNavigator: false,
|
|
builder: _buildDialog,
|
|
);
|
|
},
|
|
child: const Text('Local Dialog'),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
style: buttonStyle,
|
|
onPressed: () {
|
|
showDialog(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
builder: _buildDialog,
|
|
);
|
|
},
|
|
child: const Text('Root Dialog'),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
return ElevatedButton(
|
|
style: buttonStyle,
|
|
onPressed: () {
|
|
showBottomSheet(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
width: double.infinity,
|
|
child: Text(
|
|
'${destination.title} BottomSheet\n'
|
|
'Tap the back button to dismiss',
|
|
style: headlineSmall,
|
|
softWrap: true,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
child: const Text('Local BottomSheet'),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ListPage extends StatelessWidget {
|
|
const ListPage({super.key, required this.destination});
|
|
|
|
final Destination destination;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const int itemCount = 50;
|
|
final ButtonStyle buttonStyle = OutlinedButton.styleFrom(
|
|
foregroundColor: destination.color,
|
|
fixedSize: const Size.fromHeight(128),
|
|
textStyle: Theme.of(context).textTheme.headlineSmall,
|
|
);
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('${destination.title} ListPage - /list'),
|
|
backgroundColor: destination.color,
|
|
),
|
|
backgroundColor: destination.color[50],
|
|
body: SizedBox.expand(
|
|
child: ListView.builder(
|
|
itemCount: itemCount,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
|
child: OutlinedButton(
|
|
style: buttonStyle.copyWith(
|
|
backgroundColor: MaterialStatePropertyAll<Color>(
|
|
Color.lerp(destination.color[100], Colors.white, index / itemCount)!,
|
|
),
|
|
),
|
|
onPressed: () {
|
|
Navigator.pushNamed(context, '/text');
|
|
},
|
|
child: Text('Push /text [$index]'),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class TextPage extends StatefulWidget {
|
|
const TextPage({super.key, required this.destination});
|
|
|
|
final Destination destination;
|
|
|
|
@override
|
|
State<TextPage> createState() => _TextPageState();
|
|
}
|
|
|
|
class _TextPageState extends State<TextPage> {
|
|
late final TextEditingController textController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
textController = TextEditingController(text: 'Sample Text');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
textController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData theme = Theme.of(context);
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('${widget.destination.title} TextPage - /list/text'),
|
|
backgroundColor: widget.destination.color,
|
|
),
|
|
backgroundColor: widget.destination.color[50],
|
|
body: Container(
|
|
padding: const EdgeInsets.all(32.0),
|
|
alignment: Alignment.center,
|
|
child: TextField(
|
|
controller: textController,
|
|
style: theme.primaryTextTheme.headlineMedium?.copyWith(
|
|
color: widget.destination.color,
|
|
),
|
|
decoration: InputDecoration(
|
|
focusedBorder: UnderlineInputBorder(
|
|
borderSide: BorderSide(
|
|
color: widget.destination.color,
|
|
width: 3.0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DestinationView extends StatefulWidget {
|
|
const DestinationView({
|
|
super.key,
|
|
required this.destination,
|
|
required this.navigatorKey,
|
|
});
|
|
|
|
final Destination destination;
|
|
final Key navigatorKey;
|
|
|
|
@override
|
|
State<DestinationView> createState() => _DestinationViewState();
|
|
}
|
|
|
|
class _DestinationViewState extends State<DestinationView> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Navigator(
|
|
key: widget.navigatorKey,
|
|
onGenerateRoute: (RouteSettings settings) {
|
|
return MaterialPageRoute<void>(
|
|
settings: settings,
|
|
builder: (BuildContext context) {
|
|
switch (settings.name) {
|
|
case '/':
|
|
return RootPage(destination: widget.destination);
|
|
case '/list':
|
|
return ListPage(destination: widget.destination);
|
|
case '/text':
|
|
return TextPage(destination: widget.destination);
|
|
}
|
|
assert(false);
|
|
return const SizedBox();
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|