mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00

Fixes #33799 Allows for a route to inform the route below it in the navigation stack how to animate when the topmost route enters are leaves the stack. It does this by making a `DelegatedTransition` available for the previous route to look up and use. If available, the route lower in the stack will wrap it's transition builders with that delegated transition and use it instead of it's default secondary transition. This is what the sample code in this PR shows an app that is able to use both a Material zoom transition and a Cupertino slide transition in one app. It also includes a custom vertical transition. Every page animates off the screen in a way to match up with the incoming page's transition. When popped, the correct transitions play in reverse. https://github.com/user-attachments/assets/1fc910fa-8cde-4e05-898e-daad8ff4a697 The below video shows this logic making a pseudo iOS styled sheet transition. https://github.com/flutter/flutter/assets/58190796/207163d8-d87f-48b1-aad9-7e770d1d96c5 All existing page transitions in Flutter will be overwritten by the incoming route if a `delegatedTransition` is provided. This can be opted out of through `canTransitionTo` for a new route widget. Of Flutter's existing page transitions, this PR only adds a `DelegatedTransition` for the Zoom and Cupertino transitions. The other transitions possible in Material will get delegated transitions in a later PR.
425 lines
12 KiB
Dart
425 lines
12 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/cupertino.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// This sample demonstrates creating a custom page transition that is able to
|
|
/// override the outgoing transition of the route behind it in the navigation
|
|
/// stack using [DelegatedTransitionBuilder], using a [MaterialApp.router]
|
|
/// pattern of navigation.
|
|
|
|
void main() {
|
|
runApp(FlexibleRouteTransitionsApp());
|
|
}
|
|
|
|
class FlexibleRouteTransitionsApp extends StatelessWidget {
|
|
FlexibleRouteTransitionsApp({super.key});
|
|
|
|
final _MyRouteInformationParser _routeInformationParser = _MyRouteInformationParser();
|
|
final MyRouterDelegate _routerDelegate = MyRouterDelegate();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp.router(
|
|
title: 'Mixing Routes',
|
|
theme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
|
pageTransitionsTheme: const PageTransitionsTheme(
|
|
builders: <TargetPlatform, PageTransitionsBuilder>{
|
|
// By default the zoom builder is used on all platforms but iOS. Normally
|
|
// on iOS the default is the Cupertino sliding transition. Setting
|
|
// it to use zoom on all platforms allows the example to show multiple
|
|
// transitions in one app for all platforms.
|
|
TargetPlatform.iOS: ZoomPageTransitionsBuilder(),
|
|
},
|
|
),
|
|
),
|
|
routeInformationParser: _routeInformationParser,
|
|
routerDelegate: _routerDelegate,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MyRouteInformationParser extends RouteInformationParser<MyPageConfiguration> {
|
|
@override
|
|
SynchronousFuture<MyPageConfiguration> parseRouteInformation(RouteInformation routeInformation) {
|
|
return SynchronousFuture<MyPageConfiguration>(MyPageConfiguration.values.firstWhere((MyPageConfiguration pageConfiguration) {
|
|
return pageConfiguration.uriString == routeInformation.uri.toString();
|
|
},
|
|
orElse: () => MyPageConfiguration.unknown,
|
|
));
|
|
}
|
|
|
|
@override
|
|
RouteInformation? restoreRouteInformation(MyPageConfiguration configuration) {
|
|
return RouteInformation(uri: configuration.uri);
|
|
}
|
|
}
|
|
|
|
class MyRouterDelegate extends RouterDelegate<MyPageConfiguration> {
|
|
final Set<VoidCallback> _listeners = <VoidCallback>{};
|
|
final List<MyPageConfiguration> _pages = <MyPageConfiguration>[];
|
|
|
|
void _notifyListeners() {
|
|
for (final VoidCallback listener in _listeners) {
|
|
listener();
|
|
}
|
|
}
|
|
|
|
void onNavigateToHome() {
|
|
_pages.clear();
|
|
_pages.add(MyPageConfiguration.home);
|
|
_notifyListeners();
|
|
}
|
|
|
|
void onNavigateToZoom() {
|
|
_pages.add(MyPageConfiguration.zoom);
|
|
_notifyListeners();
|
|
}
|
|
|
|
void onNavigateToIOS() {
|
|
_pages.add(MyPageConfiguration.iOS);
|
|
_notifyListeners();
|
|
}
|
|
|
|
void onNavigateToVertical() {
|
|
_pages.add(MyPageConfiguration.vertical);
|
|
_notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void addListener(VoidCallback listener) {
|
|
_listeners.add(listener);
|
|
}
|
|
|
|
@override
|
|
void removeListener(VoidCallback listener) {
|
|
_listeners.remove(listener);
|
|
}
|
|
|
|
@override
|
|
Future<bool> popRoute() {
|
|
if (_pages.isEmpty) {
|
|
return SynchronousFuture<bool>(false);
|
|
}
|
|
_pages.removeLast();
|
|
_notifyListeners();
|
|
return SynchronousFuture<bool>(true);
|
|
}
|
|
|
|
@override
|
|
Future<void> setNewRoutePath(MyPageConfiguration configuration) {
|
|
_pages.add(configuration);
|
|
_notifyListeners();
|
|
return SynchronousFuture<void>(null);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Navigator(
|
|
restorationScopeId: 'root',
|
|
onDidRemovePage: (Page<dynamic> page) {
|
|
_pages.remove(MyPageConfiguration.fromName(page.name!));
|
|
},
|
|
pages: _pages.map((MyPageConfiguration page) => switch (page) {
|
|
MyPageConfiguration.unknown => _MyUnknownPage<void>(),
|
|
MyPageConfiguration.home => _MyHomePage<void>(routerDelegate: this),
|
|
MyPageConfiguration.zoom => _ZoomPage<void>(routerDelegate: this),
|
|
MyPageConfiguration.iOS => _IOSPage<void>(routerDelegate: this),
|
|
MyPageConfiguration.vertical => _VerticalPage<void>(routerDelegate: this),
|
|
}).toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MyUnknownPage<T> extends MaterialPage<T> {
|
|
_MyUnknownPage() : super(
|
|
restorationId: 'unknown-page',
|
|
child: Scaffold(
|
|
appBar: AppBar(title: const Text('404')),
|
|
body: const Center(
|
|
child: Text('404'),
|
|
),
|
|
),
|
|
);
|
|
|
|
@override
|
|
String get name => MyPageConfiguration.unknown.name;
|
|
}
|
|
|
|
class _MyHomePage<T> extends MaterialPage<T> {
|
|
_MyHomePage({required this.routerDelegate}) : super(
|
|
restorationId: 'home-page',
|
|
child: _MyPageScaffold(title: 'Home', routerDelegate: routerDelegate),
|
|
);
|
|
|
|
final MyRouterDelegate routerDelegate;
|
|
|
|
@override
|
|
String get name => MyPageConfiguration.home.name;
|
|
}
|
|
|
|
class _ZoomPage<T> extends MaterialPage<T> {
|
|
_ZoomPage({required this.routerDelegate}) : super(
|
|
restorationId: 'zoom-page',
|
|
child: _MyPageScaffold(title: 'Zoom Route', routerDelegate: routerDelegate),
|
|
);
|
|
|
|
final MyRouterDelegate routerDelegate;
|
|
|
|
@override
|
|
String get name => MyPageConfiguration.zoom.name;
|
|
}
|
|
|
|
class _IOSPage<T> extends CupertinoPage<T> {
|
|
_IOSPage({required this.routerDelegate}) : super(
|
|
restorationId: 'ios-page',
|
|
child: _MyPageScaffold(title: 'Cupertino Route', routerDelegate: routerDelegate),
|
|
);
|
|
|
|
final MyRouterDelegate routerDelegate;
|
|
|
|
@override
|
|
String get name => MyPageConfiguration.iOS.name;
|
|
}
|
|
|
|
class _VerticalPage<T> extends _VerticalTransitionPage<T> {
|
|
_VerticalPage({required this.routerDelegate}) : super(
|
|
restorationId: 'vertical-page',
|
|
child: _MyPageScaffold(title: 'Vertical Route', routerDelegate: routerDelegate),
|
|
);
|
|
|
|
final MyRouterDelegate routerDelegate;
|
|
|
|
@override
|
|
String get name => MyPageConfiguration.vertical.name;
|
|
}
|
|
|
|
class _MyPageScaffold extends StatelessWidget {
|
|
const _MyPageScaffold({required this.title, required this.routerDelegate});
|
|
|
|
final String title;
|
|
|
|
final MyRouterDelegate routerDelegate;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
|
title: Text(title),
|
|
),
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
TextButton(
|
|
onPressed: () {
|
|
routerDelegate.onNavigateToVertical();
|
|
},
|
|
child: const Text('Crazy Vertical Transition'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
routerDelegate.onNavigateToZoom();
|
|
},
|
|
child: const Text('Zoom Transition'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
routerDelegate.onNavigateToIOS();
|
|
},
|
|
child: const Text('Cupertino Transition'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// A Page that applies a _VerticalPageTransition.
|
|
class _VerticalTransitionPage<T> extends Page<T> {
|
|
|
|
const _VerticalTransitionPage({
|
|
required this.child,
|
|
this.maintainState = true,
|
|
this.fullscreenDialog = false,
|
|
this.allowSnapshotting = true,
|
|
super.key,
|
|
super.canPop,
|
|
super.onPopInvoked,
|
|
super.name,
|
|
super.arguments,
|
|
super.restorationId,
|
|
});
|
|
|
|
final Widget child;
|
|
|
|
final bool maintainState;
|
|
|
|
final bool fullscreenDialog;
|
|
|
|
final bool allowSnapshotting;
|
|
|
|
@override
|
|
Route<T> createRoute(BuildContext context) {
|
|
return _PageBasedVerticalPageRoute<T>(page: this);
|
|
}
|
|
}
|
|
|
|
class _PageBasedVerticalPageRoute<T> extends PageRoute<T> {
|
|
_PageBasedVerticalPageRoute({
|
|
required _VerticalTransitionPage<T> page,
|
|
super.allowSnapshotting,
|
|
}) : super(settings: page);
|
|
|
|
_VerticalTransitionPage<T> get _page => settings as _VerticalTransitionPage<T>;
|
|
|
|
@override
|
|
bool get maintainState => _page.maintainState;
|
|
|
|
@override
|
|
bool get fullscreenDialog => _page.fullscreenDialog;
|
|
|
|
@override
|
|
String get debugLabel => '${super.debugLabel}(${_page.name})';
|
|
|
|
@override
|
|
DelegatedTransitionBuilder? get delegatedTransition => _VerticalPageTransition._delegatedTransitionBuilder;
|
|
|
|
@override
|
|
Color? get barrierColor => const Color(0x00000000);
|
|
|
|
@override
|
|
bool get barrierDismissible => false;
|
|
|
|
@override
|
|
String? get barrierLabel => 'Should be no visible barrier...';
|
|
|
|
@override
|
|
bool get opaque => false;
|
|
|
|
@override
|
|
Duration get transitionDuration => const Duration(milliseconds: 2000);
|
|
|
|
@override
|
|
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
|
|
return _page.child;
|
|
}
|
|
|
|
@override
|
|
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
|
return _VerticalPageTransition(
|
|
primaryRouteAnimation: animation,
|
|
secondaryRouteAnimation: secondaryAnimation,
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
// A page transition that slides off the screen vertically, and uses
|
|
// delegatedTransition to ensure that the outgoing route slides with it.
|
|
class _VerticalPageTransition extends StatelessWidget {
|
|
_VerticalPageTransition({
|
|
required Animation<double> primaryRouteAnimation,
|
|
required this.secondaryRouteAnimation,
|
|
required this.child,
|
|
}) : _primaryPositionAnimation =
|
|
CurvedAnimation(
|
|
parent: primaryRouteAnimation,
|
|
curve: _curve,
|
|
reverseCurve: _curve,
|
|
).drive(_kBottomUpTween),
|
|
_secondaryPositionAnimation =
|
|
CurvedAnimation(
|
|
parent: secondaryRouteAnimation,
|
|
curve: _curve,
|
|
reverseCurve: _curve,
|
|
)
|
|
.drive(_kTopDownTween);
|
|
|
|
final Animation<Offset> _primaryPositionAnimation;
|
|
|
|
final Animation<Offset> _secondaryPositionAnimation;
|
|
|
|
final Animation<double> secondaryRouteAnimation;
|
|
|
|
final Widget child;
|
|
|
|
static const Curve _curve = Curves.decelerate;
|
|
|
|
static final Animatable<Offset> _kBottomUpTween = Tween<Offset>(
|
|
begin: const Offset(0.0, 1.0),
|
|
end: Offset.zero,
|
|
);
|
|
|
|
static final Animatable<Offset> _kTopDownTween = Tween<Offset>(
|
|
begin: Offset.zero,
|
|
end: const Offset(0.0, -1.0),
|
|
);
|
|
|
|
// When the _VerticalTransitionPageRoute animates onto or off of the navigation
|
|
// stack, this transition is given to the route below it so that they animate in
|
|
// sync.
|
|
static Widget _delegatedTransitionBuilder(
|
|
BuildContext context,
|
|
Animation<double> animation,
|
|
Animation<double> secondaryAnimation,
|
|
bool allowSnapshotting,
|
|
Widget? child
|
|
) {
|
|
final Animatable<Offset> tween = Tween<Offset>(
|
|
begin: Offset.zero,
|
|
end: const Offset(0.0, -1.0),
|
|
).chain(CurveTween(curve: _curve));
|
|
|
|
return SlideTransition(
|
|
position: secondaryAnimation.drive(tween),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasDirectionality(context));
|
|
final TextDirection textDirection = Directionality.of(context);
|
|
return SlideTransition(
|
|
position: _secondaryPositionAnimation,
|
|
textDirection: textDirection,
|
|
transformHitTests: false,
|
|
child: SlideTransition(
|
|
position: _primaryPositionAnimation,
|
|
textDirection: textDirection,
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
|
child: child,
|
|
)
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum MyPageConfiguration {
|
|
home(uriString: '/'),
|
|
zoom(uriString: '/zoom'),
|
|
iOS(uriString: '/iOS'),
|
|
vertical(uriString: '/vertical'),
|
|
unknown(uriString: '/404');
|
|
|
|
const MyPageConfiguration({
|
|
required this.uriString,
|
|
});
|
|
|
|
final String uriString;
|
|
|
|
static MyPageConfiguration fromName(String testName) {
|
|
return values.firstWhere((MyPageConfiguration page) => page.name == testName);
|
|
}
|
|
|
|
Uri get uri => Uri.parse(uriString);
|
|
}
|