
This PR aims to support Android's predictive back gesture when popping the entire Flutter app. Predictive route transitions between routes inside of a Flutter app will come later. <img width="200" src="https://user-images.githubusercontent.com/389558/217918109-945febaa-9086-41cc-a476-1a189c7831d8.gif" /> ### Trying it out If you want to try this feature yourself, here are the necessary steps: 1. Run Android 33 or above. 1. Enable the feature flag for predictive back on the device under "Developer options". 1. Create a Flutter project, or clone [my example project](https://github.com/justinmc/flutter_predictive_back_examples). 1. Set `android:enableOnBackInvokedCallback="true"` in android/app/src/main/AndroidManifest.xml (already done in the example project). 1. Check out this branch. 1. Run the app. Perform a back gesture (swipe from the left side of the screen). You should see the predictive back animation like in the animation above and be able to commit or cancel it. ### go_router support go_router works with predictive back out of the box because it uses a Navigator internally that dispatches NavigationNotifications! ~~go_router can be supported by adding a listener to the router and updating SystemNavigator.setFrameworkHandlesBack.~~ Similar to with nested Navigators, nested go_routers is supported by using a PopScope widget. <details> <summary>Full example of nested go_routers</summary> ```dart // 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:go_router/go_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; void main() => runApp(_MyApp()); class _MyApp extends StatelessWidget { final GoRouter router = GoRouter( routes: <RouteBase>[ GoRoute( path: '/', builder: (BuildContext context, GoRouterState state) => _HomePage(), ), GoRoute( path: '/nested_navigators', builder: (BuildContext context, GoRouterState state) => _NestedGoRoutersPage(), ), ], ); @override Widget build(BuildContext context) { return MaterialApp.router( routerConfig: router, ); } } class _HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Nested Navigators Example'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text('Home Page'), const Text('A system back gesture here will exit the app.'), const SizedBox(height: 20.0), ListTile( title: const Text('Nested go_router route'), subtitle: const Text('This route has another go_router in addition to the one used with MaterialApp above.'), onTap: () { context.push('/nested_navigators'); }, ), ], ), ), ); } } class _NestedGoRoutersPage extends StatefulWidget { @override State<_NestedGoRoutersPage> createState() => _NestedGoRoutersPageState(); } class _NestedGoRoutersPageState extends State<_NestedGoRoutersPage> { late final GoRouter _router; final GlobalKey<NavigatorState> _nestedNavigatorKey = GlobalKey<NavigatorState>(); // If the nested navigator has routes that can be popped, then we want to // block the root navigator from handling the pop so that the nested navigator // can handle it instead. bool get _popEnabled { // canPop will throw an error if called before build. Is this the best way // to avoid that? return _nestedNavigatorKey.currentState == null ? true : !_router.canPop(); } void _onRouterChanged() { // Here the _router reports the location correctly, but canPop is still out // of date. Hence the post frame callback. SchedulerBinding.instance.addPostFrameCallback((Duration duration) { setState(() {}); }); } @override void initState() { super.initState(); final BuildContext rootContext = context; _router = GoRouter( navigatorKey: _nestedNavigatorKey, routes: [ GoRoute( path: '/', builder: (BuildContext context, GoRouterState state) => _LinksPage( title: 'Nested once - home route', backgroundColor: Colors.indigo, onBack: () { rootContext.pop(); }, buttons: <Widget>[ TextButton( onPressed: () { context.push('/two'); }, child: const Text('Go to another route in this nested Navigator'), ), ], ), ), GoRoute( path: '/two', builder: (BuildContext context, GoRouterState state) => _LinksPage( backgroundColor: Colors.indigo.withBlue(255), title: 'Nested once - page two', ), ), ], ); _router.addListener(_onRouterChanged); } @override void dispose() { _router.removeListener(_onRouterChanged); super.dispose(); } @override Widget build(BuildContext context) { return PopScope( popEnabled: _popEnabled, onPopped: (bool success) { if (success) { return; } _router.pop(); }, child: Router<Object>.withConfig( restorationScopeId: 'router-2', config: _router, ), ); } } class _LinksPage extends StatelessWidget { const _LinksPage ({ required this.backgroundColor, this.buttons = const <Widget>[], this.onBack, required this.title, }); final Color backgroundColor; final List<Widget> buttons; final VoidCallback? onBack; final String title; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: backgroundColor, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text(title), //const Text('A system back here will go back to Nested Navigators Page One'), ...buttons, TextButton( onPressed: onBack ?? () { context.pop(); }, child: const Text('Go back'), ), ], ), ), ); } } ``` </details> ### Resources Fixes https://github.com/flutter/flutter/issues/109513 Depends on engine PR https://github.com/flutter/engine/pull/39208 ✔️ Design doc: https://docs.google.com/document/d/1BGCWy1_LRrXEB6qeqTAKlk-U2CZlKJ5xI97g45U7azk/edit# Migration guide: https://github.com/flutter/website/pull/8952
1337 lines
49 KiB
Dart
1337 lines
49 KiB
Dart
// 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:math';
|
|
import 'dart:ui' show ImageFilter, lerpDouble;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
import 'colors.dart';
|
|
import 'interface_level.dart';
|
|
import 'localizations.dart';
|
|
|
|
const double _kBackGestureWidth = 20.0;
|
|
const double _kMinFlingVelocity = 1.0; // Screen widths per second.
|
|
|
|
// An eyeballed value for the maximum time it takes for a page to animate forward
|
|
// if the user releases a page mid swipe.
|
|
const int _kMaxDroppedSwipePageForwardAnimationTime = 800; // Milliseconds.
|
|
|
|
// The maximum time for a page to get reset to it's original position if the
|
|
// user releases a page mid swipe.
|
|
const int _kMaxPageBackAnimationTime = 300; // Milliseconds.
|
|
|
|
/// Barrier color used for a barrier visible during transitions for Cupertino
|
|
/// page routes.
|
|
///
|
|
/// This barrier color is only used for full-screen page routes with
|
|
/// `fullscreenDialog: false`.
|
|
///
|
|
/// By default, `fullscreenDialog` Cupertino route transitions have no
|
|
/// `barrierColor`, and [CupertinoDialogRoute]s and [CupertinoModalPopupRoute]s
|
|
/// have a `barrierColor` defined by [kCupertinoModalBarrierColor].
|
|
///
|
|
/// A relatively rigorous eyeball estimation.
|
|
const Color _kCupertinoPageTransitionBarrierColor = Color(0x18000000);
|
|
|
|
/// Barrier color for a Cupertino modal barrier.
|
|
///
|
|
/// Extracted from https://developer.apple.com/design/resources/.
|
|
const Color kCupertinoModalBarrierColor = CupertinoDynamicColor.withBrightness(
|
|
color: Color(0x33000000),
|
|
darkColor: Color(0x7A000000),
|
|
);
|
|
|
|
// The duration of the transition used when a modal popup is shown.
|
|
const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335);
|
|
|
|
// Offset from offscreen to the right to fully on screen.
|
|
final Animatable<Offset> _kRightMiddleTween = Tween<Offset>(
|
|
begin: const Offset(1.0, 0.0),
|
|
end: Offset.zero,
|
|
);
|
|
|
|
// Offset from fully on screen to 1/3 offscreen to the left.
|
|
final Animatable<Offset> _kMiddleLeftTween = Tween<Offset>(
|
|
begin: Offset.zero,
|
|
end: const Offset(-1.0/3.0, 0.0),
|
|
);
|
|
|
|
// Offset from offscreen below to fully on screen.
|
|
final Animatable<Offset> _kBottomUpTween = Tween<Offset>(
|
|
begin: const Offset(0.0, 1.0),
|
|
end: Offset.zero,
|
|
);
|
|
|
|
/// A mixin that replaces the entire screen with an iOS transition for a
|
|
/// [PageRoute].
|
|
///
|
|
/// {@template flutter.cupertino.cupertinoRouteTransitionMixin}
|
|
/// The page slides in from the right and exits in reverse. The page also shifts
|
|
/// to the left in parallax when another page enters to cover it.
|
|
///
|
|
/// The page slides in from the bottom and exits in reverse with no parallax
|
|
/// effect for fullscreen dialogs.
|
|
/// {@endtemplate}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [MaterialRouteTransitionMixin], which is a mixin that provides
|
|
/// platform-appropriate transitions for a [PageRoute].
|
|
/// * [CupertinoPageRoute], which is a [PageRoute] that leverages this mixin.
|
|
mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
|
|
/// Builds the primary contents of the route.
|
|
@protected
|
|
Widget buildContent(BuildContext context);
|
|
|
|
/// {@template flutter.cupertino.CupertinoRouteTransitionMixin.title}
|
|
/// A title string for this route.
|
|
///
|
|
/// Used to auto-populate [CupertinoNavigationBar] and
|
|
/// [CupertinoSliverNavigationBar]'s `middle`/`largeTitle` widgets when
|
|
/// one is not manually supplied.
|
|
/// {@endtemplate}
|
|
String? get title;
|
|
|
|
ValueNotifier<String?>? _previousTitle;
|
|
|
|
/// The title string of the previous [CupertinoPageRoute].
|
|
///
|
|
/// The [ValueListenable]'s value is readable after the route is installed
|
|
/// onto a [Navigator]. The [ValueListenable] will also notify its listeners
|
|
/// if the value changes (such as by replacing the previous route).
|
|
///
|
|
/// The [ValueListenable] itself will be null before the route is installed.
|
|
/// Its content value will be null if the previous route has no title or
|
|
/// is not a [CupertinoPageRoute].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [ValueListenableBuilder], which can be used to listen and rebuild
|
|
/// widgets based on a ValueListenable.
|
|
ValueListenable<String?> get previousTitle {
|
|
assert(
|
|
_previousTitle != null,
|
|
'Cannot read the previousTitle for a route that has not yet been installed',
|
|
);
|
|
return _previousTitle!;
|
|
}
|
|
|
|
@override
|
|
void didChangePrevious(Route<dynamic>? previousRoute) {
|
|
final String? previousTitleString = previousRoute is CupertinoRouteTransitionMixin
|
|
? previousRoute.title
|
|
: null;
|
|
if (_previousTitle == null) {
|
|
_previousTitle = ValueNotifier<String?>(previousTitleString);
|
|
} else {
|
|
_previousTitle!.value = previousTitleString;
|
|
}
|
|
super.didChangePrevious(previousRoute);
|
|
}
|
|
|
|
@override
|
|
// A relatively rigorous eyeball estimation.
|
|
Duration get transitionDuration => const Duration(milliseconds: 500);
|
|
|
|
@override
|
|
Color? get barrierColor => fullscreenDialog ? null : _kCupertinoPageTransitionBarrierColor;
|
|
|
|
@override
|
|
String? get barrierLabel => null;
|
|
|
|
@override
|
|
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
|
|
// Don't perform outgoing animation if the next route is a fullscreen dialog.
|
|
return nextRoute is CupertinoRouteTransitionMixin && !nextRoute.fullscreenDialog;
|
|
}
|
|
|
|
/// True if an iOS-style back swipe pop gesture is currently underway for [route].
|
|
///
|
|
/// This just check 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);
|
|
return Semantics(
|
|
scopesRoute: true,
|
|
explicitChildNodes: true,
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
// Called by _CupertinoBackGestureDetector when a pop ("back") drag start
|
|
// gesture is detected. The returned controller handles all of the subsequent
|
|
// drag events.
|
|
static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
|
|
assert(_isPopGestureEnabled(route));
|
|
|
|
return _CupertinoBackGestureController<T>(
|
|
navigator: route.navigator!,
|
|
controller: route.controller!, // protected access
|
|
);
|
|
}
|
|
|
|
/// Returns a [CupertinoFullscreenDialogTransition] if [route] is a full
|
|
/// screen dialog, otherwise a [CupertinoPageTransition] is returned.
|
|
///
|
|
/// Used by [CupertinoPageRoute.buildTransitions].
|
|
///
|
|
/// This method can be applied to any [PageRoute], not just
|
|
/// [CupertinoPageRoute]. It's typically used to provide a Cupertino style
|
|
/// horizontal transition for material widgets when the target platform
|
|
/// is [TargetPlatform.iOS].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [CupertinoPageTransitionsBuilder], which uses this method to define a
|
|
/// [PageTransitionsBuilder] for the [PageTransitionsTheme].
|
|
static Widget buildPageTransitions<T>(
|
|
PageRoute<T> route,
|
|
BuildContext context,
|
|
Animation<double> animation,
|
|
Animation<double> secondaryAnimation,
|
|
Widget child,
|
|
) {
|
|
// Check if the route has an animation that's currently participating
|
|
// in a back swipe gesture.
|
|
//
|
|
// In the middle of a back gesture drag, let the transition be linear to
|
|
// match finger motions.
|
|
final bool linearTransition = isPopGestureInProgress(route);
|
|
if (route.fullscreenDialog) {
|
|
return CupertinoFullscreenDialogTransition(
|
|
primaryRouteAnimation: animation,
|
|
secondaryRouteAnimation: secondaryAnimation,
|
|
linearTransition: linearTransition,
|
|
child: child,
|
|
);
|
|
} else {
|
|
return CupertinoPageTransition(
|
|
primaryRouteAnimation: animation,
|
|
secondaryRouteAnimation: secondaryAnimation,
|
|
linearTransition: linearTransition,
|
|
child: _CupertinoBackGestureDetector<T>(
|
|
enabledCallback: () => _isPopGestureEnabled<T>(route),
|
|
onStartPopGesture: () => _startPopGesture<T>(route),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
|
return buildPageTransitions<T>(this, context, animation, secondaryAnimation, child);
|
|
}
|
|
}
|
|
|
|
/// A modal route that replaces the entire screen with an iOS transition.
|
|
///
|
|
/// {@macro flutter.cupertino.cupertinoRouteTransitionMixin}
|
|
///
|
|
/// By default, when a modal route is replaced by another, the previous route
|
|
/// remains in memory. To free all the resources when this is not necessary, set
|
|
/// [maintainState] to false.
|
|
///
|
|
/// The type `T` specifies the return type of the route which can be supplied as
|
|
/// the route is popped from the stack via [Navigator.pop] when an optional
|
|
/// `result` can be provided.
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [CupertinoRouteTransitionMixin], for a mixin that provides iOS transition
|
|
/// for this modal route.
|
|
/// * [MaterialPageRoute], for an adaptive [PageRoute] that uses a
|
|
/// platform-appropriate transition.
|
|
/// * [CupertinoPageScaffold], for applications that have one page with a fixed
|
|
/// navigation bar on top.
|
|
/// * [CupertinoTabScaffold], for applications that have a tab bar at the
|
|
/// bottom with multiple pages.
|
|
/// * [CupertinoPage], for a [Page] version of this class.
|
|
class CupertinoPageRoute<T> extends PageRoute<T> with CupertinoRouteTransitionMixin<T> {
|
|
/// Creates a page route for use in an iOS designed app.
|
|
///
|
|
/// The [builder], [maintainState], and [fullscreenDialog] arguments must not
|
|
/// be null.
|
|
CupertinoPageRoute({
|
|
required this.builder,
|
|
this.title,
|
|
super.settings,
|
|
this.maintainState = true,
|
|
super.fullscreenDialog,
|
|
super.allowSnapshotting = true,
|
|
}) {
|
|
assert(opaque);
|
|
}
|
|
|
|
/// Builds the primary contents of the route.
|
|
final WidgetBuilder builder;
|
|
|
|
@override
|
|
Widget buildContent(BuildContext context) => builder(context);
|
|
|
|
@override
|
|
final String? title;
|
|
|
|
@override
|
|
final bool maintainState;
|
|
|
|
@override
|
|
String get debugLabel => '${super.debugLabel}(${settings.name})';
|
|
}
|
|
|
|
// A page-based version of CupertinoPageRoute.
|
|
//
|
|
// This route uses the builder from the page to build its content. This ensures
|
|
// the content is up to date after page updates.
|
|
class _PageBasedCupertinoPageRoute<T> extends PageRoute<T> with CupertinoRouteTransitionMixin<T> {
|
|
_PageBasedCupertinoPageRoute({
|
|
required CupertinoPage<T> page,
|
|
super.allowSnapshotting = true,
|
|
}) : super(settings: page) {
|
|
assert(opaque);
|
|
}
|
|
|
|
CupertinoPage<T> get _page => settings as CupertinoPage<T>;
|
|
|
|
@override
|
|
Widget buildContent(BuildContext context) => _page.child;
|
|
|
|
@override
|
|
String? get title => _page.title;
|
|
|
|
@override
|
|
bool get maintainState => _page.maintainState;
|
|
|
|
@override
|
|
bool get fullscreenDialog => _page.fullscreenDialog;
|
|
|
|
@override
|
|
String get debugLabel => '${super.debugLabel}(${_page.name})';
|
|
}
|
|
|
|
/// A page that creates a cupertino style [PageRoute].
|
|
///
|
|
/// {@macro flutter.cupertino.cupertinoRouteTransitionMixin}
|
|
///
|
|
/// By default, when a created modal route is replaced by another, the previous
|
|
/// route remains in memory. To free all the resources when this is not
|
|
/// necessary, set [maintainState] to false.
|
|
///
|
|
/// The type `T` specifies the return type of the route which can be supplied as
|
|
/// the route is popped from the stack via [Navigator.transitionDelegate] by
|
|
/// providing the optional `result` argument to the
|
|
/// [RouteTransitionRecord.markForPop] in the [TransitionDelegate.resolve].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [CupertinoPageRoute], for a [PageRoute] version of this class.
|
|
class CupertinoPage<T> extends Page<T> {
|
|
/// Creates a cupertino page.
|
|
const CupertinoPage({
|
|
required this.child,
|
|
this.maintainState = true,
|
|
this.title,
|
|
this.fullscreenDialog = false,
|
|
this.allowSnapshotting = true,
|
|
super.key,
|
|
super.name,
|
|
super.arguments,
|
|
super.restorationId,
|
|
});
|
|
|
|
/// The content to be shown in the [Route] created by this page.
|
|
final Widget child;
|
|
|
|
/// {@macro flutter.cupertino.CupertinoRouteTransitionMixin.title}
|
|
final String? title;
|
|
|
|
/// {@macro flutter.widgets.ModalRoute.maintainState}
|
|
final bool maintainState;
|
|
|
|
/// {@macro flutter.widgets.PageRoute.fullscreenDialog}
|
|
final bool fullscreenDialog;
|
|
|
|
/// {@macro flutter.widgets.TransitionRoute.allowSnapshotting}
|
|
final bool allowSnapshotting;
|
|
|
|
@override
|
|
Route<T> createRoute(BuildContext context) {
|
|
return _PageBasedCupertinoPageRoute<T>(page: this, allowSnapshotting: allowSnapshotting);
|
|
}
|
|
}
|
|
|
|
/// Provides an iOS-style page transition animation.
|
|
///
|
|
/// The page slides in from the right and exits in reverse. It also shifts to the left in
|
|
/// a parallax motion when another page enters to cover it.
|
|
class CupertinoPageTransition extends StatelessWidget {
|
|
/// Creates an iOS-style page transition.
|
|
///
|
|
/// * `primaryRouteAnimation` is a linear route animation from 0.0 to 1.0
|
|
/// when this screen is being pushed.
|
|
/// * `secondaryRouteAnimation` is a linear route animation from 0.0 to 1.0
|
|
/// when another screen is being pushed on top of this one.
|
|
/// * `linearTransition` is whether to perform the transitions linearly.
|
|
/// Used to precisely track back gesture drags.
|
|
CupertinoPageTransition({
|
|
super.key,
|
|
required Animation<double> primaryRouteAnimation,
|
|
required Animation<double> secondaryRouteAnimation,
|
|
required this.child,
|
|
required bool linearTransition,
|
|
}) : _primaryPositionAnimation =
|
|
(linearTransition
|
|
? primaryRouteAnimation
|
|
: CurvedAnimation(
|
|
parent: primaryRouteAnimation,
|
|
curve: Curves.fastEaseInToSlowEaseOut,
|
|
reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped,
|
|
)
|
|
).drive(_kRightMiddleTween),
|
|
_secondaryPositionAnimation =
|
|
(linearTransition
|
|
? secondaryRouteAnimation
|
|
: CurvedAnimation(
|
|
parent: secondaryRouteAnimation,
|
|
curve: Curves.linearToEaseOut,
|
|
reverseCurve: Curves.easeInToLinear,
|
|
)
|
|
).drive(_kMiddleLeftTween),
|
|
_primaryShadowAnimation =
|
|
(linearTransition
|
|
? primaryRouteAnimation
|
|
: CurvedAnimation(
|
|
parent: primaryRouteAnimation,
|
|
curve: Curves.linearToEaseOut,
|
|
)
|
|
).drive(_CupertinoEdgeShadowDecoration.kTween);
|
|
|
|
// When this page is coming in to cover another page.
|
|
final Animation<Offset> _primaryPositionAnimation;
|
|
// When this page is becoming covered by another page.
|
|
final Animation<Offset> _secondaryPositionAnimation;
|
|
final Animation<Decoration> _primaryShadowAnimation;
|
|
|
|
/// The widget below this widget in the tree.
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasDirectionality(context));
|
|
final TextDirection textDirection = Directionality.of(context);
|
|
return SlideTransition(
|
|
position: _secondaryPositionAnimation,
|
|
textDirection: textDirection,
|
|
transformHitTests: false,
|
|
child: SlideTransition(
|
|
position: _primaryPositionAnimation,
|
|
textDirection: textDirection,
|
|
child: DecoratedBoxTransition(
|
|
decoration: _primaryShadowAnimation,
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// An iOS-style transition used for summoning fullscreen dialogs.
|
|
///
|
|
/// For example, used when creating a new calendar event by bringing in the next
|
|
/// screen from the bottom.
|
|
class CupertinoFullscreenDialogTransition extends StatelessWidget {
|
|
/// Creates an iOS-style transition used for summoning fullscreen dialogs.
|
|
///
|
|
/// * `primaryRouteAnimation` is a linear route animation from 0.0 to 1.0
|
|
/// when this screen is being pushed.
|
|
/// * `secondaryRouteAnimation` is a linear route animation from 0.0 to 1.0
|
|
/// when another screen is being pushed on top of this one.
|
|
/// * `linearTransition` is whether to perform the secondary transition linearly.
|
|
/// Used to precisely track back gesture drags.
|
|
CupertinoFullscreenDialogTransition({
|
|
super.key,
|
|
required Animation<double> primaryRouteAnimation,
|
|
required Animation<double> secondaryRouteAnimation,
|
|
required this.child,
|
|
required bool linearTransition,
|
|
}) : _positionAnimation = CurvedAnimation(
|
|
parent: primaryRouteAnimation,
|
|
curve: Curves.linearToEaseOut,
|
|
// The curve must be flipped so that the reverse animation doesn't play
|
|
// an ease-in curve, which iOS does not use.
|
|
reverseCurve: Curves.linearToEaseOut.flipped,
|
|
).drive(_kBottomUpTween),
|
|
_secondaryPositionAnimation =
|
|
(linearTransition
|
|
? secondaryRouteAnimation
|
|
: CurvedAnimation(
|
|
parent: secondaryRouteAnimation,
|
|
curve: Curves.linearToEaseOut,
|
|
reverseCurve: Curves.easeInToLinear,
|
|
)
|
|
).drive(_kMiddleLeftTween);
|
|
|
|
final Animation<Offset> _positionAnimation;
|
|
// When this page is becoming covered by another page.
|
|
final Animation<Offset> _secondaryPositionAnimation;
|
|
|
|
/// The widget below this widget in the tree.
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasDirectionality(context));
|
|
final TextDirection textDirection = Directionality.of(context);
|
|
return SlideTransition(
|
|
position: _secondaryPositionAnimation,
|
|
textDirection: textDirection,
|
|
transformHitTests: false,
|
|
child: SlideTransition(
|
|
position: _positionAnimation,
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// This is the widget side of [_CupertinoBackGestureController].
|
|
///
|
|
/// This widget provides a gesture recognizer which, when it determines the
|
|
/// route can be closed with a back gesture, creates the controller and
|
|
/// feeds it the input from the gesture recognizer.
|
|
///
|
|
/// The gesture data is converted from absolute coordinates to logical
|
|
/// coordinates by this widget.
|
|
///
|
|
/// The type `T` specifies the return type of the route with which this gesture
|
|
/// detector is associated.
|
|
class _CupertinoBackGestureDetector<T> extends StatefulWidget {
|
|
const _CupertinoBackGestureDetector({
|
|
super.key,
|
|
required this.enabledCallback,
|
|
required this.onStartPopGesture,
|
|
required this.child,
|
|
});
|
|
|
|
final Widget child;
|
|
|
|
final ValueGetter<bool> enabledCallback;
|
|
|
|
final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture;
|
|
|
|
@override
|
|
_CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>();
|
|
}
|
|
|
|
class _CupertinoBackGestureDetectorState<T> extends State<_CupertinoBackGestureDetector<T>> {
|
|
_CupertinoBackGestureController<T>? _backGestureController;
|
|
|
|
late HorizontalDragGestureRecognizer _recognizer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
|
|
..onStart = _handleDragStart
|
|
..onUpdate = _handleDragUpdate
|
|
..onEnd = _handleDragEnd
|
|
..onCancel = _handleDragCancel;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_recognizer.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleDragStart(DragStartDetails details) {
|
|
assert(mounted);
|
|
assert(_backGestureController == null);
|
|
_backGestureController = widget.onStartPopGesture();
|
|
}
|
|
|
|
void _handleDragUpdate(DragUpdateDetails details) {
|
|
assert(mounted);
|
|
assert(_backGestureController != null);
|
|
_backGestureController!.dragUpdate(_convertToLogical(details.primaryDelta! / context.size!.width));
|
|
}
|
|
|
|
void _handleDragEnd(DragEndDetails details) {
|
|
assert(mounted);
|
|
assert(_backGestureController != null);
|
|
_backGestureController!.dragEnd(_convertToLogical(details.velocity.pixelsPerSecond.dx / context.size!.width));
|
|
_backGestureController = null;
|
|
}
|
|
|
|
void _handleDragCancel() {
|
|
assert(mounted);
|
|
// This can be called even if start is not called, paired with the "down" event
|
|
// that we don't consider here.
|
|
_backGestureController?.dragEnd(0.0);
|
|
_backGestureController = null;
|
|
}
|
|
|
|
void _handlePointerDown(PointerDownEvent event) {
|
|
if (widget.enabledCallback()) {
|
|
_recognizer.addPointer(event);
|
|
}
|
|
}
|
|
|
|
double _convertToLogical(double value) {
|
|
switch (Directionality.of(context)) {
|
|
case TextDirection.rtl:
|
|
return -value;
|
|
case TextDirection.ltr:
|
|
return value;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasDirectionality(context));
|
|
// For devices with notches, the drag area needs to be larger on the side
|
|
// that has the notch.
|
|
double dragAreaWidth = Directionality.of(context) == TextDirection.ltr ?
|
|
MediaQuery.paddingOf(context).left :
|
|
MediaQuery.paddingOf(context).right;
|
|
dragAreaWidth = max(dragAreaWidth, _kBackGestureWidth);
|
|
return Stack(
|
|
fit: StackFit.passthrough,
|
|
children: <Widget>[
|
|
widget.child,
|
|
PositionedDirectional(
|
|
start: 0.0,
|
|
width: dragAreaWidth,
|
|
top: 0.0,
|
|
bottom: 0.0,
|
|
child: Listener(
|
|
onPointerDown: _handlePointerDown,
|
|
behavior: HitTestBehavior.translucent,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A controller for an iOS-style back gesture.
|
|
///
|
|
/// This is created by a [CupertinoPageRoute] in response from a gesture caught
|
|
/// by a [_CupertinoBackGestureDetector] widget, which then also feeds it input
|
|
/// from the gesture. It controls the animation controller owned by the route,
|
|
/// based on the input provided by the gesture detector.
|
|
///
|
|
/// This class works entirely in logical coordinates (0.0 is new page dismissed,
|
|
/// 1.0 is new page on top).
|
|
///
|
|
/// The type `T` specifies the return type of the route with which this gesture
|
|
/// detector controller is associated.
|
|
class _CupertinoBackGestureController<T> {
|
|
/// Creates a controller for an iOS-style back gesture.
|
|
///
|
|
/// The [navigator] and [controller] arguments must not be null.
|
|
_CupertinoBackGestureController({
|
|
required this.navigator,
|
|
required this.controller,
|
|
}) {
|
|
navigator.didStartUserGesture();
|
|
}
|
|
|
|
final AnimationController controller;
|
|
final NavigatorState navigator;
|
|
|
|
/// The drag gesture has changed by [fractionalDelta]. The total range of the
|
|
/// drag should be 0.0 to 1.0.
|
|
void dragUpdate(double delta) {
|
|
controller.value -= delta;
|
|
}
|
|
|
|
/// The drag gesture has ended with a horizontal motion of
|
|
/// [fractionalVelocity] as a fraction of screen width per second.
|
|
void dragEnd(double velocity) {
|
|
// Fling in the appropriate direction.
|
|
//
|
|
// This curve has been determined through rigorously eyeballing native iOS
|
|
// animations.
|
|
const Curve animationCurve = Curves.fastLinearToSlowEaseIn;
|
|
final bool animateForward;
|
|
|
|
// If the user releases the page before mid screen with sufficient velocity,
|
|
// or after mid screen, we should animate the page out. Otherwise, the page
|
|
// should be animated back in.
|
|
if (velocity.abs() >= _kMinFlingVelocity) {
|
|
animateForward = velocity <= 0;
|
|
} else {
|
|
animateForward = controller.value > 0.5;
|
|
}
|
|
|
|
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.
|
|
final int droppedPageForwardAnimationTime = min(
|
|
lerpDouble(_kMaxDroppedSwipePageForwardAnimationTime, 0, controller.value)!.floor(),
|
|
_kMaxPageBackAnimationTime,
|
|
);
|
|
controller.animateTo(1.0, duration: Duration(milliseconds: droppedPageForwardAnimationTime), curve: animationCurve);
|
|
} 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) {
|
|
// Otherwise, use a custom popping animation duration and curve.
|
|
final int droppedPageBackAnimationTime = lerpDouble(0, _kMaxDroppedSwipePageForwardAnimationTime, controller.value)!.floor();
|
|
controller.animateBack(0.0, duration: Duration(milliseconds: droppedPageBackAnimationTime), curve: animationCurve);
|
|
}
|
|
}
|
|
|
|
if (controller.isAnimating) {
|
|
// Keep the userGestureInProgress in true state so we don't change the
|
|
// curve of the page transition mid-flight since CupertinoPageTransition
|
|
// depends on userGestureInProgress.
|
|
late AnimationStatusListener animationStatusCallback;
|
|
animationStatusCallback = (AnimationStatus status) {
|
|
navigator.didStopUserGesture();
|
|
controller.removeStatusListener(animationStatusCallback);
|
|
};
|
|
controller.addStatusListener(animationStatusCallback);
|
|
} else {
|
|
navigator.didStopUserGesture();
|
|
}
|
|
}
|
|
}
|
|
|
|
// A custom [Decoration] used to paint an extra shadow on the start edge of the
|
|
// box it's decorating. It's like a [BoxDecoration] with only a gradient except
|
|
// it paints on the start side of the box instead of behind the box.
|
|
class _CupertinoEdgeShadowDecoration extends Decoration {
|
|
const _CupertinoEdgeShadowDecoration._([this._colors]);
|
|
|
|
static DecorationTween kTween = DecorationTween(
|
|
begin: const _CupertinoEdgeShadowDecoration._(), // No decoration initially.
|
|
end: const _CupertinoEdgeShadowDecoration._(
|
|
// Eyeballed gradient used to mimic a drop shadow on the start side only.
|
|
<Color>[
|
|
Color(0x04000000),
|
|
Color(0x00000000),
|
|
],
|
|
),
|
|
);
|
|
|
|
// Colors used to paint a gradient at the start edge of the box it is
|
|
// decorating.
|
|
//
|
|
// The first color in the list is used at the start of the gradient, which
|
|
// is located at the start edge of the decorated box.
|
|
//
|
|
// If this is null, no shadow is drawn.
|
|
//
|
|
// The list must have at least two colors in it (otherwise it would not be a
|
|
// gradient).
|
|
final List<Color>? _colors;
|
|
|
|
// Linearly interpolate between two edge shadow decorations decorations.
|
|
//
|
|
// The `t` argument represents position on the timeline, with 0.0 meaning
|
|
// that the interpolation has not started, returning `a` (or something
|
|
// equivalent to `a`), 1.0 meaning that the interpolation has finished,
|
|
// returning `b` (or something equivalent to `b`), and values in between
|
|
// meaning that the interpolation is at the relevant point on the timeline
|
|
// between `a` and `b`. The interpolation can be extrapolated beyond 0.0 and
|
|
// 1.0, so negative values and values greater than 1.0 are valid (and can
|
|
// easily be generated by curves such as [Curves.elasticInOut]).
|
|
//
|
|
// Values for `t` are usually obtained from an [Animation<double>], such as
|
|
// an [AnimationController].
|
|
//
|
|
// See also:
|
|
//
|
|
// * [Decoration.lerp].
|
|
static _CupertinoEdgeShadowDecoration? lerp(
|
|
_CupertinoEdgeShadowDecoration? a,
|
|
_CupertinoEdgeShadowDecoration? b,
|
|
double t,
|
|
) {
|
|
if (identical(a, b)) {
|
|
return a;
|
|
}
|
|
if (a == null) {
|
|
return b!._colors == null ? b : _CupertinoEdgeShadowDecoration._(b._colors!.map<Color>((Color color) => Color.lerp(null, color, t)!).toList());
|
|
}
|
|
if (b == null) {
|
|
return a._colors == null ? a : _CupertinoEdgeShadowDecoration._(a._colors!.map<Color>((Color color) => Color.lerp(null, color, 1.0 - t)!).toList());
|
|
}
|
|
assert(b._colors != null || a._colors != null);
|
|
// If it ever becomes necessary, we could allow decorations with different
|
|
// length' here, similarly to how it is handled in [LinearGradient.lerp].
|
|
assert(b._colors == null || a._colors == null || a._colors!.length == b._colors!.length);
|
|
return _CupertinoEdgeShadowDecoration._(
|
|
<Color>[
|
|
for (int i = 0; i < b._colors!.length; i += 1)
|
|
Color.lerp(a._colors?[i], b._colors?[i], t)!,
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
_CupertinoEdgeShadowDecoration lerpFrom(Decoration? a, double t) {
|
|
if (a is _CupertinoEdgeShadowDecoration) {
|
|
return _CupertinoEdgeShadowDecoration.lerp(a, this, t)!;
|
|
}
|
|
return _CupertinoEdgeShadowDecoration.lerp(null, this, t)!;
|
|
}
|
|
|
|
@override
|
|
_CupertinoEdgeShadowDecoration lerpTo(Decoration? b, double t) {
|
|
if (b is _CupertinoEdgeShadowDecoration) {
|
|
return _CupertinoEdgeShadowDecoration.lerp(this, b, t)!;
|
|
}
|
|
return _CupertinoEdgeShadowDecoration.lerp(this, null, t)!;
|
|
}
|
|
|
|
@override
|
|
_CupertinoEdgeShadowPainter createBoxPainter([ VoidCallback? onChanged ]) {
|
|
return _CupertinoEdgeShadowPainter(this, onChanged);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
if (other.runtimeType != runtimeType) {
|
|
return false;
|
|
}
|
|
return other is _CupertinoEdgeShadowDecoration
|
|
&& other._colors == _colors;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => _colors.hashCode;
|
|
|
|
@override
|
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
super.debugFillProperties(properties);
|
|
properties.add(IterableProperty<Color>('colors', _colors));
|
|
}
|
|
}
|
|
|
|
/// A [BoxPainter] used to draw the page transition shadow using gradients.
|
|
class _CupertinoEdgeShadowPainter extends BoxPainter {
|
|
_CupertinoEdgeShadowPainter(
|
|
this._decoration,
|
|
super.onChanged,
|
|
) : assert(_decoration._colors == null || _decoration._colors!.length > 1);
|
|
|
|
final _CupertinoEdgeShadowDecoration _decoration;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
|
final List<Color>? colors = _decoration._colors;
|
|
if (colors == null) {
|
|
return;
|
|
}
|
|
|
|
// The following code simulates drawing a [LinearGradient] configured as
|
|
// follows:
|
|
//
|
|
// LinearGradient(
|
|
// begin: AlignmentDirectional(0.90, 0.0), // Spans 5% of the page.
|
|
// colors: _decoration._colors,
|
|
// )
|
|
//
|
|
// A performance evaluation on Feb 8, 2021 showed, that drawing the gradient
|
|
// manually as implemented below is more performant than relying on
|
|
// [LinearGradient.createShader] because compiling that shader takes a long
|
|
// time. On an iPhone XR, the implementation below reduced the worst frame
|
|
// time for a cupertino page transition of a newly installed app from ~95ms
|
|
// down to ~30ms, mainly because there's no longer a need to compile a
|
|
// shader for the LinearGradient.
|
|
//
|
|
// The implementation below divides the width of the shadow into multiple
|
|
// bands of equal width, one for each color interval defined by
|
|
// `_decoration._colors`. Band x is filled with a gradient going from
|
|
// `_decoration._colors[x]` to `_decoration._colors[x + 1]` by drawing a
|
|
// bunch of 1px wide rects. The rects change their color by lerping between
|
|
// the two colors that define the interval of the band.
|
|
|
|
// Shadow spans 5% of the page.
|
|
final double shadowWidth = 0.05 * configuration.size!.width;
|
|
final double shadowHeight = configuration.size!.height;
|
|
final double bandWidth = shadowWidth / (colors.length - 1);
|
|
|
|
final TextDirection? textDirection = configuration.textDirection;
|
|
assert(textDirection != null);
|
|
final double start;
|
|
final double shadowDirection; // -1 for ltr, 1 for rtl.
|
|
switch (textDirection!) {
|
|
case TextDirection.rtl:
|
|
start = offset.dx + configuration.size!.width;
|
|
shadowDirection = 1;
|
|
case TextDirection.ltr:
|
|
start = offset.dx;
|
|
shadowDirection = -1;
|
|
}
|
|
|
|
int bandColorIndex = 0;
|
|
for (int dx = 0; dx < shadowWidth; dx += 1) {
|
|
if (dx ~/ bandWidth != bandColorIndex) {
|
|
bandColorIndex += 1;
|
|
}
|
|
final Paint paint = Paint()
|
|
..color = Color.lerp(colors[bandColorIndex], colors[bandColorIndex + 1], (dx % bandWidth) / bandWidth)!;
|
|
final double x = start + shadowDirection * dx;
|
|
canvas.drawRect(Rect.fromLTWH(x - 1.0, offset.dy, 1.0, shadowHeight), paint);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A route that shows a modal iOS-style popup that slides up from the
|
|
/// bottom of the screen.
|
|
///
|
|
/// Such a popup is an alternative to a menu or a dialog and prevents the user
|
|
/// from interacting with the rest of the app.
|
|
///
|
|
/// It is used internally by [showCupertinoModalPopup] or can be directly pushed
|
|
/// onto the [Navigator] stack to enable state restoration. See
|
|
/// [showCupertinoModalPopup] for a state restoration app example.
|
|
///
|
|
/// The `barrierColor` argument determines the [Color] of the barrier underneath
|
|
/// the popup. When unspecified, the barrier color defaults to a light opacity
|
|
/// black scrim based on iOS's dialog screens. To correctly have iOS resolve
|
|
/// to the appropriate modal colors, pass in
|
|
/// `CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context)`.
|
|
///
|
|
/// The `barrierDismissible` argument determines whether clicking outside the
|
|
/// popup results in dismissal. It is `true` by default.
|
|
///
|
|
/// The `semanticsDismissible` argument is used to determine whether the
|
|
/// semantics of the modal barrier are included in the semantics tree.
|
|
///
|
|
/// The `routeSettings` argument is used to provide [RouteSettings] to the
|
|
/// created Route.
|
|
///
|
|
/// {@macro flutter.widgets.RawDialogRoute}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [DisplayFeatureSubScreen], which documents the specifics of how
|
|
/// [DisplayFeature]s can split the screen into sub-screens.
|
|
/// * [CupertinoActionSheet], which is the widget usually returned by the
|
|
/// `builder` argument.
|
|
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/views/action-sheets/>
|
|
class CupertinoModalPopupRoute<T> extends PopupRoute<T> {
|
|
/// A route that shows a modal iOS-style popup that slides up from the
|
|
/// bottom of the screen.
|
|
CupertinoModalPopupRoute({
|
|
required this.builder,
|
|
this.barrierLabel = 'Dismiss',
|
|
this.barrierColor = kCupertinoModalBarrierColor,
|
|
bool barrierDismissible = true,
|
|
bool semanticsDismissible = false,
|
|
super.filter,
|
|
super.settings,
|
|
this.anchorPoint,
|
|
}) : _barrierDismissible = barrierDismissible,
|
|
_semanticsDismissible = semanticsDismissible;
|
|
|
|
/// A builder that builds the widget tree for the [CupertinoModalPopupRoute].
|
|
///
|
|
/// The [builder] argument typically builds a [CupertinoActionSheet] widget.
|
|
///
|
|
/// Content below the widget is dimmed with a [ModalBarrier]. The widget built
|
|
/// by the [builder] does not share a context with the route it was originally
|
|
/// built from. Use a [StatefulBuilder] or a custom [StatefulWidget] if the
|
|
/// widget needs to update dynamically.
|
|
final WidgetBuilder builder;
|
|
|
|
final bool _barrierDismissible;
|
|
|
|
final bool _semanticsDismissible;
|
|
|
|
@override
|
|
final String barrierLabel;
|
|
|
|
@override
|
|
final Color? barrierColor;
|
|
|
|
@override
|
|
bool get barrierDismissible => _barrierDismissible;
|
|
|
|
@override
|
|
bool get semanticsDismissible => _semanticsDismissible;
|
|
|
|
@override
|
|
Duration get transitionDuration => _kModalPopupTransitionDuration;
|
|
|
|
Animation<double>? _animation;
|
|
|
|
late Tween<Offset> _offsetTween;
|
|
|
|
/// {@macro flutter.widgets.DisplayFeatureSubScreen.anchorPoint}
|
|
final Offset? anchorPoint;
|
|
|
|
@override
|
|
Animation<double> createAnimation() {
|
|
assert(_animation == null);
|
|
_animation = CurvedAnimation(
|
|
parent: super.createAnimation(),
|
|
|
|
// These curves were initially measured from native iOS horizontal page
|
|
// route animations and seemed to be a good match here as well.
|
|
curve: Curves.linearToEaseOut,
|
|
reverseCurve: Curves.linearToEaseOut.flipped,
|
|
);
|
|
_offsetTween = Tween<Offset>(
|
|
begin: const Offset(0.0, 1.0),
|
|
end: Offset.zero,
|
|
);
|
|
return _animation!;
|
|
}
|
|
|
|
@override
|
|
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
|
|
return CupertinoUserInterfaceLevel(
|
|
data: CupertinoUserInterfaceLevelData.elevated,
|
|
child: DisplayFeatureSubScreen(
|
|
anchorPoint: anchorPoint,
|
|
child: Builder(builder: builder),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
|
return Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: FractionalTranslation(
|
|
translation: _offsetTween.evaluate(_animation!),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shows a modal iOS-style popup that slides up from the bottom of the screen.
|
|
///
|
|
/// Such a popup is an alternative to a menu or a dialog and prevents the user
|
|
/// from interacting with the rest of the app.
|
|
///
|
|
/// The `context` argument is used to look up the [Navigator] for the popup.
|
|
/// It is only used when the method is called. Its corresponding widget can be
|
|
/// safely removed from the tree before the popup is closed.
|
|
///
|
|
/// The `barrierColor` argument determines the [Color] of the barrier underneath
|
|
/// the popup. When unspecified, the barrier color defaults to a light opacity
|
|
/// black scrim based on iOS's dialog screens.
|
|
///
|
|
/// The `barrierDismissible` argument determines whether clicking outside the
|
|
/// popup results in dismissal. It is `true` by default.
|
|
///
|
|
/// The `useRootNavigator` argument is used to determine whether to push the
|
|
/// popup to the [Navigator] furthest from or nearest to the given `context`. It
|
|
/// is `true` by default.
|
|
///
|
|
/// The `semanticsDismissible` argument is used to determine whether the
|
|
/// semantics of the modal barrier are included in the semantics tree.
|
|
///
|
|
/// The `routeSettings` argument is used to provide [RouteSettings] to the
|
|
/// created Route.
|
|
///
|
|
/// The `builder` argument typically builds a [CupertinoActionSheet] widget.
|
|
/// Content below the widget is dimmed with a [ModalBarrier]. The widget built
|
|
/// by the `builder` does not share a context with the location that
|
|
/// [showCupertinoModalPopup] is originally called from. Use a
|
|
/// [StatefulBuilder] or a custom [StatefulWidget] if the widget needs to
|
|
/// update dynamically.
|
|
///
|
|
/// {@macro flutter.widgets.RawDialogRoute}
|
|
///
|
|
/// Returns a `Future` that resolves to the value that was passed to
|
|
/// [Navigator.pop] when the popup was closed.
|
|
///
|
|
/// ### State Restoration in Modals
|
|
///
|
|
/// Using this method will not enable state restoration for the modal. In order
|
|
/// to enable state restoration for a modal, use [Navigator.restorablePush]
|
|
/// or [Navigator.restorablePushNamed] with [CupertinoModalPopupRoute].
|
|
///
|
|
/// For more information about state restoration, see [RestorationManager].
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This sample demonstrates how to create a restorable Cupertino modal route.
|
|
/// This is accomplished by enabling state restoration by specifying
|
|
/// [CupertinoApp.restorationScopeId] and using [Navigator.restorablePush] to
|
|
/// push [CupertinoModalPopupRoute] when the [CupertinoButton] is tapped.
|
|
///
|
|
/// {@macro flutter.widgets.RestorationManager}
|
|
///
|
|
/// ** See code in examples/api/lib/cupertino/route/show_cupertino_modal_popup.0.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [DisplayFeatureSubScreen], which documents the specifics of how
|
|
/// [DisplayFeature]s can split the screen into sub-screens.
|
|
/// * [CupertinoActionSheet], which is the widget usually returned by the
|
|
/// `builder` argument to [showCupertinoModalPopup].
|
|
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/views/action-sheets/>
|
|
Future<T?> showCupertinoModalPopup<T>({
|
|
required BuildContext context,
|
|
required WidgetBuilder builder,
|
|
ImageFilter? filter,
|
|
Color barrierColor = kCupertinoModalBarrierColor,
|
|
bool barrierDismissible = true,
|
|
bool useRootNavigator = true,
|
|
bool semanticsDismissible = false,
|
|
RouteSettings? routeSettings,
|
|
Offset? anchorPoint,
|
|
}) {
|
|
return Navigator.of(context, rootNavigator: useRootNavigator).push(
|
|
CupertinoModalPopupRoute<T>(
|
|
builder: builder,
|
|
filter: filter,
|
|
barrierColor: CupertinoDynamicColor.resolve(barrierColor, context),
|
|
barrierDismissible: barrierDismissible,
|
|
semanticsDismissible: semanticsDismissible,
|
|
settings: routeSettings,
|
|
anchorPoint: anchorPoint,
|
|
),
|
|
);
|
|
}
|
|
|
|
// The curve and initial scale values were mostly eyeballed from iOS, however
|
|
// they reuse the same animation curve that was modeled after native page
|
|
// transitions.
|
|
final Animatable<double> _dialogScaleTween = Tween<double>(begin: 1.3, end: 1.0)
|
|
.chain(CurveTween(curve: Curves.linearToEaseOut));
|
|
|
|
Widget _buildCupertinoDialogTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
|
final CurvedAnimation fadeAnimation = CurvedAnimation(
|
|
parent: animation,
|
|
curve: Curves.easeInOut,
|
|
);
|
|
if (animation.status == AnimationStatus.reverse) {
|
|
return FadeTransition(
|
|
opacity: fadeAnimation,
|
|
child: child,
|
|
);
|
|
}
|
|
return FadeTransition(
|
|
opacity: fadeAnimation,
|
|
child: ScaleTransition(
|
|
scale: animation.drive(_dialogScaleTween),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Displays an iOS-style dialog above the current contents of the app, with
|
|
/// iOS-style entrance and exit animations, modal barrier color, and modal
|
|
/// barrier behavior (by default, the dialog is not dismissible with a tap on
|
|
/// the barrier).
|
|
///
|
|
/// This function takes a `builder` which typically builds a [CupertinoAlertDialog]
|
|
/// widget. Content below the dialog is dimmed with a [ModalBarrier]. The widget
|
|
/// returned by the `builder` does not share a context with the location that
|
|
/// [showCupertinoDialog] is originally called from. Use a [StatefulBuilder] or
|
|
/// a custom [StatefulWidget] if the dialog needs to update dynamically.
|
|
///
|
|
/// The `context` argument is used to look up the [Navigator] for the dialog.
|
|
/// It is only used when the method is called. Its corresponding widget can
|
|
/// be safely removed from the tree before the dialog is closed.
|
|
///
|
|
/// The `useRootNavigator` argument is used to determine whether to push the
|
|
/// dialog to the [Navigator] furthest from or nearest to the given `context`.
|
|
/// By default, `useRootNavigator` is `true` and the dialog route created by
|
|
/// this method is pushed to the root navigator.
|
|
///
|
|
/// {@macro flutter.widgets.RawDialogRoute}
|
|
///
|
|
/// If the application has multiple [Navigator] objects, it may be necessary to
|
|
/// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the
|
|
/// dialog rather than just `Navigator.pop(context, result)`.
|
|
///
|
|
/// Returns a [Future] that resolves to the value (if any) that was passed to
|
|
/// [Navigator.pop] when the dialog was closed.
|
|
///
|
|
/// ### State Restoration in Dialogs
|
|
///
|
|
/// Using this method will not enable state restoration for the dialog. In order
|
|
/// to enable state restoration for a dialog, use [Navigator.restorablePush]
|
|
/// or [Navigator.restorablePushNamed] with [CupertinoDialogRoute].
|
|
///
|
|
/// For more information about state restoration, see [RestorationManager].
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This sample demonstrates how to create a restorable Cupertino dialog. This is
|
|
/// accomplished by enabling state restoration by specifying
|
|
/// [CupertinoApp.restorationScopeId] and using [Navigator.restorablePush] to
|
|
/// push [CupertinoDialogRoute] when the [CupertinoButton] is tapped.
|
|
///
|
|
/// {@macro flutter.widgets.RestorationManager}
|
|
///
|
|
/// ** See code in examples/api/lib/cupertino/route/show_cupertino_dialog.0.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [CupertinoAlertDialog], an iOS-style alert dialog.
|
|
/// * [showDialog], which displays a Material-style dialog.
|
|
/// * [showGeneralDialog], which allows for customization of the dialog popup.
|
|
/// * [DisplayFeatureSubScreen], which documents the specifics of how
|
|
/// [DisplayFeature]s can split the screen into sub-screens.
|
|
/// * <https://developer.apple.com/ios/human-interface-guidelines/views/alerts/>
|
|
Future<T?> showCupertinoDialog<T>({
|
|
required BuildContext context,
|
|
required WidgetBuilder builder,
|
|
String? barrierLabel,
|
|
bool useRootNavigator = true,
|
|
bool barrierDismissible = false,
|
|
RouteSettings? routeSettings,
|
|
Offset? anchorPoint,
|
|
}) {
|
|
|
|
return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(CupertinoDialogRoute<T>(
|
|
builder: builder,
|
|
context: context,
|
|
barrierDismissible: barrierDismissible,
|
|
barrierLabel: barrierLabel,
|
|
barrierColor: CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context),
|
|
settings: routeSettings,
|
|
anchorPoint: anchorPoint,
|
|
));
|
|
}
|
|
|
|
/// A dialog route that shows an iOS-style dialog.
|
|
///
|
|
/// It is used internally by [showCupertinoDialog] or can be directly pushed
|
|
/// onto the [Navigator] stack to enable state restoration. See
|
|
/// [showCupertinoDialog] for a state restoration app example.
|
|
///
|
|
/// This function takes a `builder` which typically builds a [Dialog] widget.
|
|
/// Content below the dialog is dimmed with a [ModalBarrier]. The widget
|
|
/// returned by the `builder` does not share a context with the location that
|
|
/// `showDialog` is originally called from. Use a [StatefulBuilder] or a
|
|
/// custom [StatefulWidget] if the dialog needs to update dynamically.
|
|
///
|
|
/// The `context` argument is used to look up
|
|
/// [CupertinoLocalizations.modalBarrierDismissLabel], which provides the
|
|
/// modal with a localized accessibility label that will be used for the
|
|
/// modal's barrier. However, a custom `barrierLabel` can be passed in as well.
|
|
///
|
|
/// The `barrierDismissible` argument is used to indicate whether tapping on the
|
|
/// barrier will dismiss the dialog. It is `true` by default and cannot be `null`.
|
|
///
|
|
/// The `barrierColor` argument is used to specify the color of the modal
|
|
/// barrier that darkens everything below the dialog. If `null`, then
|
|
/// [CupertinoDynamicColor.resolve] is used to compute the modal color.
|
|
///
|
|
/// The `settings` argument define the settings for this route. See
|
|
/// [RouteSettings] for details.
|
|
///
|
|
/// {@macro flutter.widgets.RawDialogRoute}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [showCupertinoDialog], which is a way to display
|
|
/// an iOS-style dialog.
|
|
/// * [showGeneralDialog], which allows for customization of the dialog popup.
|
|
/// * [showDialog], which displays a Material dialog.
|
|
/// * [DisplayFeatureSubScreen], which documents the specifics of how
|
|
/// [DisplayFeature]s can split the screen into sub-screens.
|
|
class CupertinoDialogRoute<T> extends RawDialogRoute<T> {
|
|
/// A dialog route that shows an iOS-style dialog.
|
|
CupertinoDialogRoute({
|
|
required WidgetBuilder builder,
|
|
required BuildContext context,
|
|
super.barrierDismissible,
|
|
Color? barrierColor,
|
|
String? barrierLabel,
|
|
// This transition duration was eyeballed comparing with iOS
|
|
super.transitionDuration = const Duration(milliseconds: 250),
|
|
super.transitionBuilder = _buildCupertinoDialogTransitions,
|
|
super.settings,
|
|
super.anchorPoint,
|
|
}) : super(
|
|
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
|
|
return builder(context);
|
|
},
|
|
barrierLabel: barrierLabel ?? CupertinoLocalizations.of(context).modalBarrierDismissLabel,
|
|
barrierColor: barrierColor ?? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context),
|
|
);
|
|
}
|