flutter/examples/api/lib/material/navigation_bar/navigation_bar.2.dart
Justin McCandless dedd100ebd
Predictive back support for root routes (#120385)
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
2023-08-04 20:44:44 +00:00

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();
},
);
},
);
}
}