diff --git a/examples/api/lib/widgets/routes/flexible_route_transitions.0.dart b/examples/api/lib/widgets/routes/flexible_route_transitions.0.dart new file mode 100644 index 00000000000..38250fdcc71 --- /dev/null +++ b/examples/api/lib/widgets/routes/flexible_route_transitions.0.dart @@ -0,0 +1,223 @@ +// 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/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]. + +void main() { + runApp(const FlexibleRouteTransitionsApp()); +} + +class FlexibleRouteTransitionsApp extends StatelessWidget { + const FlexibleRouteTransitionsApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Mixing Routes', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + // 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(), + }, + ), + ), + home: const _MyHomePage(title: 'Zoom Page'), + ); + } +} + +class _MyHomePage extends StatelessWidget { + const _MyHomePage({required this.title}); + + final String title; + + @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: [ + TextButton( + onPressed: () { + Navigator.of(context).push( + _VerticalTransitionPageRoute( + builder: (BuildContext context) { + return const _MyHomePage(title: 'Crazy Vertical Page'); + }, + ), + ); + }, + child: const Text('Crazy Vertical Transition'), + ), + TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const _MyHomePage(title: 'Zoom Page'); + }, + ), + ); + }, + child: const Text('Zoom Transition'), + ), + TextButton( + onPressed: () { + final CupertinoPageRoute route = CupertinoPageRoute( + builder: (BuildContext context) { + return const _MyHomePage(title: 'Cupertino Page'); + } + ); + Navigator.of(context).push(route); + }, + child: const Text('Cupertino Transition'), + ), + ], + ), + ), + ); + } +} + +// A PageRoute that applies a _VerticalPageTransition. +class _VerticalTransitionPageRoute extends PageRoute { + + _VerticalTransitionPageRoute({ + required this.builder, + }); + + final WidgetBuilder builder; + + @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 maintainState => true; + + @override + bool get opaque => false; + + @override + Duration get transitionDuration => const Duration(milliseconds: 2000); + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return builder(context); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation 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 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 _primaryPositionAnimation; + + final Animation _secondaryPositionAnimation; + + final Animation secondaryRouteAnimation; + + final Widget child; + + static const Curve _curve = Curves.decelerate; + + static final Animatable _kBottomUpTween = Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ); + + static final Animatable _kTopDownTween = Tween( + 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 animation, + Animation secondaryAnimation, + bool allowSnapshotting, + Widget? child + ) { + final Animatable tween = Tween( + 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, + ) + ), + ); + } +} diff --git a/examples/api/lib/widgets/routes/flexible_route_transitions.1.dart b/examples/api/lib/widgets/routes/flexible_route_transitions.1.dart new file mode 100644 index 00000000000..b2d538dabbd --- /dev/null +++ b/examples/api/lib/widgets/routes/flexible_route_transitions.1.dart @@ -0,0 +1,424 @@ +// 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: { + // 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 { + @override + SynchronousFuture parseRouteInformation(RouteInformation routeInformation) { + return SynchronousFuture(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 { + final Set _listeners = {}; + final List _pages = []; + + 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 popRoute() { + if (_pages.isEmpty) { + return SynchronousFuture(false); + } + _pages.removeLast(); + _notifyListeners(); + return SynchronousFuture(true); + } + + @override + Future setNewRoutePath(MyPageConfiguration configuration) { + _pages.add(configuration); + _notifyListeners(); + return SynchronousFuture(null); + } + + @override + Widget build(BuildContext context) { + return Navigator( + restorationScopeId: 'root', + onDidRemovePage: (Page page) { + _pages.remove(MyPageConfiguration.fromName(page.name!)); + }, + pages: _pages.map((MyPageConfiguration page) => switch (page) { + MyPageConfiguration.unknown => _MyUnknownPage(), + MyPageConfiguration.home => _MyHomePage(routerDelegate: this), + MyPageConfiguration.zoom => _ZoomPage(routerDelegate: this), + MyPageConfiguration.iOS => _IOSPage(routerDelegate: this), + MyPageConfiguration.vertical => _VerticalPage(routerDelegate: this), + }).toList(), + ); + } +} + +class _MyUnknownPage extends MaterialPage { + _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 extends MaterialPage { + _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 extends MaterialPage { + _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 extends CupertinoPage { + _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 extends _VerticalTransitionPage { + _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: [ + 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 extends Page { + + 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 createRoute(BuildContext context) { + return _PageBasedVerticalPageRoute(page: this); + } +} + +class _PageBasedVerticalPageRoute extends PageRoute { + _PageBasedVerticalPageRoute({ + required _VerticalTransitionPage page, + super.allowSnapshotting, + }) : super(settings: page); + + _VerticalTransitionPage get _page => settings as _VerticalTransitionPage; + + @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 animation, Animation secondaryAnimation) { + return _page.child; + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation 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 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 _primaryPositionAnimation; + + final Animation _secondaryPositionAnimation; + + final Animation secondaryRouteAnimation; + + final Widget child; + + static const Curve _curve = Curves.decelerate; + + static final Animatable _kBottomUpTween = Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ); + + static final Animatable _kTopDownTween = Tween( + 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 animation, + Animation secondaryAnimation, + bool allowSnapshotting, + Widget? child + ) { + final Animatable tween = Tween( + 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); +} diff --git a/examples/api/test/widgets/routes/flexible_route_transitions.0_test.dart b/examples/api/test/widgets/routes/flexible_route_transitions.0_test.dart new file mode 100644 index 00000000000..0deea41b9f9 --- /dev/null +++ b/examples/api/test/widgets/routes/flexible_route_transitions.0_test.dart @@ -0,0 +1,59 @@ +// 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_api_samples/widgets/routes/flexible_route_transitions.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Flexible Transitions App is able to build', (WidgetTester tester) async { + await tester.pumpWidget( + const example.FlexibleRouteTransitionsApp(), + ); + + expect(find.text('Zoom Page'), findsOneWidget); + expect(find.text('Zoom Transition'), findsOneWidget); + expect(find.text('Crazy Vertical Transition'), findsOneWidget); + expect(find.text('Cupertino Transition'), findsOneWidget); + + await tester.tap(find.text('Cupertino Transition')); + + await tester.pumpAndSettle(); + + expect(find.text('Zoom Page'), findsNothing); + expect(find.text('Cupertino Page'), findsOneWidget); + }); + + testWidgets('A vertical slide animation is passed to the previous route', (WidgetTester tester) async { + await tester.pumpWidget( + const example.FlexibleRouteTransitionsApp(), + ); + + expect(find.text('Zoom Page'), findsOneWidget); + + // Save the Y coordinate of the page title. + double lastYPosition = tester.getTopLeft(find.text('Zoom Page')).dy; + + await tester.tap(find.text('Crazy Vertical Transition')); + + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 10)); + // The current Y coordinate of the page title should be lower than it was + // before as the page slides upwards. + expect(tester.getTopLeft(find.text('Zoom Page')).dy, lessThan(lastYPosition)); + lastYPosition = tester.getTopLeft(find.text('Zoom Page')).dy; + + await tester.pump(const Duration(milliseconds: 10)); + expect(tester.getTopLeft(find.text('Zoom Page')).dy, lessThan(lastYPosition)); + lastYPosition = tester.getTopLeft(find.text('Zoom Page')).dy; + + await tester.pump(const Duration(milliseconds: 10)); + expect(tester.getTopLeft(find.text('Zoom Page')).dy, lessThan(lastYPosition)); + lastYPosition = tester.getTopLeft(find.text('Zoom Page')).dy; + + await tester.pumpAndSettle(); + + expect(find.text('Crazy Vertical Page'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/routes/flexible_route_transitions.1_test.dart b/examples/api/test/widgets/routes/flexible_route_transitions.1_test.dart new file mode 100644 index 00000000000..5e3f8a47726 --- /dev/null +++ b/examples/api/test/widgets/routes/flexible_route_transitions.1_test.dart @@ -0,0 +1,63 @@ +// 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_api_samples/widgets/routes/flexible_route_transitions.1.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../navigator_utils.dart'; + +void main() { + testWidgets('Flexible Transitions App is able to build', (WidgetTester tester) async { + await tester.pumpWidget( + FlexibleRouteTransitionsApp(), + ); + + expect(find.text('Zoom Transition'), findsOneWidget); + expect(find.text('Crazy Vertical Transition'), findsOneWidget); + expect(find.text('Cupertino Transition'), findsOneWidget); + }); + + testWidgets('on Pop the correct page shows', (WidgetTester tester) async { + await tester.pumpWidget( + FlexibleRouteTransitionsApp(), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('Cupertino Route'), findsNothing); + expect(find.text('Zoom Route'), findsNothing); + + await tester.tap(find.text('Zoom Transition')); + + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsNothing); + expect(find.text('Cupertino Route'), findsNothing); + expect(find.text('Zoom Route'), findsOneWidget); + + await tester.tap(find.text('Cupertino Transition')); + + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsNothing); + expect(find.text('Cupertino Route'), findsOneWidget); + expect(find.text('Zoom Route'), findsNothing); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsNothing); + expect(find.text('Cupertino Route'), findsNothing); + expect(find.text('Zoom Route'), findsOneWidget); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('Cupertino Route'), findsNothing); + expect(find.text('Zoom Route'), findsNothing); + }); +} diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index 51f650c6ee6..48d40976bd0 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -161,7 +161,18 @@ mixin CupertinoRouteTransitionMixin on PageRoute { @override bool canTransitionTo(TransitionRoute nextRoute) { // Don't perform outgoing animation if the next route is a fullscreen dialog. - return nextRoute is CupertinoRouteTransitionMixin && !nextRoute.fullscreenDialog; + final bool nextRouteIsNotFullscreen = (nextRoute is! PageRoute) || !nextRoute.fullscreenDialog; + + // If the next route has a delegated transition, then this route is able to + // use that delegated transition to smoothly sync with the next route's + // transition. + final bool nextRouteHasDelegatedTransition = nextRoute is ModalRoute + && nextRoute.delegatedTransition != null; + + // Otherwise if the next route has the same route transition mixin as this + // one, then this route will already be synced with its transition. + return nextRouteIsNotFullscreen && + ((nextRoute is CupertinoRouteTransitionMixin) || nextRouteHasDelegatedTransition); } @override @@ -286,6 +297,9 @@ class CupertinoPageRoute extends PageRoute with CupertinoRouteTransitionMi assert(opaque); } + @override + DelegatedTransitionBuilder? get delegatedTransition => CupertinoPageTransition.delegatedTransition; + /// Builds the primary contents of the route. final WidgetBuilder builder; @@ -314,6 +328,9 @@ class _PageBasedCupertinoPageRoute extends PageRoute with CupertinoRouteTr assert(opaque); } + @override + DelegatedTransitionBuilder? get delegatedTransition => this.fullscreenDialog ? null : CupertinoPageTransition.delegatedTransition; + CupertinoPage get _page => settings as CupertinoPage; @override @@ -415,6 +432,27 @@ class CupertinoPageTransition extends StatefulWidget { /// Used to precisely track back gesture drags. final bool linearTransition; + /// The Cupertino styled [DelegatedTransitionBuilder] provided to the previous + /// route. + /// + /// {@macro flutter.widgets.delegatedTransition} + static Widget? delegatedTransition(BuildContext context, Animation animation, Animation secondaryAnimation, bool allowSnapshotting, Widget? child) { + final Animation delegatedPositionAnimation = + CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.linearToEaseOut, + reverseCurve: Curves.easeInToLinear, + ).drive(_kMiddleLeftTween); + assert(debugCheckHasDirectionality(context)); + final TextDirection textDirection = Directionality.of(context); + return SlideTransition( + position: delegatedPositionAnimation, + textDirection: textDirection, + transformHitTests: false, + child: child, + ); + } + @override State createState() => _CupertinoPageTransitionState(); } diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index 57fb860f519..2b87aaa9783 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -95,11 +95,33 @@ mixin MaterialRouteTransitionMixin on PageRoute { @override String? get barrierLabel => null; + @override + DelegatedTransitionBuilder? get delegatedTransition => _delegatedTransition; + + static Widget? _delegatedTransition(BuildContext context, Animation animation, Animation secondaryAnimation, bool allowSnapshotting, Widget? child) { + final PageTransitionsTheme theme = Theme.of(context).pageTransitionsTheme; + final TargetPlatform platform = Theme.of(context).platform; + final DelegatedTransitionBuilder? themeDelegatedTransition = theme.delegatedTransition(platform); + return themeDelegatedTransition != null ? themeDelegatedTransition(context, animation, secondaryAnimation, allowSnapshotting, child) : null; + } + @override bool canTransitionTo(TransitionRoute nextRoute) { + // Don't perform outgoing animation if the next route is a fullscreen dialog, + // or there is no matching transition to use. // Don't perform outgoing animation if the next route is a fullscreen dialog. - return (nextRoute is MaterialRouteTransitionMixin && !nextRoute.fullscreenDialog) - || (nextRoute is CupertinoRouteTransitionMixin && !nextRoute.fullscreenDialog); + final bool nextRouteIsNotFullscreen = (nextRoute is! PageRoute) || !nextRoute.fullscreenDialog; + + // If the next route has a delegated transition, then this route is able to + // use that delegated transition to smoothly sync with the next route's + // transition. + final bool nextRouteHasDelegatedTransition = nextRoute is ModalRoute + && nextRoute.delegatedTransition != null; + + // Otherwise if the next route has the same route transition mixin as this + // one, then this route will already be synced with its transition. + return nextRouteIsNotFullscreen && + ((nextRoute is MaterialRouteTransitionMixin) || nextRouteHasDelegatedTransition); } @override diff --git a/packages/flutter/lib/src/material/page_transitions_theme.dart b/packages/flutter/lib/src/material/page_transitions_theme.dart index 70aeb06054e..940937d9666 100644 --- a/packages/flutter/lib/src/material/page_transitions_theme.dart +++ b/packages/flutter/lib/src/material/page_transitions_theme.dart @@ -305,33 +305,14 @@ class _ZoomPageTransition extends StatelessWidget { child: child, ); }, - child: DualTransitionBuilder( - animation: ReverseAnimation(secondaryAnimation), - forwardBuilder: ( - BuildContext context, - Animation animation, - Widget? child, - ) { - return _ZoomEnterTransition( - animation: animation, - allowSnapshotting: allowSnapshotting && allowEnterRouteSnapshotting , - reverse: true, - backgroundColor: enterTransitionBackgroundColor, - child: child, - ); - }, - reverseBuilder: ( - BuildContext context, - Animation animation, - Widget? child, - ) { - return _ZoomExitTransition( - animation: animation, - allowSnapshotting: allowSnapshotting, - child: child, - ); - }, - child: child, + child: ZoomPageTransitionsBuilder._snapshotAwareDelegatedTransition( + context, + animation, + secondaryAnimation, + child, + allowSnapshotting, + allowEnterRouteSnapshotting, + enterTransitionBackgroundColor ), ); } @@ -575,6 +556,11 @@ abstract class PageTransitionsBuilder { /// const constructors so that they can be used in const expressions. const PageTransitionsBuilder(); + /// Provideds a secondary transition to the previous route. + /// + /// {@macro flutter.widgets.delegatedTransition} + DelegatedTransitionBuilder? get delegatedTransition => null; + /// Wraps the child with one or more transition widgets which define how [route] /// arrives on and leaves the screen. /// @@ -728,6 +714,44 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder { // for the Impeller backend. static const bool _kProfileForceDisableSnapshotting = bool.fromEnvironment('flutter.benchmarks.force_disable_snapshot'); + @override + DelegatedTransitionBuilder? get delegatedTransition => (BuildContext context, Animation animation, Animation secondaryAnimation, bool allowSnapshotting, Widget? child) + => _snapshotAwareDelegatedTransition(context, animation, secondaryAnimation, child, allowSnapshotting && this.allowSnapshotting, allowEnterRouteSnapshotting, backgroundColor); + + // A transition builder that takes into account the snapshotting properties of + // ZoomPageTransitionsBuilder. + static Widget _snapshotAwareDelegatedTransition(BuildContext context, Animation animation, Animation secondaryAnimation, Widget? child, bool allowSnapshotting, bool allowEnterRouteSnapshotting, Color? backgroundColor) { + final Color enterTransitionBackgroundColor = backgroundColor ?? Theme.of(context).colorScheme.surface; + return DualTransitionBuilder( + animation: ReverseAnimation(secondaryAnimation), + forwardBuilder: ( + BuildContext context, + Animation animation, + Widget? child, + ) { + return _ZoomEnterTransition( + animation: animation, + allowSnapshotting: allowSnapshotting && allowEnterRouteSnapshotting, + reverse: true, + backgroundColor: enterTransitionBackgroundColor, + child: child, + ); + }, + reverseBuilder: ( + BuildContext context, + Animation animation, + Widget? child, + ) { + return _ZoomExitTransition( + animation: animation, + allowSnapshotting: allowSnapshotting, + child: child, + ); + }, + child: child, + ); + } + @override Widget buildTransitions( PageRoute route, @@ -771,6 +795,9 @@ class CupertinoPageTransitionsBuilder extends PageTransitionsBuilder { /// Constructs a page transition animation that matches the iOS transition. const CupertinoPageTransitionsBuilder(); + @override + DelegatedTransitionBuilder? get delegatedTransition => CupertinoPageTransition.delegatedTransition; + @override Widget buildTransitions( PageRoute route, @@ -853,6 +880,16 @@ class PageTransitionsTheme with Diagnosticable { ); } + /// Provides the delegate transition for the target platform. + /// + /// {@macro flutter.widgets.delegatedTransition} + DelegatedTransitionBuilder? delegatedTransition(TargetPlatform platform) { + final PageTransitionsBuilder matchingBuilder = + builders[platform] ?? const ZoomPageTransitionsBuilder(); + + return matchingBuilder.delegatedTransition; + } + // Map the builders to a list with one PageTransitionsBuilder per platform for // the operator == overload. List _all(Map builders) { diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 0a21b567f0b..29f398356b1 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -1078,7 +1078,7 @@ class _ModalScopeState extends State<_ModalScope> { child: ListenableBuilder( listenable: _listenable, // immutable builder: (BuildContext context, Widget? child) { - return widget.route.buildTransitions( + return widget.route._buildFlexibleTransitions( context, widget.route.animation!, widget.route.secondaryAnimation!, @@ -1417,6 +1417,77 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute null; + + /// The [DelegatedTransitionBuilder] received from the route above this one in + /// the navigation stack. + /// + /// {@macro flutter.widgets.delegatedTransition} + /// + /// The `receivedTransition` will use the above route's [delegatedTransition] in + /// order to show the right route transition when the above route either enters + /// or leaves the navigation stack. If not null, the `receivedTransition` will + /// wrap the route content. + @visibleForTesting + DelegatedTransitionBuilder? receivedTransition; + + // Wraps the transitions of this route with a DelegatedTransitionBuilder, when + // _receivedTransition is not null. + Widget _buildFlexibleTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (receivedTransition == null) { + return buildTransitions(context, animation, secondaryAnimation, child); + } + + // Create a static proxy animation to supress the original secondary transition. + final ProxyAnimation proxyAnimation = ProxyAnimation(); + + final Widget proxiedOriginalTransitions = buildTransitions(context, animation, proxyAnimation, child); + + // If recievedTransitions return null, then we want to return the original transitions, + // but with the secondary animation still proxied. This keeps a desynched + // animation from playing. + return receivedTransition!(context, animation, secondaryAnimation, allowSnapshotting, proxiedOriginalTransitions) ?? + proxiedOriginalTransitions; + } + @override void install() { super.install(); @@ -1933,12 +2004,22 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute? nextRoute) { + if (nextRoute is ModalRoute && canTransitionTo(nextRoute) && nextRoute.delegatedTransition != this.delegatedTransition) { + receivedTransition = nextRoute.delegatedTransition; + } else { + receivedTransition = null; + } super.didChangeNext(nextRoute); changedInternalState(); } @override void didPopNext(Route nextRoute) { + if (nextRoute is ModalRoute && canTransitionTo(nextRoute) && nextRoute.delegatedTransition != this.delegatedTransition) { + receivedTransition = nextRoute.delegatedTransition; + } else { + receivedTransition = null; + } super.didPopNext(nextRoute); changedInternalState(); _maybeDispatchNavigationNotification(); diff --git a/packages/flutter/lib/src/widgets/transitions.dart b/packages/flutter/lib/src/widgets/transitions.dart index c69a66f93f7..bfab9a50245 100644 --- a/packages/flutter/lib/src/widgets/transitions.dart +++ b/packages/flutter/lib/src/widgets/transitions.dart @@ -142,6 +142,17 @@ class _AnimatedState extends State { Widget build(BuildContext context) => widget.build(context); } +/// Signature for a builder used to control a page's exit transition. +/// +/// When a new route enters the stack, the `animation` argument is typically +/// used to control the entery and exit transition of the topmost route. The exit +/// transition of the route just below the new route is controlled with the +/// `secondaryAnimation`, which also controls the transition of the old route +/// when the topmost route is popped off the stack. +/// +/// Typically used as the argument for [ModalRoute.delegatedTransition]. +typedef DelegatedTransitionBuilder = Widget? Function(BuildContext context, Animation animation, Animation secondaryAnimation, bool allowSnapshotting, Widget? child); + /// Animates the position of a widget relative to its normal position. /// /// The translation is expressed as an [Offset] scaled to the child's size. For diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart index a058ed1bd37..461088284ca 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -5,6 +5,7 @@ import 'dart:collection'; import 'dart:ui'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -1918,6 +1919,336 @@ void main() { expect(modalRoute, isNotNull); expect(modalRoute!.requestFocus, isFalse); }); + + testWidgets('outgoing route receives a delegated transition from the new route', (WidgetTester tester) async { + final GlobalKey navigatorKey = GlobalKey(); + + final MaterialPageRoute materialPageRoute = MaterialPageRoute( + builder: (BuildContext context) { + return Scaffold( + body: TextButton( + onPressed: () { + final CupertinoPageRoute route = CupertinoPageRoute( + builder: (BuildContext context) { + return const Text('Cupertino Transition'); + } + ); + Navigator.of(context).push(route); + }, + child: const Text('Cupertino Transition'), + ), + ); + } + ); + + await tester.pumpWidget(MaterialApp( + navigatorKey: navigatorKey, + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.iOS: ZoomPageTransitionsBuilder(), + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), + ), + home: Scaffold( + body: TextButton( + onPressed: () { + navigatorKey.currentState!.push(materialPageRoute); + }, + child: const Text('Material Route Transition'), + ), + ), + ) + ); + + expect(materialPageRoute.receivedTransition, null); + + await tester.tap(find.text('Material Route Transition')); + + await tester.pumpAndSettle(); + + expect(find.text('Cupertino Transition'), findsOneWidget); + expect(find.text('Material Route Transition'), findsNothing); + + expect(materialPageRoute.receivedTransition, null); + + await tester.tap(find.text('Cupertino Transition')); + + await tester.pumpAndSettle(); + + expect(materialPageRoute.receivedTransition, isNotNull); + expect(materialPageRoute.receivedTransition, CupertinoPageTransition.delegatedTransition); + }); + + testWidgets('outgoing route does not receive a delegated transition from a route with the same transition', (WidgetTester tester) async { + final GlobalKey navigatorKey = GlobalKey(); + + final MaterialPageRoute materialPageRoute = MaterialPageRoute( + builder: (BuildContext context) { + return Scaffold( + body: TextButton( + onPressed: () { + final MaterialPageRoute route = MaterialPageRoute( + builder: (BuildContext context) { + return const Text('Page 3'); + } + ); + Navigator.of(context).push(route); + }, + child: const Text('Second Material Transition'), + ), + ); + } + ); + + await tester.pumpWidget(MaterialApp( + navigatorKey: navigatorKey, + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.iOS: ZoomPageTransitionsBuilder(), + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), + ), + home: Scaffold( + body: TextButton( + onPressed: () { + navigatorKey.currentState!.push(materialPageRoute); + }, + child: const Text('Material Route Transition'), + ), + ), + ) + ); + + expect(materialPageRoute.receivedTransition, null); + + await tester.tap(find.text('Material Route Transition')); + + await tester.pumpAndSettle(); + + expect(materialPageRoute.receivedTransition, null); + + await tester.tap(find.text('Second Material Transition')); + + await tester.pumpAndSettle(); + + expect(materialPageRoute.receivedTransition, null); + }); + + testWidgets('outgoing route does not receive a delegated transition from a route with the same un-snapshotted transition', (WidgetTester tester) async { + final GlobalKey navigatorKey = GlobalKey(); + + final MaterialPageRoute materialPageRoute = MaterialPageRoute( + allowSnapshotting: false, + builder: (BuildContext context) { + return Scaffold( + body: TextButton( + onPressed: () { + final MaterialPageRoute route = MaterialPageRoute( + allowSnapshotting: false, + builder: (BuildContext context) { + return const Text('Page 3'); + } + ); + Navigator.of(context).push(route); + }, + child: const Text('Second Material Transition'), + ), + ); + } + ); + + await tester.pumpWidget(MaterialApp( + navigatorKey: navigatorKey, + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.iOS: ZoomPageTransitionsBuilder(), + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), + ), + home: Scaffold( + body: TextButton( + onPressed: () { + navigatorKey.currentState!.push(materialPageRoute); + }, + child: const Text('Material Route Transition'), + ), + ), + ) + ); + + expect(materialPageRoute.receivedTransition, null); + + await tester.tap(find.text('Material Route Transition')); + + await tester.pumpAndSettle(); + + expect(materialPageRoute.receivedTransition, null); + + await tester.tap(find.text('Second Material Transition')); + + await tester.pumpAndSettle(); + + expect(materialPageRoute.receivedTransition, null); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + + expect(materialPageRoute.receivedTransition, null); + }); + + testWidgets('a received transition animates the same as a non-received transition', (WidgetTester tester) async { + final GlobalKey navigatorKey = GlobalKey(); + + const Key firstPlaceholderKey = Key('First Placeholder'); + const Key secondPlaceholderKey = Key('Second Placeholder'); + + final CupertinoPageRoute cupertinoPageRoute = CupertinoPageRoute( + builder: (BuildContext context) { + return Column( + children: [ + const Placeholder(key: secondPlaceholderKey), + TextButton( + onPressed: () { + final CupertinoPageRoute route = CupertinoPageRoute( + builder: (BuildContext context) { + return Column( + children: [ + TextButton( + onPressed: () {}, + child: const Text('Page 3') + ), + ], + ); + } + ); + Navigator.of(context).push(route); + }, + child: const Text('Second Cupertino Transition'), + ), + ], + ); + } + ); + + await tester.pumpWidget(MaterialApp( + navigatorKey: navigatorKey, + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.iOS: ZoomPageTransitionsBuilder(), + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), + ), + home: Column( + children: [ + const Placeholder(key: firstPlaceholderKey), + TextButton( + onPressed: () { + navigatorKey.currentState!.push(cupertinoPageRoute); + }, + child: const Text('First Cupertino Transition'), + ), + ] + ), + ) + ); + + // Start first page transition. This one will be playing the delegated transition + // received from Cupertino page route. + await tester.tap(find.text('First Cupertino Transition')); + + await tester.pump(); + + // Save the position of element on the screen at certain intervals + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalOne = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalTwo = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalThree = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalFour = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalFive = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalSix = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalSeven = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalEight = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalNine = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 40)); + final double xLocationIntervalTen = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 50)); + final double xLocationIntervalEleven = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + await tester.pump(const Duration(milliseconds: 50)); + final double xLocationIntervalTwelve = tester.getTopLeft(find.byKey(firstPlaceholderKey)).dx; + + // Give time to the animation to finish + await tester.pumpAndSettle(const Duration(milliseconds: 1)); + + // Start the second page transition. This time it's the default secondary + // transition of a Cupertino page, with no delegation. + await tester.tap(find.text('Second Cupertino Transition')); + + await tester.pump(); + + // Compare against the values from before. + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalOne, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalTwo, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalThree, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalFour, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalFive, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalSix, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalSeven, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalEight, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalNine, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalTen, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalEleven, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byKey(secondPlaceholderKey)).dx, moreOrLessEquals(xLocationIntervalTwelve, epsilon: 0.1)); + }); }); testWidgets('can be dismissed with escape keyboard shortcut', (WidgetTester tester) async {