[WIP] Predictive back support for routes (#141373)
A new page transition, PredictiveBackPageTransitionsBuilder, which handles predictive back gestures on Android (where supported).
diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart
index 6615335..3f6347c 100644
--- a/packages/flutter/lib/material.dart
+++ b/packages/flutter/lib/material.dart
@@ -137,6 +137,7 @@
export 'src/material/paginated_data_table.dart';
export 'src/material/popup_menu.dart';
export 'src/material/popup_menu_theme.dart';
+export 'src/material/predictive_back_page_transitions_builder.dart';
export 'src/material/progress_indicator.dart';
export 'src/material/progress_indicator_theme.dart';
export 'src/material/radio.dart';
diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart
index a5ed2f7..4516577 100644
--- a/packages/flutter/lib/services.dart
+++ b/packages/flutter/lib/services.dart
@@ -33,6 +33,7 @@
export 'src/services/mouse_tracking.dart';
export 'src/services/platform_channel.dart';
export 'src/services/platform_views.dart';
+export 'src/services/predictive_back_event.dart';
export 'src/services/process_text.dart';
export 'src/services/raw_keyboard.dart';
export 'src/services/raw_keyboard_android.dart';
diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart
index 90ee60b..b9a0659 100644
--- a/packages/flutter/lib/src/cupertino/route.dart
+++ b/packages/flutter/lib/src/cupertino/route.dart
@@ -156,79 +156,6 @@
return nextRoute is CupertinoRouteTransitionMixin && !nextRoute.fullscreenDialog;
}
- /// True if an iOS-style back swipe pop gesture is currently underway for [route].
- ///
- /// This just checks the route's [NavigatorState.userGestureInProgress].
- ///
- /// See also:
- ///
- /// * [popGestureEnabled], which returns true if a user-triggered pop gesture
- /// would be allowed.
- static bool isPopGestureInProgress(PageRoute<dynamic> route) {
- return route.navigator!.userGestureInProgress;
- }
-
- /// True if an iOS-style back swipe pop gesture is currently underway for this route.
- ///
- /// See also:
- ///
- /// * [isPopGestureInProgress], which returns true if a Cupertino pop gesture
- /// is currently underway for specific route.
- /// * [popGestureEnabled], which returns true if a user-triggered pop gesture
- /// would be allowed.
- bool get popGestureInProgress => isPopGestureInProgress(this);
-
- /// Whether a pop gesture can be started by the user.
- ///
- /// Returns true if the user can edge-swipe to a previous route.
- ///
- /// Returns false once [isPopGestureInProgress] is true, but
- /// [isPopGestureInProgress] can only become true if [popGestureEnabled] was
- /// true first.
- ///
- /// This should only be used between frames, not during build.
- bool get popGestureEnabled => _isPopGestureEnabled(this);
-
- static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
- // If there's nothing to go back to, then obviously we don't support
- // the back gesture.
- if (route.isFirst) {
- return false;
- }
- // If the route wouldn't actually pop if we popped it, then the gesture
- // would be really confusing (or would skip internal routes), so disallow it.
- if (route.willHandlePopInternally) {
- return false;
- }
- // If attempts to dismiss this route might be vetoed such as in a page
- // with forms, then do not allow the user to dismiss the route with a swipe.
- if (route.hasScopedWillPopCallback
- || route.popDisposition == RoutePopDisposition.doNotPop) {
- return false;
- }
- // Fullscreen dialogs aren't dismissible by back swipe.
- if (route.fullscreenDialog) {
- return false;
- }
- // If we're in an animation already, we cannot be manually swiped.
- if (route.animation!.status != AnimationStatus.completed) {
- return false;
- }
- // If we're being popped into, we also cannot be swiped until the pop above
- // it completes. This translates to our secondary animation being
- // dismissed.
- if (route.secondaryAnimation!.status != AnimationStatus.dismissed) {
- return false;
- }
- // If we're in a gesture already, we cannot start another.
- if (isPopGestureInProgress(route)) {
- return false;
- }
-
- // Looks like a back gesture would be welcome!
- return true;
- }
-
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
final Widget child = buildContent(context);
@@ -243,7 +170,7 @@
// gesture is detected. The returned controller handles all of the subsequent
// drag events.
static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
- assert(_isPopGestureEnabled(route));
+ assert(route.popGestureEnabled);
return _CupertinoBackGestureController<T>(
navigator: route.navigator!,
@@ -279,7 +206,7 @@
//
// In the middle of a back gesture drag, let the transition be linear to
// match finger motions.
- final bool linearTransition = isPopGestureInProgress(route);
+ final bool linearTransition = route.popGestureInProgress;
if (route.fullscreenDialog) {
return CupertinoFullscreenDialogTransition(
primaryRouteAnimation: animation,
@@ -293,10 +220,8 @@
secondaryRouteAnimation: secondaryAnimation,
linearTransition: linearTransition,
child: _CupertinoBackGestureDetector<T>(
- enabledCallback: () => _isPopGestureEnabled<T>(route),
+ enabledCallback: () => route.popGestureEnabled,
onStartPopGesture: () => _startPopGesture<T>(route),
- getIsCurrent: () => route.isCurrent,
- getIsActive: () => route.isActive,
child: child,
),
);
@@ -600,8 +525,6 @@
required this.enabledCallback,
required this.onStartPopGesture,
required this.child,
- required this.getIsActive,
- required this.getIsCurrent,
});
final Widget child;
@@ -610,9 +533,6 @@
final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture;
- final ValueGetter<bool> getIsActive;
- final ValueGetter<bool> getIsCurrent;
-
@override
_CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>();
}
diff --git a/packages/flutter/lib/src/material/page_transitions_theme.dart b/packages/flutter/lib/src/material/page_transitions_theme.dart
index 6b6a0b6..cbcfa2c 100644
--- a/packages/flutter/lib/src/material/page_transitions_theme.dart
+++ b/packages/flutter/lib/src/material/page_transitions_theme.dart
@@ -7,6 +7,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
import 'colors.dart';
import 'theme.dart';
@@ -545,6 +546,9 @@
/// that's similar to the one provided in Android Q.
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
/// transition that matches native iOS page transitions.
+/// * [PredictiveBackPageTransitionsBuilder], which defines a page
+/// transition that allows peeking behind the current route on Android U and
+/// above.
class FadeUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
/// Constructs a page transition animation that slides the page up.
const FadeUpwardsPageTransitionsBuilder();
@@ -573,6 +577,8 @@
/// that's similar to the one provided in Android Q.
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
/// transition that matches native iOS page transitions.
+/// * [PredictiveBackPageTransitionsBuilder], which defines a page
+/// transition that allows peeking behind the current route on Android.
class OpenUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
/// Constructs a page transition animation that matches the transition used on
/// Android P.
@@ -606,6 +612,8 @@
/// that's similar to the one provided by Android P.
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
/// transition that matches native iOS page transitions.
+/// * [PredictiveBackPageTransitionsBuilder], which defines a page
+/// transition that allows peeking behind the current route on Android.
class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
/// Constructs a page transition animation that matches the transition used on
/// Android Q.
@@ -656,11 +664,11 @@
@override
Widget buildTransitions<T>(
- PageRoute<T>? route,
- BuildContext? context,
+ PageRoute<T> route,
+ BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
- Widget? child,
+ Widget child,
) {
if (_kProfileForceDisableSnapshotting) {
return _ZoomPageTransitionNoCache(
@@ -672,7 +680,7 @@
return _ZoomPageTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
- allowSnapshotting: allowSnapshotting && (route?.allowSnapshotting ?? true),
+ allowSnapshotting: allowSnapshotting && route.allowSnapshotting,
allowEnterRouteSnapshotting: allowEnterRouteSnapshotting,
child: child,
);
@@ -690,6 +698,8 @@
/// that's similar to the one provided by Android P.
/// * [ZoomPageTransitionsBuilder], which defines the default page transition
/// that's similar to the one provided in Android Q.
+/// * [PredictiveBackPageTransitionsBuilder], which defines a page
+/// transition that allows peeking behind the current route on Android.
class CupertinoPageTransitionsBuilder extends PageTransitionsBuilder {
/// Constructs a page transition animation that matches the iOS transition.
const CupertinoPageTransitionsBuilder();
@@ -741,7 +751,9 @@
/// By default the list of builders is: [ZoomPageTransitionsBuilder]
/// for [TargetPlatform.android], and [CupertinoPageTransitionsBuilder] for
/// [TargetPlatform.iOS] and [TargetPlatform.macOS].
- const PageTransitionsTheme({ Map<TargetPlatform, PageTransitionsBuilder> builders = _defaultBuilders }) : _builders = builders;
+ const PageTransitionsTheme({
+ Map<TargetPlatform, PageTransitionsBuilder> builders = _defaultBuilders,
+ }) : _builders = builders;
static const Map<TargetPlatform, PageTransitionsBuilder> _defaultBuilders = <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: ZoomPageTransitionsBuilder(),
@@ -765,17 +777,13 @@
Animation<double> secondaryAnimation,
Widget child,
) {
- TargetPlatform platform = Theme.of(context).platform;
-
- if (CupertinoRouteTransitionMixin.isPopGestureInProgress(route)) {
- platform = TargetPlatform.iOS;
- }
-
- final PageTransitionsBuilder matchingBuilder = builders[platform] ?? switch (platform) {
- TargetPlatform.iOS => const CupertinoPageTransitionsBuilder(),
- TargetPlatform.android || TargetPlatform.fuchsia || TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => const ZoomPageTransitionsBuilder(),
- };
- return matchingBuilder.buildTransitions<T>(route, context, animation, secondaryAnimation, child);
+ return _PageTransitionsThemeTransitions<T>(
+ builders: builders,
+ route: route,
+ animation: animation,
+ secondaryAnimation: secondaryAnimation,
+ child: child,
+ );
}
// Map the builders to a list with one PageTransitionsBuilder per platform for
@@ -815,6 +823,55 @@
}
}
+class _PageTransitionsThemeTransitions<T> extends StatefulWidget {
+ const _PageTransitionsThemeTransitions({
+ required this.builders,
+ required this.route,
+ required this.animation,
+ required this.secondaryAnimation,
+ required this.child,
+ });
+
+ final Map<TargetPlatform, PageTransitionsBuilder> builders;
+ final PageRoute<T> route;
+ final Animation<double> animation;
+ final Animation<double> secondaryAnimation;
+ final Widget child;
+
+ @override
+ State<_PageTransitionsThemeTransitions<T>> createState() => _PageTransitionsThemeTransitionsState<T>();
+}
+
+class _PageTransitionsThemeTransitionsState<T> extends State<_PageTransitionsThemeTransitions<T>> {
+ TargetPlatform? _transitionPlatform;
+
+ @override
+ Widget build(BuildContext context) {
+ TargetPlatform platform = Theme.of(context).platform;
+
+ // If the theme platform is changed in the middle of a pop gesture, keep the
+ // transition that the gesture began with until the gesture is finished.
+ if (widget.route.popGestureInProgress) {
+ _transitionPlatform ??= platform;
+ platform = _transitionPlatform!;
+ } else {
+ _transitionPlatform = null;
+ }
+
+ final PageTransitionsBuilder matchingBuilder = widget.builders[platform] ?? switch (platform) {
+ TargetPlatform.iOS => const CupertinoPageTransitionsBuilder(),
+ TargetPlatform.android || TargetPlatform.fuchsia || TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => const ZoomPageTransitionsBuilder(),
+ };
+ return matchingBuilder.buildTransitions<T>(
+ widget.route,
+ context,
+ widget.animation,
+ widget.secondaryAnimation,
+ widget.child,
+ );
+ }
+}
+
// Take an image and draw it centered and scaled. The image is already scaled by the [pixelRatio].
void _drawImageScaledAndCentered(PaintingContext context, ui.Image image, double scale, double opacity, double pixelRatio) {
if (scale <= 0.0 || opacity <= 0.0) {
diff --git a/packages/flutter/lib/src/material/predictive_back_page_transitions_builder.dart b/packages/flutter/lib/src/material/predictive_back_page_transitions_builder.dart
new file mode 100644
index 0000000..26d2491
--- /dev/null
+++ b/packages/flutter/lib/src/material/predictive_back_page_transitions_builder.dart
@@ -0,0 +1,325 @@
+// 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/services.dart';
+import 'package:flutter/widgets.dart';
+
+import 'page_transitions_theme.dart';
+
+/// Used by [PageTransitionsTheme] to define a [MaterialPageRoute] page
+/// transition animation that looks like the default page transition used on
+/// Android U and above when using predictive back.
+///
+/// Currently predictive back is only supported on Android U and above, and if
+/// this [PageTransitionsBuilder] is used by any other platform, it will fall
+/// back to [ZoomPageTransitionsBuilder].
+///
+/// When used on Android U and above, animates along with the back gesture to
+/// reveal the destination route. Can be canceled by dragging back towards the
+/// edge of the screen.
+///
+/// See also:
+///
+/// * [FadeUpwardsPageTransitionsBuilder], which defines a page transition
+/// that's similar to the one provided by Android O.
+/// * [OpenUpwardsPageTransitionsBuilder], which defines a page transition
+/// that's similar to the one provided by Android P.
+/// * [ZoomPageTransitionsBuilder], which defines the default page transition
+/// that's similar to the one provided in Android Q.
+/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
+/// transition that matches native iOS page transitions.
+class PredictiveBackPageTransitionsBuilder extends PageTransitionsBuilder {
+ /// Creates an instance of a [PageTransitionsBuilder] that matches Android U's
+ /// predictive back transition.
+ const PredictiveBackPageTransitionsBuilder();
+
+ @override
+ Widget buildTransitions<T>(
+ PageRoute<T> route,
+ BuildContext context,
+ Animation<double> animation,
+ Animation<double> secondaryAnimation,
+ Widget child,
+ ) {
+ return _PredictiveBackGestureDetector(
+ route: route,
+ builder: (BuildContext context) {
+ // Only do a predictive back transition when the user is performing a
+ // pop gesture. Otherwise, for things like button presses or other
+ // programmatic navigation, fall back to ZoomPageTransitionsBuilder.
+ if (route.popGestureInProgress) {
+ return _PredictiveBackPageTransition(
+ animation: animation,
+ secondaryAnimation: secondaryAnimation,
+ getIsCurrent: () => route.isCurrent,
+ child: child,
+ );
+ }
+
+ return const ZoomPageTransitionsBuilder().buildTransitions(
+ route,
+ context,
+ animation,
+ secondaryAnimation,
+ child,
+ );
+ },
+ );
+ }
+}
+
+class _PredictiveBackGestureDetector extends StatefulWidget {
+ const _PredictiveBackGestureDetector({
+ required this.route,
+ required this.builder,
+ });
+
+ final WidgetBuilder builder;
+ final PredictiveBackRoute route;
+
+ @override
+ State<_PredictiveBackGestureDetector> createState() =>
+ _PredictiveBackGestureDetectorState();
+}
+
+class _PredictiveBackGestureDetectorState extends State<_PredictiveBackGestureDetector>
+ with WidgetsBindingObserver {
+ /// True when the predictive back gesture is enabled.
+ bool get _isEnabled {
+ return widget.route.isCurrent
+ && widget.route.popGestureEnabled;
+ }
+
+ /// The back event when the gesture first started.
+ PredictiveBackEvent? get startBackEvent => _startBackEvent;
+ PredictiveBackEvent? _startBackEvent;
+ set startBackEvent(PredictiveBackEvent? startBackEvent) {
+ if (_startBackEvent != startBackEvent && mounted) {
+ setState(() {
+ _startBackEvent = startBackEvent;
+ });
+ }
+ }
+
+ /// The most recent back event during the gesture.
+ PredictiveBackEvent? get currentBackEvent => _currentBackEvent;
+ PredictiveBackEvent? _currentBackEvent;
+ set currentBackEvent(PredictiveBackEvent? currentBackEvent) {
+ if (_currentBackEvent != currentBackEvent && mounted) {
+ setState(() {
+ _currentBackEvent = currentBackEvent;
+ });
+ }
+ }
+
+ // Begin WidgetsBindingObserver.
+
+ @override
+ bool handleStartBackGesture(PredictiveBackEvent backEvent) {
+ final bool gestureInProgress = !backEvent.isButtonEvent && _isEnabled;
+ if (!gestureInProgress) {
+ return false;
+ }
+
+ widget.route.handleStartBackGesture(progress: 1 - backEvent.progress);
+ startBackEvent = currentBackEvent = backEvent;
+ return true;
+ }
+
+ @override
+ void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) {
+ widget.route.handleUpdateBackGestureProgress(progress: 1 - backEvent.progress);
+ currentBackEvent = backEvent;
+ }
+
+ @override
+ void handleCancelBackGesture() {
+ widget.route.handleCancelBackGesture();
+ startBackEvent = currentBackEvent = null;
+ }
+
+ @override
+ void handleCommitBackGesture() {
+ widget.route.handleCommitBackGesture();
+ startBackEvent = currentBackEvent = null;
+ }
+
+ // End WidgetsBindingObserver.
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return widget.builder(context);
+ }
+}
+
+/// Android's predictive back page transition.
+class _PredictiveBackPageTransition extends StatelessWidget {
+ const _PredictiveBackPageTransition({
+ required this.animation,
+ required this.secondaryAnimation,
+ required this.getIsCurrent,
+ required this.child,
+ });
+
+ // These values were eyeballed to match the native predictive back animation
+ // on a Pixel 2 running Android API 34.
+ static const double _scaleFullyOpened = 1.0;
+ static const double _scaleStartTransition = 0.95;
+ static const double _opacityFullyOpened = 1.0;
+ static const double _opacityStartTransition = 0.95;
+ static const double _weightForStartState = 65.0;
+ static const double _weightForEndState = 35.0;
+ static const double _screenWidthDivisionFactor = 20.0;
+ static const double _xShiftAdjustment = 8.0;
+
+ final Animation<double> animation;
+ final Animation<double> secondaryAnimation;
+ final ValueGetter<bool> getIsCurrent;
+ final Widget child;
+
+ Widget _secondaryAnimatedBuilder(BuildContext context, Widget? child) {
+ final Size size = MediaQuery.sizeOf(context);
+ final double screenWidth = size.width;
+ final double xShift =
+ (screenWidth / _screenWidthDivisionFactor) - _xShiftAdjustment;
+
+ final bool isCurrent = getIsCurrent();
+ final Tween<double> xShiftTween = isCurrent
+ ? ConstantTween<double>(0)
+ : Tween<double>(begin: xShift, end: 0);
+ final Animatable<double> scaleTween = isCurrent
+ ? ConstantTween<double>(_scaleFullyOpened)
+ : TweenSequence<double>(<TweenSequenceItem<double>>[
+ TweenSequenceItem<double>(
+ tween: Tween<double>(
+ begin: _scaleStartTransition,
+ end: _scaleFullyOpened,
+ ),
+ weight: _weightForStartState,
+ ),
+ TweenSequenceItem<double>(
+ tween: Tween<double>(
+ begin: _scaleFullyOpened,
+ end: _scaleFullyOpened,
+ ),
+ weight: _weightForEndState,
+ ),
+ ]);
+ final Animatable<double> fadeTween = isCurrent
+ ? ConstantTween<double>(_opacityFullyOpened)
+ : TweenSequence<double>(<TweenSequenceItem<double>>[
+ TweenSequenceItem<double>(
+ tween: Tween<double>(
+ begin: _opacityFullyOpened,
+ end: _opacityStartTransition,
+ ),
+ weight: _weightForStartState,
+ ),
+ TweenSequenceItem<double>(
+ tween: Tween<double>(
+ begin: _opacityFullyOpened,
+ end: _opacityFullyOpened,
+ ),
+ weight: _weightForEndState,
+ ),
+ ]);
+
+ return Transform.translate(
+ offset: Offset(xShiftTween.animate(secondaryAnimation).value, 0),
+ child: Transform.scale(
+ scale: scaleTween.animate(secondaryAnimation).value,
+ child: Opacity(
+ opacity: fadeTween.animate(secondaryAnimation).value,
+ child: child,
+ ),
+ ),
+ );
+ }
+
+ Widget _primaryAnimatedBuilder(BuildContext context, Widget? child) {
+ final Size size = MediaQuery.sizeOf(context);
+ final double screenWidth = size.width;
+ final double xShift =
+ (screenWidth / _screenWidthDivisionFactor) - _xShiftAdjustment;
+
+ final Animatable<double> xShiftTween =
+ TweenSequence<double>(<TweenSequenceItem<double>>[
+ TweenSequenceItem<double>(
+ tween: Tween<double>(begin: 0.0, end: 0.0),
+ weight: _weightForStartState,
+ ),
+ TweenSequenceItem<double>(
+ tween: Tween<double>(begin: xShift, end: 0.0),
+ weight: _weightForEndState,
+ ),
+ ]);
+ final Animatable<double> scaleTween =
+ TweenSequence<double>(<TweenSequenceItem<double>>[
+ TweenSequenceItem<double>(
+ tween: Tween<double>(
+ begin: _scaleFullyOpened,
+ end: _scaleFullyOpened,
+ ),
+ weight: _weightForStartState,
+ ),
+ TweenSequenceItem<double>(
+ tween: Tween<double>(
+ begin: _scaleStartTransition,
+ end: _scaleFullyOpened,
+ ),
+ weight: _weightForEndState,
+ ),
+ ]);
+ final Animatable<double> fadeTween =
+ TweenSequence<double>(<TweenSequenceItem<double>>[
+ TweenSequenceItem<double>(
+ tween: Tween<double>(begin: 0.0, end: 0.0),
+ weight: _weightForStartState,
+ ),
+ TweenSequenceItem<double>(
+ tween: Tween<double>(
+ begin: _opacityStartTransition,
+ end: _opacityFullyOpened,
+ ),
+ weight: _weightForEndState,
+ ),
+ ]);
+
+ return Transform.translate(
+ offset: Offset(xShiftTween.animate(animation).value, 0),
+ child: Transform.scale(
+ scale: scaleTween.animate(animation).value,
+ child: Opacity(
+ opacity: fadeTween.animate(animation).value,
+ child: child,
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: secondaryAnimation,
+ builder: _secondaryAnimatedBuilder,
+ child: AnimatedBuilder(
+ animation: animation,
+ builder: _primaryAnimatedBuilder,
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/services/predictive_back_event.dart b/packages/flutter/lib/src/services/predictive_back_event.dart
new file mode 100644
index 0000000..f73cf51
--- /dev/null
+++ b/packages/flutter/lib/src/services/predictive_back_event.dart
@@ -0,0 +1,115 @@
+// 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:ui';
+
+import 'package:flutter/foundation.dart';
+
+/// Enum representing the edge from which a swipe starts in a back gesture.
+///
+/// This is used in [PredictiveBackEvent] to indicate the starting edge of the
+/// swipe gesture.
+enum SwipeEdge {
+ /// Indicates that the swipe gesture starts from the left edge of the screen.
+ left,
+
+ /// Indicates that the swipe gesture starts from the right edge of the screen.
+ right,
+}
+
+/// Object used to report back gesture progress in Android.
+///
+/// Holds information about the touch event, swipe direction, and the animation
+/// progress that predictive back animations should follow.
+@immutable
+final class PredictiveBackEvent {
+ /// Creates a new [PredictiveBackEvent] instance.
+ const PredictiveBackEvent._({
+ required this.touchOffset,
+ required this.progress,
+ required this.swipeEdge,
+ }) : assert(progress >= 0.0 && progress <= 1.0);
+
+ /// Creates an [PredictiveBackEvent] from a Map, typically used when converting
+ /// data received from a platform channel.
+ factory PredictiveBackEvent.fromMap(Map<String?, Object?> map) {
+ final List<Object?>? touchOffset = map['touchOffset'] as List<Object?>?;
+ return PredictiveBackEvent._(
+ touchOffset: touchOffset == null
+ ? null
+ : Offset(
+ (touchOffset[0]! as num).toDouble(),
+ (touchOffset[1]! as num).toDouble(),
+ ),
+ progress: (map['progress']! as num).toDouble(),
+ swipeEdge: SwipeEdge.values[map['swipeEdge']! as int],
+ );
+ }
+
+ /// The global position of the touch point as an `Offset`, or `null` if the
+ /// event is triggered by a button press.
+ ///
+ /// This represents the touch location that initiates or interacts with the
+ /// back gesture. When `null`, it indicates the gesture was not started by a
+ /// touch event, such as a back button press in devices with hardware buttons.
+ final Offset? touchOffset;
+
+ /// Returns a value between 0.0 and 1.0 representing how far along the back
+ /// gesture is.
+ ///
+ /// This value is driven by the horizontal location of the touch point, and
+ /// should be used as the fraction to seek the predictive back animation with.
+ /// Specifically,
+ ///
+ /// - The progress is 0.0 when the touch is at the starting edge of the screen
+ /// (left or right), and the animation should seek to its start state.
+ /// - The progress is approximately 1.0 when the touch is at the opposite side
+ /// of the screen, and the animation should seek to its end state. Exact end
+ /// value may vary depending on screen size.
+ ///
+ /// When the gesture is canceled, the progress value continues to update,
+ /// animating back to 0.0 until the cancellation animation completes.
+ ///
+ /// In-between locations are linearly interpolated based on horizontal
+ /// distance from the starting edge and smooth clamped to 1.0 when the
+ /// distance exceeds a system-wide threshold.
+ final double progress;
+
+ /// The screen edge from which the swipe gesture starts.
+ final SwipeEdge swipeEdge;
+
+ /// Indicates if the event was triggered by a system back button press.
+ ///
+ /// Returns false for a predictive back gesture.
+ bool get isButtonEvent =>
+ // The Android documentation for BackEvent
+ // (https://developer.android.com/reference/android/window/BackEvent#getTouchX())
+ // says that getTouchX and getTouchY should return NaN when the system
+ // back button is pressed, but in practice it seems to return 0.0, hence
+ // the check for Offset.zero here. This was tested directly in the engine
+ // on Android emulator running API 34.
+ touchOffset == null || (progress == 0.0 && touchOffset == Offset.zero);
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) {
+ return true;
+ }
+ if (other.runtimeType != runtimeType) {
+ return false;
+ }
+ return other is PredictiveBackEvent &&
+ touchOffset == other.touchOffset &&
+ progress == other.progress &&
+ swipeEdge == other.swipeEdge;
+ }
+
+ @override
+ int get hashCode => Object.hash(touchOffset, progress, swipeEdge);
+
+ @override
+ String toString() {
+ return 'PredictiveBackEvent{touchOffset: $touchOffset, progress: $progress, swipeEdge: $swipeEdge}';
+ }
+}
diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart
index 24cf722..63dcc7f 100644
--- a/packages/flutter/lib/src/services/system_channels.dart
+++ b/packages/flutter/lib/src/services/system_channels.dart
@@ -58,6 +58,27 @@
JSONMethodCodec(),
);
+ /// A [MethodChannel] for handling predictive back gestures.
+ ///
+ /// Currently, this feature is only available on Android U and above.
+ ///
+ /// No outgoing methods are defined for this channel (invoked using
+ /// [OptionalMethodChannel.invokeMethod]).
+ ///
+ /// The following incoming methods are defined for this channel (registered
+ /// using [MethodChannel.setMethodCallHandler]):
+ ///
+ /// * `startBackGesture`: The user has started a predictive back gesture.
+ /// * `updateBackGestureProgress`: The user has continued dragging the
+ /// predictive back gesture.
+ /// * `commitBackGesture`: The user has finished a predictive back gesture,
+ /// indicating that the current route should be popped.
+ /// * `cancelBackGesture`: The user has canceled a predictive back gesture,
+ /// indicating that no navigation should occur.
+ static const MethodChannel backGesture = OptionalMethodChannel(
+ 'flutter/backgesture',
+ );
+
/// A JSON [MethodChannel] for invoking miscellaneous platform methods.
///
/// The following outgoing methods are defined for this channel (invoked using
diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart
index 2939408..8c89000 100644
--- a/packages/flutter/lib/src/widgets/binding.dart
+++ b/packages/flutter/lib/src/widgets/binding.dart
@@ -76,6 +76,57 @@
/// {@macro flutter.widgets.AndroidPredictiveBack}
Future<bool> didPopRoute() => Future<bool>.value(false);
+ /// Called at the start of a predictive back gesture.
+ ///
+ /// Observers are notified in registration order until one returns true or all
+ /// observers have been notified. If an observer returns true then that
+ /// observer, and only that observer, will be notified of subsequent events in
+ /// this same gesture (for example [handleUpdateBackGestureProgress], etc.).
+ ///
+ /// Observers are expected to return true if they were able to handle the
+ /// notification, for example by starting a predictive back animation, and
+ /// false otherwise. [PredictiveBackPageTransitionsBuilder] uses this
+ /// mechanism to listen for predictive back gestures.
+ ///
+ /// If all observers indicate they are not handling this back gesture by
+ /// returning false, then a navigation pop will result when
+ /// [handleCommitBackGesture] is called, as in a non-predictive system back
+ /// gesture.
+ ///
+ /// Currently, this is only used on Android devices that support the
+ /// predictive back feature.
+ bool handleStartBackGesture(PredictiveBackEvent backEvent) => false;
+
+ /// Called when a predictive back gesture moves.
+ ///
+ /// The observer which was notified of this gesture's [handleStartBackGesture]
+ /// is the same observer notified for this.
+ ///
+ /// Currently, this is only used on Android devices that support the
+ /// predictive back feature.
+ void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) {}
+
+ /// Called when a predictive back gesture is finished successfully, indicating
+ /// that the current route should be popped.
+ ///
+ /// The observer which was notified of this gesture's [handleStartBackGesture]
+ /// is the same observer notified for this. If there is none, then a
+ /// navigation pop will result, as in a non-predictive system back gesture.
+ ///
+ /// Currently, this is only used on Android devices that support the
+ /// predictive back feature.
+ void handleCommitBackGesture() {}
+
+ /// Called when a predictive back gesture is canceled, indicating that no
+ /// navigation should occur.
+ ///
+ /// The observer which was notified of this gesture's [handleStartBackGesture]
+ /// is the same observer notified for this.
+ ///
+ /// Currently, this is only used on Android devices that support the
+ /// predictive back feature.
+ void handleCancelBackGesture() {}
+
/// Called when the host tells the application to push a new route onto the
/// navigator.
///
@@ -360,6 +411,9 @@
buildOwner!.onBuildScheduled = _handleBuildScheduled;
platformDispatcher.onLocaleChanged = handleLocaleChanged;
SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation);
+ SystemChannels.backGesture.setMethodCallHandler(
+ _handleBackGestureInvocation,
+ );
assert(() {
FlutterErrorDetails.propertiesTransformers.add(debugTransformDebugCreator);
return true;
@@ -646,7 +700,12 @@
///
/// * [addObserver], for the method that adds observers in the first place.
/// * [WidgetsBindingObserver], which has an example of using this method.
- bool removeObserver(WidgetsBindingObserver observer) => _observers.remove(observer);
+ bool removeObserver(WidgetsBindingObserver observer) {
+ if (observer == _backGestureObserver) {
+ _backGestureObserver = null;
+ }
+ return _observers.remove(observer);
+ }
@override
Future<AppExitResponse> handleRequestAppExit() async {
@@ -780,6 +839,50 @@
SystemNavigator.pop();
}
+ // The observer that is currently handling an active predictive back gesture.
+ WidgetsBindingObserver? _backGestureObserver;
+
+ Future<bool> _handleStartBackGesture(Map<String?, Object?> arguments) {
+ _backGestureObserver = null;
+ final PredictiveBackEvent backEvent = PredictiveBackEvent.fromMap(arguments);
+ for (final WidgetsBindingObserver observer in List<WidgetsBindingObserver>.of(_observers)) {
+ if (observer.handleStartBackGesture(backEvent)) {
+ _backGestureObserver = observer;
+ return Future<bool>.value(true);
+ }
+ }
+ return Future<bool>.value(false);
+ }
+
+ Future<void> _handleUpdateBackGestureProgress(Map<String?, Object?> arguments) async {
+ if (_backGestureObserver == null) {
+ return;
+ }
+
+ final PredictiveBackEvent backEvent = PredictiveBackEvent.fromMap(arguments);
+ _backGestureObserver!.handleUpdateBackGestureProgress(backEvent);
+ }
+
+ Future<void> _handleCommitBackGesture() async {
+ if (_backGestureObserver == null) {
+ // If the predictive back was not handled, then the route should be popped
+ // like a normal, non-predictive back. For example, this will happen if a
+ // back gesture occurs but no predictive back route transition exists to
+ // handle it. The back gesture should still cause normal pop even if it
+ // doesn't cause a predictive transition.
+ return handlePopRoute();
+ }
+ _backGestureObserver?.handleCommitBackGesture();
+ }
+
+ Future<void> _handleCancelBackGesture() async {
+ if (_backGestureObserver == null) {
+ return;
+ }
+
+ _backGestureObserver!.handleCancelBackGesture();
+ }
+
/// Called when the host tells the app to push a new route onto the
/// navigator.
///
@@ -823,6 +926,18 @@
};
}
+ Future<dynamic> _handleBackGestureInvocation(MethodCall methodCall) {
+ final Map<String?, Object?>? arguments =
+ (methodCall.arguments as Map<Object?, Object?>?)?.cast<String?, Object?>();
+ return switch (methodCall.method) {
+ 'startBackGesture' => _handleStartBackGesture(arguments!),
+ 'updateBackGestureProgress' => _handleUpdateBackGestureProgress(arguments!),
+ 'commitBackGesture' => _handleCommitBackGesture(),
+ 'cancelBackGesture' => _handleCancelBackGesture(),
+ _ => throw MissingPluginException(),
+ };
+ }
+
@override
void handleAppLifecycleStateChanged(AppLifecycleState state) {
super.handleAppLifecycleStateChanged(state);
diff --git a/packages/flutter/lib/src/widgets/pages.dart b/packages/flutter/lib/src/widgets/pages.dart
index 2399efb..620957e 100644
--- a/packages/flutter/lib/src/widgets/pages.dart
+++ b/packages/flutter/lib/src/widgets/pages.dart
@@ -51,6 +51,12 @@
@override
bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) => previousRoute is PageRoute;
+
+ @override
+ bool get popGestureEnabled {
+ // Fullscreen dialogs aren't dismissible by back swipe.
+ return !fullscreenDialog && super.popGestureEnabled;
+ }
}
Widget _defaultTransitionsBuilder(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart
index c4194e4..277a5fb 100644
--- a/packages/flutter/lib/src/widgets/routes.dart
+++ b/packages/flutter/lib/src/widgets/routes.dart
@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:async';
+import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
@@ -97,7 +98,7 @@
/// See also:
///
/// * [Route], which documents the meaning of the `T` generic type argument.
-abstract class TransitionRoute<T> extends OverlayRoute<T> {
+abstract class TransitionRoute<T> extends OverlayRoute<T> implements PredictiveBackRoute {
/// Creates a route that animates itself when it is pushed or popped.
TransitionRoute({
super.settings,
@@ -476,6 +477,84 @@
/// [ModalRoute.buildTransitions] `secondaryAnimation` to run.
bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) => true;
+ // Begin PredictiveBackRoute.
+
+ @override
+ void handleStartBackGesture({double progress = 0.0}) {
+ assert(isCurrent);
+ _controller?.value = progress;
+ navigator?.didStartUserGesture();
+ }
+
+ @override
+ void handleUpdateBackGestureProgress({required double progress}) {
+ // If some other navigation happened during this gesture, don't mess with
+ // the transition anymore.
+ if (!isCurrent) {
+ return;
+ }
+ _controller?.value = progress;
+ }
+
+ @override
+ void handleCancelBackGesture() {
+ _handleDragEnd(animateForward: true);
+ }
+
+ @override
+ void handleCommitBackGesture() {
+ _handleDragEnd(animateForward: false);
+ }
+
+ void _handleDragEnd({required bool animateForward}) {
+ if (isCurrent) {
+ if (animateForward) {
+ // The closer the panel is to dismissing, the shorter the animation is.
+ // We want to cap the animation time, but we want to use a linear curve
+ // to determine it.
+ // These values were eyeballed to match the native predictive back
+ // animation on a Pixel 2 running Android API 34.
+ final int droppedPageForwardAnimationTime = min(
+ ui.lerpDouble(800, 0, _controller!.value)!.floor(),
+ 300,
+ );
+ _controller?.animateTo(
+ 1.0,
+ duration: Duration(milliseconds: droppedPageForwardAnimationTime),
+ curve: Curves.fastLinearToSlowEaseIn,
+ );
+ } else {
+ // This route is destined to pop at this point. Reuse navigator's pop.
+ navigator?.pop();
+
+ // The popping may have finished inline if already at the target destination.
+ if (_controller?.isAnimating ?? false) {
+ // Otherwise, use a custom popping animation duration and curve.
+ final int droppedPageBackAnimationTime =
+ ui.lerpDouble(0, 800, _controller!.value)!.floor();
+ _controller!.animateBack(0.0,
+ duration: Duration(milliseconds: droppedPageBackAnimationTime),
+ curve: Curves.fastLinearToSlowEaseIn);
+ }
+ }
+ }
+
+ if (_controller?.isAnimating ?? false) {
+ // Keep the userGestureInProgress in true state since AndroidBackGesturePageTransitionsBuilder
+ // depends on userGestureInProgress.
+ late final AnimationStatusListener animationStatusCallback;
+ animationStatusCallback = (AnimationStatus status) {
+ navigator?.didStopUserGesture();
+ _controller!.removeStatusListener(animationStatusCallback);
+ };
+ _controller!.addStatusListener(animationStatusCallback);
+ } else {
+ navigator?.didStopUserGesture();
+ }
+ }
+
+ // End PredictiveBackRoute.
+
@override
void dispose() {
assert(!_transitionCompleter.isCompleted, 'Cannot dispose a $runtimeType twice.');
@@ -496,6 +575,39 @@
String toString() => '${objectRuntimeType(this, 'TransitionRoute')}(animation: $_controller)';
}
+/// An interface for a route that supports predictive back gestures.
+///
+/// See also:
+///
+/// * [PredictiveBackPageTransitionsBuilder], which builds page transitions for
+/// predictive back.
+abstract interface class PredictiveBackRoute {
+ /// Whether this route is the top-most route on the navigator.
+ bool get isCurrent;
+
+ /// Whether a pop gesture can be started by the user for this route.
+ bool get popGestureEnabled;
+
+ /// Handles a predictive back gesture starting.
+ ///
+ /// The `progress` parameter indicates the progress of the gesture from 0.0 to
+ /// 1.0, as in [PredictiveBackEvent.progress].
+ void handleStartBackGesture({double progress = 0.0});
+
+ /// Handles a predictive back gesture updating as the user drags across the
+ /// screen.
+ ///
+ /// The `progress` parameter indicates the progress of the gesture from 0.0 to
+ /// 1.0, as in [PredictiveBackEvent.progress].
+ void handleUpdateBackGestureProgress({required double progress});
+
+ /// Handles a predictive back gesture ending successfully.
+ void handleCommitBackGesture();
+
+ /// Handles a predictive back gesture ending in cancelation.
+ void handleCancelBackGesture();
+}
+
/// An entry in the history of a [LocalHistoryRoute].
class LocalHistoryEntry {
/// Creates an entry in the history of a [LocalHistoryRoute].
@@ -1465,6 +1577,56 @@
/// of this property.
bool get maintainState;
+ /// True if a back gesture (iOS-style back swipe or Android predictive back)
+ /// is currently underway for this route.
+ ///
+ /// See also:
+ ///
+ /// * [popGestureEnabled], which returns true if a user-triggered pop gesture
+ /// would be allowed.
+ bool get popGestureInProgress => navigator!.userGestureInProgress;
+
+ /// Whether a pop gesture can be started by the user for this route.
+ ///
+ /// Returns true if the user can edge-swipe to a previous route.
+ ///
+ /// This should only be used between frames, not during build.
+ @override
+ bool get popGestureEnabled {
+ // If there's nothing to go back to, then obviously we don't support
+ // the back gesture.
+ if (isFirst) {
+ return false;
+ }
+ // If the route wouldn't actually pop if we popped it, then the gesture
+ // would be really confusing (or would skip internal routes), so disallow it.
+ if (willHandlePopInternally) {
+ return false;
+ }
+ // If attempts to dismiss this route might be vetoed such as in a page
+ // with forms, then do not allow the user to dismiss the route with a swipe.
+ if (hasScopedWillPopCallback ||
+ popDisposition == RoutePopDisposition.doNotPop) {
+ return false;
+ }
+ // If we're in an animation already, we cannot be manually swiped.
+ if (animation!.status != AnimationStatus.completed) {
+ return false;
+ }
+ // If we're being popped into, we also cannot be swiped until the pop above
+ // it completes. This translates to our secondary animation being
+ // dismissed.
+ if (secondaryAnimation!.status != AnimationStatus.dismissed) {
+ return false;
+ }
+ // If we're in a gesture already, we cannot start another.
+ if (popGestureInProgress) {
+ return false;
+ }
+
+ // Looks like a back gesture would be welcome!
+ return true;
+ }
// The API for _ModalScope and HeroController
@@ -1562,13 +1724,12 @@
/// method checks.
@override
RoutePopDisposition get popDisposition {
- final bool canPop = _popEntries.every((PopEntry popEntry) {
- return popEntry.canPopNotifier.value;
- });
-
- if (!canPop) {
- return RoutePopDisposition.doNotPop;
+ for (final PopEntry popEntry in _popEntries) {
+ if (!popEntry.canPopNotifier.value) {
+ return RoutePopDisposition.doNotPop;
+ }
}
+
return super.popDisposition;
}
diff --git a/packages/flutter/test/material/page_transitions_theme_test.dart b/packages/flutter/test/material/page_transitions_theme_test.dart
index 4334980..275c310 100644
--- a/packages/flutter/test/material/page_transitions_theme_test.dart
+++ b/packages/flutter/test/material/page_transitions_theme_test.dart
@@ -6,9 +6,12 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
+ final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
+
testWidgets('Default PageTransitionsTheme platform', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: Text('home')));
final PageTransitionsTheme theme = Theme.of(tester.element(find.text('home'))).pageTransitionsTheme;
@@ -430,4 +433,90 @@
await tester.pumpAndSettle();
expect(builtCount, 1);
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
+
+ testWidgets('predictive back gestures pop the route on all platforms regardless of whether their transition handles predictive back', (WidgetTester tester) async {
+ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
+ '/': (BuildContext context) => Material(
+ child: TextButton(
+ child: const Text('push'),
+ onPressed: () { Navigator.of(context).pushNamed('/b'); },
+ ),
+ ),
+ '/b': (BuildContext context) => const Text('page b'),
+ };
+
+ await tester.pumpWidget(
+ MaterialApp(
+ routes: routes,
+ ),
+ );
+
+ expect(find.text('push'), findsOneWidget);
+ expect(find.text('page b'), findsNothing);
+
+ await tester.tap(find.text('push'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('push'), findsNothing);
+ expect(find.text('page b'), findsOneWidget);
+
+ // Start a system pop gesture.
+ final ByteData startMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'startBackGesture',
+ <String, dynamic>{
+ 'touchOffset': <double>[5.0, 300.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ startMessage,
+ (ByteData? _) {},
+ );
+ await tester.pump();
+
+ expect(find.text('push'), findsNothing);
+ expect(find.text('page b'), findsOneWidget);
+
+ // Drag the system back gesture far enough to commit.
+ final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'updateBackGestureProgress',
+ <String, dynamic>{
+ 'x': 100.0,
+ 'y': 300.0,
+ 'progress': 0.35,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ updateMessage,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('push'), findsNothing);
+ expect(find.text('page b'), findsOneWidget);
+
+ // Commit the system back gesture.
+ final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'commitBackGesture',
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ commitMessage,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('push'), findsOneWidget);
+ expect(find.text('page b'), findsNothing);
+ }, variant: TargetPlatformVariant.all());
}
diff --git a/packages/flutter/test/material/predictive_back_page_transitions_builder_test.dart b/packages/flutter/test/material/predictive_back_page_transitions_builder_test.dart
new file mode 100644
index 0000000..3df1a69
--- /dev/null
+++ b/packages/flutter/test/material/predictive_back_page_transitions_builder_test.dart
@@ -0,0 +1,444 @@
+// 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/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
+
+ Finder findPredictiveBackPageTransition() {
+ return find.descendant(
+ of: find.byType(MaterialApp),
+ matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_PredictiveBackPageTransition'),
+ );
+ }
+ Finder findFallbackPageTransition() {
+ return find.descendant(
+ of: find.byType(MaterialApp),
+ matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_ZoomPageTransition'),
+ );
+ }
+
+ testWidgets('PredictiveBackPageTransitionsBuilder supports predictive back on Android', (WidgetTester tester) async {
+ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
+ '/': (BuildContext context) => Material(
+ child: TextButton(
+ child: const Text('push'),
+ onPressed: () { Navigator.of(context).pushNamed('/b'); },
+ ),
+ ),
+ '/b': (BuildContext context) => const Text('page b'),
+ };
+
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(
+ pageTransitionsTheme: PageTransitionsTheme(
+ builders: <TargetPlatform, PageTransitionsBuilder>{
+ for (final TargetPlatform platform in TargetPlatform.values)
+ platform: const PredictiveBackPageTransitionsBuilder(),
+ },
+ ),
+ ),
+ routes: routes,
+ ),
+ );
+
+ expect(find.text('push'), findsOneWidget);
+ expect(find.text('page b'), findsNothing);
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+
+ await tester.tap(find.text('push'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('push'), findsNothing);
+ expect(find.text('page b'), findsOneWidget);
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+
+ // Only Android supports backGesture channel methods. Other platforms will
+ // do nothing.
+ if (defaultTargetPlatform != TargetPlatform.android) {
+ return;
+ }
+
+ // Start a system pop gesture, which will switch to using
+ // _PredictiveBackPageTransition for the page transition.
+ final ByteData startMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'startBackGesture',
+ <String, dynamic>{
+ 'touchOffset': <double>[5.0, 300.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ startMessage,
+ (ByteData? _) {},
+ );
+ await tester.pump();
+
+ expect(findPredictiveBackPageTransition(), findsOneWidget);
+ expect(findFallbackPageTransition(), findsNothing);
+ final Offset startPageBOffset = tester.getTopLeft(find.text('page b'));
+ expect(startPageBOffset.dx, 0.0);
+
+ // Drag the system back gesture far enough to commit.
+ final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'updateBackGestureProgress',
+ <String, dynamic>{
+ 'x': 100.0,
+ 'y': 300.0,
+ 'progress': 0.35,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ updateMessage,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(findPredictiveBackPageTransition(), findsNWidgets(2));
+ expect(findFallbackPageTransition(), findsNothing);
+
+ final Offset updatePageBOffset = tester.getTopLeft(find.text('page b'));
+ expect(updatePageBOffset.dx, greaterThan(startPageBOffset.dx));
+
+ // Commit the system back gesture.
+ final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'commitBackGesture',
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ commitMessage,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+ expect(find.text('push'), findsOneWidget);
+ expect(find.text('page b'), findsNothing);
+ }, variant: TargetPlatformVariant.all());
+
+ testWidgets('PredictiveBackPageTransitionsBuilder supports canceling a predictive back gesture', (WidgetTester tester) async {
+ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
+ '/': (BuildContext context) => Material(
+ child: TextButton(
+ child: const Text('push'),
+ onPressed: () { Navigator.of(context).pushNamed('/b'); },
+ ),
+ ),
+ '/b': (BuildContext context) => const Text('page b'),
+ };
+
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(
+ pageTransitionsTheme: PageTransitionsTheme(
+ builders: <TargetPlatform, PageTransitionsBuilder>{
+ for (final TargetPlatform platform in TargetPlatform.values)
+ platform: const PredictiveBackPageTransitionsBuilder(),
+ },
+ ),
+ ),
+ routes: routes,
+ ),
+ );
+
+ expect(find.text('push'), findsOneWidget);
+ expect(find.text('page b'), findsNothing);
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+
+ await tester.tap(find.text('push'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('push'), findsNothing);
+ expect(find.text('page b'), findsOneWidget);
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+
+ // Only Android supports backGesture channel methods. Other platforms will
+ // do nothing.
+ if (defaultTargetPlatform != TargetPlatform.android) {
+ return;
+ }
+
+ // Start a system pop gesture, which will switch to using
+ // _PredictiveBackPageTransition for the page transition.
+ final ByteData startMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'startBackGesture',
+ <String, dynamic>{
+ 'touchOffset': <double>[5.0, 300.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ startMessage,
+ (ByteData? _) {},
+ );
+ await tester.pump();
+
+ expect(findPredictiveBackPageTransition(), findsOneWidget);
+ expect(findFallbackPageTransition(), findsNothing);
+ final Offset startPageBOffset = tester.getTopLeft(find.text('page b'));
+ expect(startPageBOffset.dx, 0.0);
+
+ // Drag the system back gesture.
+ final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'updateBackGestureProgress',
+ <String, dynamic>{
+ 'touchOffset': <double>[100.0, 300.0],
+ 'progress': 0.35,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ updateMessage,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(findPredictiveBackPageTransition(), findsNWidgets(2));
+ expect(findFallbackPageTransition(), findsNothing);
+
+ final Offset updatePageBOffset = tester.getTopLeft(find.text('page b'));
+ expect(updatePageBOffset.dx, greaterThan(startPageBOffset.dx));
+
+ // Cancel the system back gesture.
+ final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'cancelBackGesture',
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ commitMessage,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('push'), findsNothing);
+ expect(find.text('page b'), findsOneWidget);
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+ }, variant: TargetPlatformVariant.all());
+
+ testWidgets('if multiple PredictiveBackPageTransitionBuilder observers, only one gets called for a given back gesture', (WidgetTester tester) async {
+ bool includingNestedNavigator = false;
+ late StateSetter setState;
+ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
+ '/': (BuildContext context) => Material(
+ child: TextButton(
+ child: const Text('push'),
+ onPressed: () { Navigator.of(context).pushNamed('/b'); },
+ ),
+ ),
+ '/b': (BuildContext context) => Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: <Widget>[
+ const Text('page b'),
+ StatefulBuilder(
+ builder: (BuildContext context, StateSetter localSetState) {
+ setState = localSetState;
+ if (!includingNestedNavigator) {
+ return const SizedBox.shrink();
+ }
+ return Navigator(
+ initialRoute: 'b/nested',
+ onGenerateRoute: (RouteSettings settings) {
+ WidgetBuilder builder;
+ switch (settings.name) {
+ case 'b/nested':
+ builder = (BuildContext context) => Material(
+ child: Theme(
+ data: ThemeData(
+ pageTransitionsTheme: PageTransitionsTheme(
+ builders: <TargetPlatform, PageTransitionsBuilder>{
+ for (final TargetPlatform platform in TargetPlatform.values)
+ platform: const PredictiveBackPageTransitionsBuilder(),
+ },
+ ),
+ ),
+ child: const Column(
+ children: <Widget>[
+ Text('Nested route inside of page b'),
+ ],
+ ),
+ ),
+ );
+ default:
+ throw Exception('Invalid route: ${settings.name}');
+ }
+ return MaterialPageRoute<void>(builder: builder, settings: settings);
+ },
+ );
+ },
+ ),
+ ],
+ ),
+ };
+
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(
+ pageTransitionsTheme: PageTransitionsTheme(
+ builders: <TargetPlatform, PageTransitionsBuilder>{
+ for (final TargetPlatform platform in TargetPlatform.values)
+ platform: const PredictiveBackPageTransitionsBuilder(),
+ },
+ ),
+ ),
+ routes: routes,
+ ),
+ );
+
+ expect(find.text('push'), findsOneWidget);
+ expect(find.text('page b'), findsNothing);
+ expect(find.text('Nested route inside of page b'), findsNothing);
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+
+ await tester.tap(find.text('push'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('push'), findsNothing);
+ expect(find.text('page b'), findsOneWidget);
+ expect(find.text('Nested route inside of page b'), findsNothing);
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+
+ // Only Android supports backGesture channel methods. Other platforms will
+ // do nothing.
+ if (defaultTargetPlatform != TargetPlatform.android) {
+ return;
+ }
+
+ // Start a system pop gesture, which will switch to using
+ // _PredictiveBackPageTransition for the page transition.
+ final ByteData startMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'startBackGesture',
+ <String, dynamic>{
+ 'touchOffset': <double>[5.0, 300.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ startMessage,
+ (ByteData? _) {},
+ );
+ await tester.pump();
+
+ expect(findPredictiveBackPageTransition(), findsOneWidget);
+ expect(findFallbackPageTransition(), findsNothing);
+ final Offset startPageBOffset = tester.getTopLeft(find.text('page b'));
+ expect(startPageBOffset.dx, 0.0);
+
+ // Drag the system back gesture.
+ final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'updateBackGestureProgress',
+ <String, dynamic>{
+ 'touchOffset': <double>[100.0, 300.0],
+ 'progress': 0.3,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ updateMessage,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(findPredictiveBackPageTransition(), findsNWidgets(2));
+ expect(findFallbackPageTransition(), findsNothing);
+
+ final Offset updatePageBOffset = tester.getTopLeft(find.text('page b'));
+ expect(updatePageBOffset.dx, greaterThan(startPageBOffset.dx));
+
+ // In the middle of the system back gesture here, add a nested Navigator
+ // that includes a new predictive back gesture observer.
+ setState(() {
+ includingNestedNavigator = true;
+ });
+ await tester.pumpAndSettle();
+ expect(find.text('push'), findsOneWidget);
+ expect(find.text('page b'), findsOneWidget);
+ expect(find.text('Nested route inside of page b'), findsOneWidget);
+
+ // Send another drag gesture, and ensure that the original observer still
+ // gets it.
+ final ByteData updateMessage2 = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'updateBackGestureProgress',
+ <String, dynamic>{
+ 'touchOffset': <double>[110.0, 300.0],
+ 'progress': 0.35,
+ 'swipeEdge': 0, // left
+ },
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ updateMessage2,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(findPredictiveBackPageTransition(), findsNWidgets(2));
+ // Despite using a PredictiveBackPageTransitions, the new route has not
+ // received a start event, so it is still using the fallback transition.
+ expect(findFallbackPageTransition(), findsOneWidget);
+
+ final Offset update2PageBOffset = tester.getTopLeft(find.text('page b'));
+ expect(update2PageBOffset.dx, greaterThan(updatePageBOffset.dx));
+
+ // Commit the system back gesture, and the original observer is able to
+ // handle the back without interference.
+ final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall(
+ const MethodCall(
+ 'commitBackGesture',
+ ),
+ );
+ await binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ commitMessage,
+ (ByteData? _) {},
+ );
+ await tester.pumpAndSettle();
+
+ expect(findPredictiveBackPageTransition(), findsNothing);
+ expect(findFallbackPageTransition(), findsOneWidget);
+ expect(find.text('push'), findsOneWidget);
+ expect(find.text('page b'), findsNothing);
+ expect(find.text('Nested route inside of page b'), findsNothing);
+ }, variant: TargetPlatformVariant.all());
+}
diff --git a/packages/flutter/test/services/predictive_back_event_test.dart b/packages/flutter/test/services/predictive_back_event_test.dart
new file mode 100644
index 0000000..a82ab30
--- /dev/null
+++ b/packages/flutter/test/services/predictive_back_event_test.dart
@@ -0,0 +1,100 @@
+// 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/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('fromMap can be created with valid Map - SwipeEdge.left', () async {
+ final PredictiveBackEvent event = PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[0.0, 100.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0,
+ });
+ expect(event.swipeEdge, SwipeEdge.left);
+ expect(event.isButtonEvent, isFalse);
+ });
+
+ test('fromMap can be created with valid Map - SwipeEdge.right', () async {
+ final PredictiveBackEvent event = PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[0.0, 100.0],
+ 'progress': 0.0,
+ 'swipeEdge': 1,
+ });
+ expect(event.swipeEdge, SwipeEdge.right);
+ expect(event.isButtonEvent, isFalse);
+ });
+
+ test('fromMap can be created with valid Map - isButtonEvent zero position', () async {
+ final PredictiveBackEvent event = PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[0.0, 0.0],
+ 'progress': 0.0,
+ 'swipeEdge': 1,
+ });
+ expect(event.isButtonEvent, isTrue);
+ });
+
+ test('fromMap can be created with valid Map - isButtonEvent null position', () async {
+ final PredictiveBackEvent event = PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': null,
+ 'progress': 0.0,
+ 'swipeEdge': 1,
+ });
+ expect(event.isButtonEvent, isTrue);
+ });
+
+ test('fromMap throws when given invalid progress', () async {
+ expect(
+ () => PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[0.0, 100.0],
+ 'progress': 2.0,
+ 'swipeEdge': 1,
+ }),
+ throwsAssertionError,
+ );
+ });
+
+ test('fromMap throws when given invalid swipeEdge', () async {
+ expect(
+ () => PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[0.0, 100.0],
+ 'progress': 0.0,
+ 'swipeEdge': 2,
+ }),
+ throwsRangeError,
+ );
+ });
+
+ test('equality when created with the same parameters', () async {
+ final PredictiveBackEvent eventA = PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[0.0, 100.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0,
+ });
+ final PredictiveBackEvent eventB = PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[0.0, 100.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0,
+ });
+ expect(eventA, equals(eventB));
+ expect(eventA.hashCode, equals(eventB.hashCode));
+ expect(eventA.toString(), equals(eventB.toString()));
+ });
+
+ test('when created with different parameters', () async {
+ final PredictiveBackEvent eventA = PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[0.0, 100.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0,
+ });
+ final PredictiveBackEvent eventB = PredictiveBackEvent.fromMap(const <String?, Object?>{
+ 'touchOffset': <double>[1.0, 100.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0,
+ });
+ expect(eventA, isNot(equals(eventB)));
+ expect(eventA.hashCode, isNot(equals(eventB.hashCode)));
+ expect(eventA.toString(), isNot(equals(eventB.toString())));
+ });
+}
diff --git a/packages/flutter/test/widgets/binding_test.dart b/packages/flutter/test/widgets/binding_test.dart
index a591474..3152913 100644
--- a/packages/flutter/test/widgets/binding_test.dart
+++ b/packages/flutter/test/widgets/binding_test.dart
@@ -114,6 +114,34 @@
}
@override
+ bool handleStartBackGesture(PredictiveBackEvent backEvent) {
+ assert(active);
+ WidgetsBinding.instance.addObserver(this);
+ return true;
+ }
+
+ @override
+ bool handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) {
+ assert(active);
+ WidgetsBinding.instance.addObserver(this);
+ return true;
+ }
+
+ @override
+ bool handleCommitBackGesture() {
+ assert(active);
+ WidgetsBinding.instance.addObserver(this);
+ return true;
+ }
+
+ @override
+ bool handleCancelBackGesture() {
+ assert(active);
+ WidgetsBinding.instance.addObserver(this);
+ return true;
+ }
+
+ @override
Future<bool> didPushRoute(String route) {
assert(active);
WidgetsBinding.instance.addObserver(this);