Unverified Commit 2079f349 authored by maRci002's avatar maRci002 Committed by GitHub

[WIP] Predictive back support for routes (#141373)

A new page transition, PredictiveBackPageTransitionsBuilder, which handles predictive back gestures on Android (where supported).
parent ab4db162
...@@ -137,6 +137,7 @@ export 'src/material/page_transitions_theme.dart'; ...@@ -137,6 +137,7 @@ export 'src/material/page_transitions_theme.dart';
export 'src/material/paginated_data_table.dart'; export 'src/material/paginated_data_table.dart';
export 'src/material/popup_menu.dart'; export 'src/material/popup_menu.dart';
export 'src/material/popup_menu_theme.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.dart';
export 'src/material/progress_indicator_theme.dart'; export 'src/material/progress_indicator_theme.dart';
export 'src/material/radio.dart'; export 'src/material/radio.dart';
......
...@@ -33,6 +33,7 @@ export 'src/services/mouse_cursor.dart'; ...@@ -33,6 +33,7 @@ export 'src/services/mouse_cursor.dart';
export 'src/services/mouse_tracking.dart'; export 'src/services/mouse_tracking.dart';
export 'src/services/platform_channel.dart'; export 'src/services/platform_channel.dart';
export 'src/services/platform_views.dart'; export 'src/services/platform_views.dart';
export 'src/services/predictive_back_event.dart';
export 'src/services/process_text.dart'; export 'src/services/process_text.dart';
export 'src/services/raw_keyboard.dart'; export 'src/services/raw_keyboard.dart';
export 'src/services/raw_keyboard_android.dart'; export 'src/services/raw_keyboard_android.dart';
......
...@@ -156,79 +156,6 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> { ...@@ -156,79 +156,6 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
return nextRoute is CupertinoRouteTransitionMixin && !nextRoute.fullscreenDialog; 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 @override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
final Widget child = buildContent(context); final Widget child = buildContent(context);
...@@ -243,7 +170,7 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> { ...@@ -243,7 +170,7 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
// gesture is detected. The returned controller handles all of the subsequent // gesture is detected. The returned controller handles all of the subsequent
// drag events. // drag events.
static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) { static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
assert(_isPopGestureEnabled(route)); assert(route.popGestureEnabled);
return _CupertinoBackGestureController<T>( return _CupertinoBackGestureController<T>(
navigator: route.navigator!, navigator: route.navigator!,
...@@ -279,7 +206,7 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> { ...@@ -279,7 +206,7 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
// //
// In the middle of a back gesture drag, let the transition be linear to // In the middle of a back gesture drag, let the transition be linear to
// match finger motions. // match finger motions.
final bool linearTransition = isPopGestureInProgress(route); final bool linearTransition = route.popGestureInProgress;
if (route.fullscreenDialog) { if (route.fullscreenDialog) {
return CupertinoFullscreenDialogTransition( return CupertinoFullscreenDialogTransition(
primaryRouteAnimation: animation, primaryRouteAnimation: animation,
...@@ -293,10 +220,8 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> { ...@@ -293,10 +220,8 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
secondaryRouteAnimation: secondaryAnimation, secondaryRouteAnimation: secondaryAnimation,
linearTransition: linearTransition, linearTransition: linearTransition,
child: _CupertinoBackGestureDetector<T>( child: _CupertinoBackGestureDetector<T>(
enabledCallback: () => _isPopGestureEnabled<T>(route), enabledCallback: () => route.popGestureEnabled,
onStartPopGesture: () => _startPopGesture<T>(route), onStartPopGesture: () => _startPopGesture<T>(route),
getIsCurrent: () => route.isCurrent,
getIsActive: () => route.isActive,
child: child, child: child,
), ),
); );
...@@ -600,8 +525,6 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget { ...@@ -600,8 +525,6 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget {
required this.enabledCallback, required this.enabledCallback,
required this.onStartPopGesture, required this.onStartPopGesture,
required this.child, required this.child,
required this.getIsActive,
required this.getIsCurrent,
}); });
final Widget child; final Widget child;
...@@ -610,9 +533,6 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget { ...@@ -610,9 +533,6 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget {
final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture; final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture;
final ValueGetter<bool> getIsActive;
final ValueGetter<bool> getIsCurrent;
@override @override
_CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>(); _CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>();
} }
......
...@@ -7,6 +7,7 @@ import 'dart:ui' as ui; ...@@ -7,6 +7,7 @@ import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'colors.dart'; import 'colors.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -545,6 +546,9 @@ abstract class PageTransitionsBuilder { ...@@ -545,6 +546,9 @@ abstract class PageTransitionsBuilder {
/// that's similar to the one provided in Android Q. /// that's similar to the one provided in Android Q.
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page /// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
/// transition that matches native iOS page transitions. /// 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 { class FadeUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
/// Constructs a page transition animation that slides the page up. /// Constructs a page transition animation that slides the page up.
const FadeUpwardsPageTransitionsBuilder(); const FadeUpwardsPageTransitionsBuilder();
...@@ -573,6 +577,8 @@ class FadeUpwardsPageTransitionsBuilder extends PageTransitionsBuilder { ...@@ -573,6 +577,8 @@ class FadeUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
/// that's similar to the one provided in Android Q. /// that's similar to the one provided in Android Q.
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page /// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
/// transition that matches native iOS page transitions. /// 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 { class OpenUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
/// Constructs a page transition animation that matches the transition used on /// Constructs a page transition animation that matches the transition used on
/// Android P. /// Android P.
...@@ -606,6 +612,8 @@ class OpenUpwardsPageTransitionsBuilder extends PageTransitionsBuilder { ...@@ -606,6 +612,8 @@ class OpenUpwardsPageTransitionsBuilder extends PageTransitionsBuilder {
/// that's similar to the one provided by Android P. /// that's similar to the one provided by Android P.
/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page /// * [CupertinoPageTransitionsBuilder], which defines a horizontal page
/// transition that matches native iOS page transitions. /// 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 { class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
/// Constructs a page transition animation that matches the transition used on /// Constructs a page transition animation that matches the transition used on
/// Android Q. /// Android Q.
...@@ -656,11 +664,11 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder { ...@@ -656,11 +664,11 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
@override @override
Widget buildTransitions<T>( Widget buildTransitions<T>(
PageRoute<T>? route, PageRoute<T> route,
BuildContext? context, BuildContext context,
Animation<double> animation, Animation<double> animation,
Animation<double> secondaryAnimation, Animation<double> secondaryAnimation,
Widget? child, Widget child,
) { ) {
if (_kProfileForceDisableSnapshotting) { if (_kProfileForceDisableSnapshotting) {
return _ZoomPageTransitionNoCache( return _ZoomPageTransitionNoCache(
...@@ -672,7 +680,7 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder { ...@@ -672,7 +680,7 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
return _ZoomPageTransition( return _ZoomPageTransition(
animation: animation, animation: animation,
secondaryAnimation: secondaryAnimation, secondaryAnimation: secondaryAnimation,
allowSnapshotting: allowSnapshotting && (route?.allowSnapshotting ?? true), allowSnapshotting: allowSnapshotting && route.allowSnapshotting,
allowEnterRouteSnapshotting: allowEnterRouteSnapshotting, allowEnterRouteSnapshotting: allowEnterRouteSnapshotting,
child: child, child: child,
); );
...@@ -690,6 +698,8 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder { ...@@ -690,6 +698,8 @@ class ZoomPageTransitionsBuilder extends PageTransitionsBuilder {
/// that's similar to the one provided by Android P. /// that's similar to the one provided by Android P.
/// * [ZoomPageTransitionsBuilder], which defines the default page transition /// * [ZoomPageTransitionsBuilder], which defines the default page transition
/// that's similar to the one provided in Android Q. /// 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 { class CupertinoPageTransitionsBuilder extends PageTransitionsBuilder {
/// Constructs a page transition animation that matches the iOS transition. /// Constructs a page transition animation that matches the iOS transition.
const CupertinoPageTransitionsBuilder(); const CupertinoPageTransitionsBuilder();
...@@ -741,7 +751,9 @@ class PageTransitionsTheme with Diagnosticable { ...@@ -741,7 +751,9 @@ class PageTransitionsTheme with Diagnosticable {
/// By default the list of builders is: [ZoomPageTransitionsBuilder] /// By default the list of builders is: [ZoomPageTransitionsBuilder]
/// for [TargetPlatform.android], and [CupertinoPageTransitionsBuilder] for /// for [TargetPlatform.android], and [CupertinoPageTransitionsBuilder] for
/// [TargetPlatform.iOS] and [TargetPlatform.macOS]. /// [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>{ static const Map<TargetPlatform, PageTransitionsBuilder> _defaultBuilders = <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: ZoomPageTransitionsBuilder(), TargetPlatform.android: ZoomPageTransitionsBuilder(),
...@@ -765,17 +777,13 @@ class PageTransitionsTheme with Diagnosticable { ...@@ -765,17 +777,13 @@ class PageTransitionsTheme with Diagnosticable {
Animation<double> secondaryAnimation, Animation<double> secondaryAnimation,
Widget child, Widget child,
) { ) {
TargetPlatform platform = Theme.of(context).platform; return _PageTransitionsThemeTransitions<T>(
builders: builders,
if (CupertinoRouteTransitionMixin.isPopGestureInProgress(route)) { route: route,
platform = TargetPlatform.iOS; animation: animation,
} secondaryAnimation: secondaryAnimation,
child: child,
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);
} }
// Map the builders to a list with one PageTransitionsBuilder per platform for // Map the builders to a list with one PageTransitionsBuilder per platform for
...@@ -815,6 +823,55 @@ class PageTransitionsTheme with Diagnosticable { ...@@ -815,6 +823,55 @@ class PageTransitionsTheme with Diagnosticable {
} }
} }
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]. // 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) { void _drawImageScaledAndCentered(PaintingContext context, ui.Image image, double scale, double opacity, double pixelRatio) {
if (scale <= 0.0 || opacity <= 0.0) { if (scale <= 0.0 || opacity <= 0.0) {
......
// 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}';
}
}
...@@ -58,6 +58,27 @@ abstract final class SystemChannels { ...@@ -58,6 +58,27 @@ abstract final class SystemChannels {
JSONMethodCodec(), 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. /// A JSON [MethodChannel] for invoking miscellaneous platform methods.
/// ///
/// The following outgoing methods are defined for this channel (invoked using /// The following outgoing methods are defined for this channel (invoked using
......
...@@ -76,6 +76,57 @@ abstract mixin class WidgetsBindingObserver { ...@@ -76,6 +76,57 @@ abstract mixin class WidgetsBindingObserver {
/// {@macro flutter.widgets.AndroidPredictiveBack} /// {@macro flutter.widgets.AndroidPredictiveBack}
Future<bool> didPopRoute() => Future<bool>.value(false); 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 /// Called when the host tells the application to push a new route onto the
/// navigator. /// navigator.
/// ///
...@@ -360,6 +411,9 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB ...@@ -360,6 +411,9 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB
buildOwner!.onBuildScheduled = _handleBuildScheduled; buildOwner!.onBuildScheduled = _handleBuildScheduled;
platformDispatcher.onLocaleChanged = handleLocaleChanged; platformDispatcher.onLocaleChanged = handleLocaleChanged;
SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation); SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation);
SystemChannels.backGesture.setMethodCallHandler(
_handleBackGestureInvocation,
);
assert(() { assert(() {
FlutterErrorDetails.propertiesTransformers.add(debugTransformDebugCreator); FlutterErrorDetails.propertiesTransformers.add(debugTransformDebugCreator);
return true; return true;
...@@ -646,7 +700,12 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB ...@@ -646,7 +700,12 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB
/// ///
/// * [addObserver], for the method that adds observers in the first place. /// * [addObserver], for the method that adds observers in the first place.
/// * [WidgetsBindingObserver], which has an example of using this method. /// * [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 @override
Future<AppExitResponse> handleRequestAppExit() async { Future<AppExitResponse> handleRequestAppExit() async {
...@@ -780,6 +839,50 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB ...@@ -780,6 +839,50 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB
SystemNavigator.pop(); 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 /// Called when the host tells the app to push a new route onto the
/// navigator. /// navigator.
/// ///
...@@ -823,6 +926,18 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB ...@@ -823,6 +926,18 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB
}; };
} }
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 @override
void handleAppLifecycleStateChanged(AppLifecycleState state) { void handleAppLifecycleStateChanged(AppLifecycleState state) {
super.handleAppLifecycleStateChanged(state); super.handleAppLifecycleStateChanged(state);
......
...@@ -51,6 +51,12 @@ abstract class PageRoute<T> extends ModalRoute<T> { ...@@ -51,6 +51,12 @@ abstract class PageRoute<T> extends ModalRoute<T> {
@override @override
bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) => previousRoute is PageRoute; 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) { Widget _defaultTransitionsBuilder(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -97,7 +98,7 @@ abstract class OverlayRoute<T> extends Route<T> { ...@@ -97,7 +98,7 @@ abstract class OverlayRoute<T> extends Route<T> {
/// See also: /// See also:
/// ///
/// * [Route], which documents the meaning of the `T` generic type argument. /// * [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. /// Creates a route that animates itself when it is pushed or popped.
TransitionRoute({ TransitionRoute({
super.settings, super.settings,
...@@ -476,6 +477,84 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> { ...@@ -476,6 +477,84 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
/// [ModalRoute.buildTransitions] `secondaryAnimation` to run. /// [ModalRoute.buildTransitions] `secondaryAnimation` to run.
bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) => true; 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 @override
void dispose() { void dispose() {
assert(!_transitionCompleter.isCompleted, 'Cannot dispose a $runtimeType twice.'); assert(!_transitionCompleter.isCompleted, 'Cannot dispose a $runtimeType twice.');
...@@ -496,6 +575,39 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> { ...@@ -496,6 +575,39 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
String toString() => '${objectRuntimeType(this, 'TransitionRoute')}(animation: $_controller)'; 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]. /// An entry in the history of a [LocalHistoryRoute].
class LocalHistoryEntry { class LocalHistoryEntry {
/// Creates an entry in the history of a [LocalHistoryRoute]. /// Creates an entry in the history of a [LocalHistoryRoute].
...@@ -1465,6 +1577,56 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T ...@@ -1465,6 +1577,56 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// of this property. /// of this property.
bool get maintainState; 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 // The API for _ModalScope and HeroController
...@@ -1562,13 +1724,12 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T ...@@ -1562,13 +1724,12 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// method checks. /// method checks.
@override @override
RoutePopDisposition get popDisposition { RoutePopDisposition get popDisposition {
final bool canPop = _popEntries.every((PopEntry popEntry) { for (final PopEntry popEntry in _popEntries) {
return popEntry.canPopNotifier.value; if (!popEntry.canPopNotifier.value) {
}); return RoutePopDisposition.doNotPop;
}
if (!canPop) {
return RoutePopDisposition.doNotPop;
} }
return super.popDisposition; return super.popDisposition;
} }
......
...@@ -6,9 +6,12 @@ import 'package:flutter/cupertino.dart'; ...@@ -6,9 +6,12 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Default PageTransitionsTheme platform', (WidgetTester tester) async { testWidgets('Default PageTransitionsTheme platform', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: Text('home'))); await tester.pumpWidget(const MaterialApp(home: Text('home')));
final PageTransitionsTheme theme = Theme.of(tester.element(find.text('home'))).pageTransitionsTheme; final PageTransitionsTheme theme = Theme.of(tester.element(find.text('home'))).pageTransitionsTheme;
...@@ -430,4 +433,90 @@ void main() { ...@@ -430,4 +433,90 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(builtCount, 1); expect(builtCount, 1);
}, variant: TargetPlatformVariant.only(TargetPlatform.android)); }, 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());
} }
// 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())));
});
}
...@@ -113,6 +113,34 @@ class RentrantObserver implements WidgetsBindingObserver { ...@@ -113,6 +113,34 @@ class RentrantObserver implements WidgetsBindingObserver {
return Future<bool>.value(true); return Future<bool>.value(true);
} }
@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 @override
Future<bool> didPushRoute(String route) { Future<bool> didPushRoute(String route) {
assert(active); assert(active);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment