Skip to content

Commit 2079f34

Browse files
authored
[WIP] Predictive back support for routes (#141373)
A new page transition, PredictiveBackPageTransitionsBuilder, which handles predictive back gestures on Android (where supported).
1 parent ab4db16 commit 2079f34

14 files changed

+1490
-107
lines changed

packages/flutter/lib/material.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export 'src/material/page_transitions_theme.dart';
137137
export 'src/material/paginated_data_table.dart';
138138
export 'src/material/popup_menu.dart';
139139
export 'src/material/popup_menu_theme.dart';
140+
export 'src/material/predictive_back_page_transitions_builder.dart';
140141
export 'src/material/progress_indicator.dart';
141142
export 'src/material/progress_indicator_theme.dart';
142143
export 'src/material/radio.dart';

packages/flutter/lib/services.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export 'src/services/mouse_cursor.dart';
3333
export 'src/services/mouse_tracking.dart';
3434
export 'src/services/platform_channel.dart';
3535
export 'src/services/platform_views.dart';
36+
export 'src/services/predictive_back_event.dart';
3637
export 'src/services/process_text.dart';
3738
export 'src/services/raw_keyboard.dart';
3839
export 'src/services/raw_keyboard_android.dart';

packages/flutter/lib/src/cupertino/route.dart

Lines changed: 3 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -156,79 +156,6 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
156156
return nextRoute is CupertinoRouteTransitionMixin && !nextRoute.fullscreenDialog;
157157
}
158158

159-
/// True if an iOS-style back swipe pop gesture is currently underway for [route].
160-
///
161-
/// This just checks the route's [NavigatorState.userGestureInProgress].
162-
///
163-
/// See also:
164-
///
165-
/// * [popGestureEnabled], which returns true if a user-triggered pop gesture
166-
/// would be allowed.
167-
static bool isPopGestureInProgress(PageRoute<dynamic> route) {
168-
return route.navigator!.userGestureInProgress;
169-
}
170-
171-
/// True if an iOS-style back swipe pop gesture is currently underway for this route.
172-
///
173-
/// See also:
174-
///
175-
/// * [isPopGestureInProgress], which returns true if a Cupertino pop gesture
176-
/// is currently underway for specific route.
177-
/// * [popGestureEnabled], which returns true if a user-triggered pop gesture
178-
/// would be allowed.
179-
bool get popGestureInProgress => isPopGestureInProgress(this);
180-
181-
/// Whether a pop gesture can be started by the user.
182-
///
183-
/// Returns true if the user can edge-swipe to a previous route.
184-
///
185-
/// Returns false once [isPopGestureInProgress] is true, but
186-
/// [isPopGestureInProgress] can only become true if [popGestureEnabled] was
187-
/// true first.
188-
///
189-
/// This should only be used between frames, not during build.
190-
bool get popGestureEnabled => _isPopGestureEnabled(this);
191-
192-
static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
193-
// If there's nothing to go back to, then obviously we don't support
194-
// the back gesture.
195-
if (route.isFirst) {
196-
return false;
197-
}
198-
// If the route wouldn't actually pop if we popped it, then the gesture
199-
// would be really confusing (or would skip internal routes), so disallow it.
200-
if (route.willHandlePopInternally) {
201-
return false;
202-
}
203-
// If attempts to dismiss this route might be vetoed such as in a page
204-
// with forms, then do not allow the user to dismiss the route with a swipe.
205-
if (route.hasScopedWillPopCallback
206-
|| route.popDisposition == RoutePopDisposition.doNotPop) {
207-
return false;
208-
}
209-
// Fullscreen dialogs aren't dismissible by back swipe.
210-
if (route.fullscreenDialog) {
211-
return false;
212-
}
213-
// If we're in an animation already, we cannot be manually swiped.
214-
if (route.animation!.status != AnimationStatus.completed) {
215-
return false;
216-
}
217-
// If we're being popped into, we also cannot be swiped until the pop above
218-
// it completes. This translates to our secondary animation being
219-
// dismissed.
220-
if (route.secondaryAnimation!.status != AnimationStatus.dismissed) {
221-
return false;
222-
}
223-
// If we're in a gesture already, we cannot start another.
224-
if (isPopGestureInProgress(route)) {
225-
return false;
226-
}
227-
228-
// Looks like a back gesture would be welcome!
229-
return true;
230-
}
231-
232159
@override
233160
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
234161
final Widget child = buildContent(context);
@@ -243,7 +170,7 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
243170
// gesture is detected. The returned controller handles all of the subsequent
244171
// drag events.
245172
static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
246-
assert(_isPopGestureEnabled(route));
173+
assert(route.popGestureEnabled);
247174

248175
return _CupertinoBackGestureController<T>(
249176
navigator: route.navigator!,
@@ -279,7 +206,7 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
279206
//
280207
// In the middle of a back gesture drag, let the transition be linear to
281208
// match finger motions.
282-
final bool linearTransition = isPopGestureInProgress(route);
209+
final bool linearTransition = route.popGestureInProgress;
283210
if (route.fullscreenDialog) {
284211
return CupertinoFullscreenDialogTransition(
285212
primaryRouteAnimation: animation,
@@ -293,10 +220,8 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
293220
secondaryRouteAnimation: secondaryAnimation,
294221
linearTransition: linearTransition,
295222
child: _CupertinoBackGestureDetector<T>(
296-
enabledCallback: () => _isPopGestureEnabled<T>(route),
223+
enabledCallback: () => route.popGestureEnabled,
297224
onStartPopGesture: () => _startPopGesture<T>(route),
298-
getIsCurrent: () => route.isCurrent,
299-
getIsActive: () => route.isActive,
300225
child: child,
301226
),
302227
);
@@ -600,8 +525,6 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget {
600525
required this.enabledCallback,
601526
required this.onStartPopGesture,
602527
required this.child,
603-
required this.getIsActive,
604-
required this.getIsCurrent,
605528
});
606529

607530
final Widget child;
@@ -610,9 +533,6 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget {
610533

611534
final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture;
612535

613-
final ValueGetter<bool> getIsActive;
614-
final ValueGetter<bool> getIsCurrent;
615-
616536
@override
617537
_CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>();
618538
}

packages/flutter/lib/src/material/page_transitions_theme.dart

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'dart:ui' as ui;
77
import 'package:flutter/cupertino.dart';
88
import 'package:flutter/foundation.dart';
99
import 'package:flutter/rendering.dart';
10+
import 'package:flutter/services.dart';
1011

1112
import 'colors.dart';
1213
import 'theme.dart';
@@ -545,6 +546,9 @@ abstract class PageTransitionsBuilder {
545546
/// that's similar to the one provided in Android Q.
546547
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
547548
/// transition that matches native iOS page transitions.
549+
/// * [PredictiveBackPageTransitionsBuilder], which defines a page
550+
/// transition that allows peeking behind the current route on Android U and
551+
/// above.
548552
class FadeUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
549553
/// Constructs a page transition animation that slides the page up.
550554
const FadeUpwardsPageTransitionsBuilder();
@@ -573,6 +577,8 @@ class FadeUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
573577
/// that's similar to the one provided in Android Q.
574578
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
575579
/// transition that matches native iOS page transitions.
580+
/// * [PredictiveBackPageTransitionsBuilder], which defines a page
581+
/// transition that allows peeking behind the current route on Android.
576582
class OpenUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
577583
/// Constructs a page transition animation that matches the transition used on
578584
/// Android P.
@@ -606,6 +612,8 @@ class OpenUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
606612
/// that's similar to the one provided by Android P.
607613
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
608614
/// transition that matches native iOS page transitions.
615+
/// * [PredictiveBackPageTransitionsBuilder], which defines a page
616+
/// transition that allows peeking behind the current route on Android.
609617
class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
610618
/// Constructs a page transition animation that matches the transition used on
611619
/// Android Q.
@@ -656,11 +664,11 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
656664

657665
@override
658666
Widget buildTransitions<T>(
659-
PageRoute<T>? route,
660-
BuildContext? context,
667+
PageRoute<T> route,
668+
BuildContext context,
661669
Animation<double> animation,
662670
Animation<double> secondaryAnimation,
663-
Widget? child,
671+
Widget child,
664672
) {
665673
if (_kProfileForceDisableSnapshotting) {
666674
return _ZoomPageTransitionNoCache(
@@ -672,7 +680,7 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
672680
return _ZoomPageTransition(
673681
animation: animation,
674682
secondaryAnimation: secondaryAnimation,
675-
allowSnapshotting: allowSnapshotting && (route?.allowSnapshotting ?? true),
683+
allowSnapshotting: allowSnapshotting && route.allowSnapshotting,
676684
allowEnterRouteSnapshotting: allowEnterRouteSnapshotting,
677685
child: child,
678686
);
@@ -690,6 +698,8 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
690698
/// that's similar to the one provided by Android P.
691699
/// * [ZoomPageTransitionsBuilder], which defines the default page transition
692700
/// that's similar to the one provided in Android Q.
701+
/// * [PredictiveBackPageTransitionsBuilder], which defines a page
702+
/// transition that allows peeking behind the current route on Android.
693703
class CupertinoPageTransitionsBuilder extends PageTransitionsBuilder {
694704
/// Constructs a page transition animation that matches the iOS transition.
695705
const CupertinoPageTransitionsBuilder();
@@ -741,7 +751,9 @@ class PageTransitionsTheme with Diagnosticable {
741751
/// By default the list of builders is: [ZoomPageTransitionsBuilder]
742752
/// for [TargetPlatform.android], and [CupertinoPageTransitionsBuilder] for
743753
/// [TargetPlatform.iOS] and [TargetPlatform.macOS].
744-
const PageTransitionsTheme({ Map<TargetPlatform, PageTransitionsBuilder> builders = _defaultBuilders }) : _builders = builders;
754+
const PageTransitionsTheme({
755+
Map<TargetPlatform, PageTransitionsBuilder> builders = _defaultBuilders,
756+
}) : _builders = builders;
745757

746758
static const Map<TargetPlatform, PageTransitionsBuilder> _defaultBuilders = <TargetPlatform, PageTransitionsBuilder>{
747759
TargetPlatform.android: ZoomPageTransitionsBuilder(),
@@ -765,17 +777,13 @@ class PageTransitionsTheme with Diagnosticable {
765777
Animation<double> secondaryAnimation,
766778
Widget child,
767779
) {
768-
TargetPlatform platform = Theme.of(context).platform;
769-
770-
if (CupertinoRouteTransitionMixin.isPopGestureInProgress(route)) {
771-
platform = TargetPlatform.iOS;
772-
}
773-
774-
final PageTransitionsBuilder matchingBuilder = builders[platform] ?? switch (platform) {
775-
TargetPlatform.iOS => const CupertinoPageTransitionsBuilder(),
776-
TargetPlatform.android || TargetPlatform.fuchsia || TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => const ZoomPageTransitionsBuilder(),
777-
};
778-
return matchingBuilder.buildTransitions<T>(route, context, animation, secondaryAnimation, child);
780+
return _PageTransitionsThemeTransitions<T>(
781+
builders: builders,
782+
route: route,
783+
animation: animation,
784+
secondaryAnimation: secondaryAnimation,
785+
child: child,
786+
);
779787
}
780788

781789
// Map the builders to a list with one PageTransitionsBuilder per platform for
@@ -815,6 +823,55 @@ class PageTransitionsTheme with Diagnosticable {
815823
}
816824
}
817825

826+
class _PageTransitionsThemeTransitions<T> extends StatefulWidget {
827+
const _PageTransitionsThemeTransitions({
828+
required this.builders,
829+
required this.route,
830+
required this.animation,
831+
required this.secondaryAnimation,
832+
required this.child,
833+
});
834+
835+
final Map<TargetPlatform, PageTransitionsBuilder> builders;
836+
final PageRoute<T> route;
837+
final Animation<double> animation;
838+
final Animation<double> secondaryAnimation;
839+
final Widget child;
840+
841+
@override
842+
State<_PageTransitionsThemeTransitions<T>> createState() => _PageTransitionsThemeTransitionsState<T>();
843+
}
844+
845+
class _PageTransitionsThemeTransitionsState<T> extends State<_PageTransitionsThemeTransitions<T>> {
846+
TargetPlatform? _transitionPlatform;
847+
848+
@override
849+
Widget build(BuildContext context) {
850+
TargetPlatform platform = Theme.of(context).platform;
851+
852+
// If the theme platform is changed in the middle of a pop gesture, keep the
853+
// transition that the gesture began with until the gesture is finished.
854+
if (widget.route.popGestureInProgress) {
855+
_transitionPlatform ??= platform;
856+
platform = _transitionPlatform!;
857+
} else {
858+
_transitionPlatform = null;
859+
}
860+
861+
final PageTransitionsBuilder matchingBuilder = widget.builders[platform] ?? switch (platform) {
862+
TargetPlatform.iOS => const CupertinoPageTransitionsBuilder(),
863+
TargetPlatform.android || TargetPlatform.fuchsia || TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => const ZoomPageTransitionsBuilder(),
864+
};
865+
return matchingBuilder.buildTransitions<T>(
866+
widget.route,
867+
context,
868+
widget.animation,
869+
widget.secondaryAnimation,
870+
widget.child,
871+
);
872+
}
873+
}
874+
818875
// Take an image and draw it centered and scaled. The image is already scaled by the [pixelRatio].
819876
void _drawImageScaledAndCentered(PaintingContext context, ui.Image image, double scale, double opacity, double pixelRatio) {
820877
if (scale <= 0.0 || opacity <= 0.0) {

0 commit comments

Comments
 (0)