
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
406 lines
13 KiB
Dart
406 lines
13 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 'dart:developer';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'backdrop.dart';
|
|
import 'demos.dart';
|
|
|
|
const String _kGalleryAssetsPackage = 'flutter_gallery_assets';
|
|
const Color _kFlutterBlue = Color(0xFF003D75);
|
|
const double _kDemoItemHeight = 64.0;
|
|
const Duration _kFrontLayerSwitchDuration = Duration(milliseconds: 300);
|
|
|
|
class _FlutterLogo extends StatelessWidget {
|
|
const _FlutterLogo();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Container(
|
|
width: 34.0,
|
|
height: 34.0,
|
|
decoration: const BoxDecoration(
|
|
image: DecorationImage(
|
|
image: AssetImage(
|
|
'logos/flutter_white/logo.png',
|
|
package: _kGalleryAssetsPackage,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CategoryItem extends StatelessWidget {
|
|
const _CategoryItem({
|
|
this.category,
|
|
this.onTap,
|
|
});
|
|
|
|
final GalleryDemoCategory? category;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final bool isDark = theme.brightness == Brightness.dark;
|
|
|
|
// This repaint boundary prevents the entire _CategoriesPage from being
|
|
// repainted when the button's ink splash animates.
|
|
return RepaintBoundary(
|
|
child: RawMaterialButton(
|
|
hoverColor: theme.primaryColor.withOpacity(0.05),
|
|
splashColor: theme.primaryColor.withOpacity(0.12),
|
|
highlightColor: Colors.transparent,
|
|
onPressed: onTap,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: const EdgeInsets.all(6.0),
|
|
child: Icon(
|
|
category!.icon,
|
|
size: 60.0,
|
|
color: isDark ? Colors.white : _kFlutterBlue,
|
|
),
|
|
),
|
|
const SizedBox(height: 10.0),
|
|
Container(
|
|
height: 48.0,
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
category!.name,
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.titleMedium!.copyWith(
|
|
fontFamily: 'GoogleSans',
|
|
color: isDark ? Colors.white : _kFlutterBlue,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CategoriesPage extends StatelessWidget {
|
|
const _CategoriesPage({
|
|
this.categories,
|
|
this.onCategoryTap,
|
|
});
|
|
|
|
final Iterable<GalleryDemoCategory>? categories;
|
|
final ValueChanged<GalleryDemoCategory>? onCategoryTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const double aspectRatio = 160.0 / 180.0;
|
|
final List<GalleryDemoCategory> categoriesList = categories!.toList();
|
|
final int columnCount = (MediaQuery.of(context).orientation == Orientation.portrait) ? 2 : 3;
|
|
|
|
return Semantics(
|
|
scopesRoute: true,
|
|
namesRoute: true,
|
|
label: 'categories',
|
|
explicitChildNodes: true,
|
|
child: SingleChildScrollView(
|
|
key: const PageStorageKey<String>('categories'),
|
|
child: LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
final double columnWidth = constraints.biggest.width / columnCount.toDouble();
|
|
final double rowHeight = math.min(225.0, columnWidth * aspectRatio);
|
|
final int rowCount = (categories!.length + columnCount - 1) ~/ columnCount;
|
|
|
|
// This repaint boundary prevents the inner contents of the front layer
|
|
// from repainting when the backdrop toggle triggers a repaint on the
|
|
// LayoutBuilder.
|
|
return RepaintBoundary(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: List<Widget>.generate(rowCount, (int rowIndex) {
|
|
final int columnCountForRow = rowIndex == rowCount - 1
|
|
? categories!.length - columnCount * math.max<int>(0, rowCount - 1)
|
|
: columnCount;
|
|
|
|
return Row(
|
|
children: List<Widget>.generate(columnCountForRow, (int columnIndex) {
|
|
final int index = rowIndex * columnCount + columnIndex;
|
|
final GalleryDemoCategory category = categoriesList[index];
|
|
|
|
return SizedBox(
|
|
width: columnWidth,
|
|
height: rowHeight,
|
|
child: _CategoryItem(
|
|
category: category,
|
|
onTap: () {
|
|
onCategoryTap!(category);
|
|
},
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DemoItem extends StatelessWidget {
|
|
const _DemoItem({ this.demo });
|
|
|
|
final GalleryDemo? demo;
|
|
|
|
void _launchDemo(BuildContext context) {
|
|
if (demo != null) {
|
|
Timeline.instantSync('Start Transition', arguments: <String, String>{
|
|
'from': '/',
|
|
'to': demo!.routeName,
|
|
});
|
|
Navigator.pushNamed(context, demo!.routeName);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final bool isDark = theme.brightness == Brightness.dark;
|
|
// ignore: deprecated_member_use, https://github.com/flutter/flutter/issues/128825
|
|
final double textScaleFactor = MediaQuery.textScalerOf(context).textScaleFactor;
|
|
return RawMaterialButton(
|
|
splashColor: theme.primaryColor.withOpacity(0.12),
|
|
highlightColor: Colors.transparent,
|
|
onPressed: () {
|
|
_launchDemo(context);
|
|
},
|
|
child: Container(
|
|
constraints: BoxConstraints(minHeight: _kDemoItemHeight * textScaleFactor),
|
|
child: Row(
|
|
children: <Widget>[
|
|
Container(
|
|
width: 56.0,
|
|
height: 56.0,
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
demo!.icon,
|
|
size: 24.0,
|
|
color: isDark ? Colors.white : _kFlutterBlue,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
Text(
|
|
demo!.title,
|
|
style: theme.textTheme.titleMedium!.copyWith(
|
|
color: isDark ? Colors.white : const Color(0xFF202124),
|
|
),
|
|
),
|
|
if (demo!.subtitle != null)
|
|
Text(
|
|
demo!.subtitle!,
|
|
style: theme.textTheme.bodyMedium!.copyWith(
|
|
color: isDark ? Colors.white : const Color(0xFF60646B)
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 44.0),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DemosPage extends StatelessWidget {
|
|
const _DemosPage(this.category);
|
|
|
|
final GalleryDemoCategory? category;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// When overriding ListView.padding, it is necessary to manually handle
|
|
// safe areas.
|
|
final double windowBottomPadding = MediaQuery.of(context).padding.bottom;
|
|
return KeyedSubtree(
|
|
key: const ValueKey<String>('GalleryDemoList'), // So the tests can find this ListView
|
|
child: Semantics(
|
|
scopesRoute: true,
|
|
namesRoute: true,
|
|
label: category!.name,
|
|
explicitChildNodes: true,
|
|
child: ListView(
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
key: PageStorageKey<String>(category!.name),
|
|
padding: EdgeInsets.only(top: 8.0, bottom: windowBottomPadding),
|
|
children: kGalleryCategoryToDemos[category!]!.map<Widget>((GalleryDemo demo) {
|
|
return _DemoItem(demo: demo);
|
|
}).toList(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class GalleryHome extends StatefulWidget {
|
|
const GalleryHome({
|
|
super.key,
|
|
this.testMode = false,
|
|
this.optionsPage,
|
|
});
|
|
|
|
final Widget? optionsPage;
|
|
final bool testMode;
|
|
|
|
// In checked mode our MaterialApp will show the default "debug" banner.
|
|
// Otherwise show the "preview" banner.
|
|
static bool showPreviewBanner = true;
|
|
|
|
@override
|
|
State<GalleryHome> createState() => _GalleryHomeState();
|
|
}
|
|
|
|
class _GalleryHomeState extends State<GalleryHome> with SingleTickerProviderStateMixin {
|
|
static final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
|
late AnimationController _controller;
|
|
GalleryDemoCategory? _category;
|
|
|
|
static Widget _topHomeLayout(Widget? currentChild, List<Widget> previousChildren) {
|
|
return Stack(
|
|
alignment: Alignment.topCenter,
|
|
children: <Widget>[
|
|
...previousChildren,
|
|
if (currentChild != null) currentChild,
|
|
],
|
|
);
|
|
}
|
|
|
|
static const AnimatedSwitcherLayoutBuilder _centerHomeLayout = AnimatedSwitcher.defaultLayoutBuilder;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: const Duration(milliseconds: 600),
|
|
debugLabel: 'preview banner',
|
|
vsync: this,
|
|
)..forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final bool isDark = theme.brightness == Brightness.dark;
|
|
final MediaQueryData media = MediaQuery.of(context);
|
|
final bool centerHome = media.orientation == Orientation.portrait && media.size.height < 800.0;
|
|
|
|
const Curve switchOutCurve = Interval(0.4, 1.0, curve: Curves.fastOutSlowIn);
|
|
const Curve switchInCurve = Interval(0.4, 1.0, curve: Curves.fastOutSlowIn);
|
|
|
|
Widget home = Scaffold(
|
|
key: _scaffoldKey,
|
|
backgroundColor: isDark ? _kFlutterBlue : theme.primaryColor,
|
|
body: SafeArea(
|
|
bottom: false,
|
|
child: PopScope(
|
|
canPop: _category == null,
|
|
onPopInvoked: (bool didPop) {
|
|
if (didPop) {
|
|
return;
|
|
}
|
|
// Pop the category page if Android back button is pressed.
|
|
setState(() => _category = null);
|
|
},
|
|
child: Backdrop(
|
|
backTitle: const Text('Options'),
|
|
backLayer: widget.optionsPage,
|
|
frontAction: AnimatedSwitcher(
|
|
duration: _kFrontLayerSwitchDuration,
|
|
switchOutCurve: switchOutCurve,
|
|
switchInCurve: switchInCurve,
|
|
child: _category == null
|
|
? const _FlutterLogo()
|
|
: IconButton(
|
|
icon: const BackButtonIcon(),
|
|
tooltip: 'Back',
|
|
onPressed: () => setState(() => _category = null),
|
|
),
|
|
),
|
|
frontTitle: AnimatedSwitcher(
|
|
duration: _kFrontLayerSwitchDuration,
|
|
child: _category == null
|
|
? const Text('Flutter gallery')
|
|
: Text(_category!.name),
|
|
),
|
|
frontHeading: widget.testMode ? null : Container(height: 24.0),
|
|
frontLayer: AnimatedSwitcher(
|
|
duration: _kFrontLayerSwitchDuration,
|
|
switchOutCurve: switchOutCurve,
|
|
switchInCurve: switchInCurve,
|
|
layoutBuilder: centerHome ? _centerHomeLayout : _topHomeLayout,
|
|
child: _category != null
|
|
? _DemosPage(_category)
|
|
: _CategoriesPage(
|
|
categories: kAllGalleryDemoCategories,
|
|
onCategoryTap: (GalleryDemoCategory category) {
|
|
setState(() => _category = category);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
assert(() {
|
|
GalleryHome.showPreviewBanner = false;
|
|
return true;
|
|
}());
|
|
|
|
if (GalleryHome.showPreviewBanner) {
|
|
home = Stack(
|
|
fit: StackFit.expand,
|
|
children: <Widget>[
|
|
home,
|
|
FadeTransition(
|
|
opacity: CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
|
child: const Banner(
|
|
message: 'PREVIEW',
|
|
location: BannerLocation.topEnd,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
home = AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: SystemUiOverlayStyle.light,
|
|
child: home,
|
|
);
|
|
|
|
return home;
|
|
}
|
|
}
|