Last active
January 22, 2020 00:54
-
-
Save kwent/d2763bc2ca894bfbb2191350d006c6d8 to your computer and use it in GitHub Desktop.
CupertinoPageRoute.dart | Animation left to right
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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:async'; | |
import 'dart:math'; | |
import 'dart:ui' show lerpDouble, ImageFilter; | |
import 'package:flutter/cupertino.dart'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/widgets.dart'; | |
import 'package:flutter/animation.dart' show Curves; | |
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 for a Cupertino modal barrier. | |
// Extracted from https://developer.apple.com/design/resources/. | |
const Color _kModalBarrierColor = 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, | |
); | |
// Custom decoration from no shadow to page shadow mimicking iOS page | |
// transitions using gradients. | |
final DecorationTween _kGradientShadowTween = DecorationTween( | |
begin: _CupertinoEdgeShadowDecoration.none, // No decoration initially. | |
end: const _CupertinoEdgeShadowDecoration( | |
edgeGradient: LinearGradient( | |
// Spans 5% of the page. | |
begin: AlignmentDirectional(0.90, 0.0), | |
end: AlignmentDirectional.centerEnd, | |
// Eyeballed gradient used to mimic a drop shadow on the start side only. | |
colors: <Color>[ | |
Color(0x00000000), | |
Color(0x04000000), | |
Color(0x12000000), | |
Color(0x38000000), | |
], | |
stops: <double>[0.0, 0.3, 0.6, 1.0], | |
), | |
), | |
); | |
/// A modal route that replaces the entire screen with an iOS transition. | |
/// | |
/// 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. | |
/// | |
/// 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: | |
/// | |
/// * [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. | |
class CupertinoPageRoute<T> extends PageRoute<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, | |
RouteSettings settings, | |
this.maintainState = true, | |
bool fullscreenDialog = false, | |
}) : assert(builder != null), | |
assert(maintainState != null), | |
assert(fullscreenDialog != null), | |
assert(opaque), | |
super(settings: settings, fullscreenDialog: fullscreenDialog); | |
/// Builds the primary contents of the route. | |
final WidgetBuilder builder; | |
/// A title string for this route. | |
/// | |
/// Used to auto-populate [CupertinoNavigationBar] and | |
/// [CupertinoSliverNavigationBar]'s `middle`/`largeTitle` widgets when | |
/// one is not manually supplied. | |
final String 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 CupertinoPageRoute | |
? previousRoute.title | |
: null; | |
if (_previousTitle == null) { | |
_previousTitle = ValueNotifier<String>(previousTitleString); | |
} else { | |
_previousTitle.value = previousTitleString; | |
} | |
super.didChangePrevious(previousRoute); | |
} | |
@override | |
final bool maintainState; | |
@override | |
// A relatively rigorous eyeball estimation. | |
Duration get transitionDuration => const Duration(milliseconds: 400); | |
@override | |
Color get barrierColor => null; | |
@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 CupertinoPageRoute && !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) | |
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 = builder(context); | |
final Widget result = Semantics( | |
scopesRoute: true, | |
explicitChildNodes: true, | |
child: child, | |
); | |
assert(() { | |
if (child == null) { | |
throw FlutterError.fromParts(<DiagnosticsNode>[ | |
ErrorSummary('The builder for route "${settings.name}" returned null.'), | |
ErrorDescription('Route builders must never return null.'), | |
]); | |
} | |
return true; | |
}()); | |
return result; | |
} | |
// 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, | |
) { | |
if (route.fullscreenDialog) { | |
return CupertinoFullscreenDialogTransition( | |
animation: animation, | |
child: child, | |
); | |
} else { | |
return CupertinoPageTransition( | |
primaryRouteAnimation: animation, | |
secondaryRouteAnimation: secondaryAnimation, | |
// 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. | |
linearTransition: isPopGestureInProgress(route), | |
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); | |
} | |
@override | |
String get debugLabel => '${super.debugLabel}(${settings.name})'; | |
} | |
/// 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 primary transition linearly. | |
/// Used to precisely track back gesture drags. | |
CupertinoPageTransition({ | |
Key key, | |
@required Animation<double> primaryRouteAnimation, | |
@required Animation<double> secondaryRouteAnimation, | |
@required this.child, | |
@required bool linearTransition, | |
}) : assert(linearTransition != null), | |
_primaryPositionAnimation = | |
(linearTransition | |
? primaryRouteAnimation | |
: CurvedAnimation( | |
// The curves below have been rigorously derived from plots of native | |
// iOS animation frames. Specifically, a video was taken of a page | |
// transition animation and the distance in each frame that the page | |
// moved was measured. A best fit bezier curve was the fitted to the | |
// point set, which is linearToEaseIn. Conversely, easeInToLinear is the | |
// reflection over the origin of linearToEaseIn. | |
parent: primaryRouteAnimation, | |
curve: Curves.linearToEaseOut, | |
reverseCurve: Curves.easeInToLinear, | |
) | |
).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(_kGradientShadowTween), | |
super(key: key); | |
// 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. | |
CupertinoFullscreenDialogTransition({ | |
Key key, | |
@required Animation<double> animation, | |
@required this.child, | |
}) : _positionAnimation = CurvedAnimation( | |
parent: animation, | |
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), | |
super(key: key); | |
final Animation<Offset> _positionAnimation; | |
/// The widget below this widget in the tree. | |
final Widget child; | |
@override | |
Widget build(BuildContext context) { | |
return 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({ | |
Key key, | |
@required this.enabledCallback, | |
@required this.onStartPopGesture, | |
@required this.child, | |
}) : assert(enabledCallback != null), | |
assert(onStartPopGesture != null), | |
assert(child != null), | |
super(key: key); | |
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; | |
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; | |
} | |
return null; | |
} | |
@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.of(context).padding.left : | |
MediaQuery.of(context).padding.right; | |
dragAreaWidth = max(dragAreaWidth, _kBackGestureWidth); | |
return Stack( | |
fit: StackFit.passthrough, | |
children: <Widget>[ | |
widget.child, | |
PositionedDirectional( | |
start: MediaQuery.of(context).size.width - dragAreaWidth, | |
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, | |
}) : assert(navigator != null), | |
assert(controller != null) { | |
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. | |
// AnimationController.fling is guaranteed to | |
// take at least one frame. | |
// | |
// This curve has been determined through rigorously eyeballing native iOS | |
// animations. | |
const Curve animationCurve = Curves.fastLinearToSlowEaseIn; | |
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. | |
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. | |
// | |
// The [edgeGradient] will be given a [TextDirection] when its shader is | |
// created, and so can be direction-sensitive; in this file we set it to a | |
// gradient that uses an AlignmentDirectional to position the gradient on the | |
// end edge of the gradient's box (which will be the edge adjacent to the start | |
// edge of the actual box we're supposed to paint in). | |
class _CupertinoEdgeShadowDecoration extends Decoration { | |
const _CupertinoEdgeShadowDecoration({ this.edgeGradient }); | |
// An edge shadow decoration where the shadow is null. This is used | |
// for interpolating from no shadow. | |
static const _CupertinoEdgeShadowDecoration none = | |
_CupertinoEdgeShadowDecoration(); | |
// A gradient to draw to the left of the box being decorated. | |
// Alignments are relative to the original box translated one box | |
// width to the left. | |
final LinearGradient edgeGradient; | |
// 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, | |
) { | |
assert(t != null); | |
if (a == null && b == null) | |
return null; | |
return _CupertinoEdgeShadowDecoration( | |
edgeGradient: LinearGradient.lerp(a?.edgeGradient, b?.edgeGradient, 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.edgeGradient == edgeGradient; | |
} | |
@override | |
int get hashCode => edgeGradient.hashCode; | |
@override | |
void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
super.debugFillProperties(properties); | |
properties.add(DiagnosticsProperty<LinearGradient>('edgeGradient', edgeGradient)); | |
} | |
} | |
/// A [BoxPainter] used to draw the page transition shadow using gradients. | |
class _CupertinoEdgeShadowPainter extends BoxPainter { | |
_CupertinoEdgeShadowPainter( | |
this._decoration, | |
VoidCallback onChange, | |
) : assert(_decoration != null), | |
super(onChange); | |
final _CupertinoEdgeShadowDecoration _decoration; | |
@override | |
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { | |
final LinearGradient gradient = _decoration.edgeGradient; | |
if (gradient == null) | |
return; | |
// The drawable space for the gradient is a rect with the same size as | |
// its parent box one box width on the start side of the box. | |
final TextDirection textDirection = configuration.textDirection; | |
assert(textDirection != null); | |
double deltaX; | |
switch (textDirection) { | |
case TextDirection.rtl: | |
deltaX = configuration.size.width; | |
break; | |
case TextDirection.ltr: | |
deltaX = -configuration.size.width; | |
break; | |
} | |
final Rect rect = (offset & configuration.size).translate(deltaX, 0.0); | |
final Paint paint = Paint() | |
..shader = gradient.createShader(rect, textDirection: textDirection); | |
canvas.drawRect(rect, paint); | |
} | |
} | |
class _CupertinoModalPopupRoute<T> extends PopupRoute<T> { | |
_CupertinoModalPopupRoute({ | |
this.barrierColor, | |
this.barrierLabel, | |
this.builder, | |
ImageFilter filter, | |
RouteSettings settings, | |
}) : super( | |
filter: filter, | |
settings: settings, | |
); | |
final WidgetBuilder builder; | |
@override | |
final String barrierLabel; | |
@override | |
final Color barrierColor; | |
@override | |
bool get barrierDismissible => true; | |
@override | |
bool get semanticsDismissible => false; | |
@override | |
Duration get transitionDuration => _kModalPopupTransitionDuration; | |
Animation<double> _animation; | |
Tween<Offset> _offsetTween; | |
@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: const Offset(0.0, 0.0), | |
); | |
return _animation; | |
} | |
@override | |
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { | |
return CupertinoUserInterfaceLevel( | |
data: CupertinoUserInterfaceLevelData.elevated, | |
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 `useRootNavigator` argument is used to determine whether to push the | |
/// popup to the [Navigator] furthest from or nearest to the given `context`. It | |
/// is `false` by default. | |
/// | |
/// 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. | |
/// | |
/// Returns a `Future` that resolves to the value that was passed to | |
/// [Navigator.pop] when the popup was closed. | |
/// | |
/// See also: | |
/// | |
/// * [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, | |
bool useRootNavigator = true, | |
}) { | |
assert(useRootNavigator != null); | |
return Navigator.of(context, rootNavigator: useRootNavigator).push( | |
_CupertinoModalPopupRoute<T>( | |
barrierColor: CupertinoDynamicColor.resolve(_kModalBarrierColor, context), | |
barrierLabel: 'Dismiss', | |
builder: builder, | |
filter: filter, | |
), | |
); | |
} | |
// 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( | |
child: child, | |
scale: animation.drive(_dialogScaleTween), | |
), | |
); | |
} | |
/// 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 (the dialog is not dismissible with a tap on the barrier). | |
/// | |
/// This function takes a `builder` which typically builds a [CupertinoDialog] | |
/// or [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. | |
/// | |
/// 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. | |
/// | |
/// See also: | |
/// | |
/// * [CupertinoDialog], an iOS-style dialog. | |
/// * [CupertinoAlertDialog], an iOS-style alert dialog. | |
/// * [showDialog], which displays a Material-style dialog. | |
/// * [showGeneralDialog], which allows for customization of the dialog popup. | |
/// * <https://developer.apple.com/ios/human-interface-guidelines/views/alerts/> | |
Future<T> showCupertinoDialog<T>({ | |
@required BuildContext context, | |
@required WidgetBuilder builder, | |
bool useRootNavigator = true, | |
}) { | |
assert(builder != null); | |
assert(useRootNavigator != null); | |
return showGeneralDialog( | |
context: context, | |
barrierDismissible: false, | |
barrierColor: CupertinoDynamicColor.resolve(_kModalBarrierColor, context), | |
// This transition duration was eyeballed comparing with iOS | |
transitionDuration: const Duration(milliseconds: 250), | |
pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { | |
return builder(context); | |
}, | |
transitionBuilder: _buildCupertinoDialogTransitions, | |
useRootNavigator: useRootNavigator, | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage: