[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);