route.dart 40.2 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// @dart = 2.8

7
import 'dart:async';
8
import 'dart:math';
9
import 'dart:ui' show lerpDouble, ImageFilter;
10

11
import 'package:flutter/foundation.dart';
12 13
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
14
import 'package:flutter/widgets.dart';
15
import 'package:flutter/animation.dart' show Curves;
16

17
import 'colors.dart';
18
import 'interface_level.dart';
19
import 'localizations.dart';
20

21 22
const double _kBackGestureWidth = 20.0;
const double _kMinFlingVelocity = 1.0; // Screen widths per second.
23

24 25 26 27 28 29 30 31
// 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.

32
// Barrier color for a Cupertino modal barrier.
33 34 35 36 37
// Extracted from https://developer.apple.com/design/resources/.
const Color _kModalBarrierColor = CupertinoDynamicColor.withBrightness(
  color: Color(0x33000000),
  darkColor: Color(0x7A000000),
);
38

39 40 41
// The duration of the transition used when a modal popup is shown.
const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335);

42
// Offset from offscreen to the right to fully on screen.
43
final Animatable<Offset> _kRightMiddleTween = Tween<Offset>(
44 45
  begin: const Offset(1.0, 0.0),
  end: Offset.zero,
46 47
);

48
// Offset from fully on screen to 1/3 offscreen to the left.
49
final Animatable<Offset> _kMiddleLeftTween = Tween<Offset>(
50 51
  begin: Offset.zero,
  end: const Offset(-1.0/3.0, 0.0),
52 53
);

54
// Offset from offscreen below to fully on screen.
55
final Animatable<Offset> _kBottomUpTween = Tween<Offset>(
56 57
  begin: const Offset(0.0, 1.0),
  end: Offset.zero,
58 59
);

60
// Custom decoration from no shadow to page shadow mimicking iOS page
61
// transitions using gradients.
62
final DecorationTween _kGradientShadowTween = DecorationTween(
63 64
  begin: _CupertinoEdgeShadowDecoration.none, // No decoration initially.
  end: const _CupertinoEdgeShadowDecoration(
65
    edgeGradient: LinearGradient(
66
      // Spans 5% of the page.
67
      begin: AlignmentDirectional(0.90, 0.0),
Ian Hickson's avatar
Ian Hickson committed
68 69
      end: AlignmentDirectional.centerEnd,
      // Eyeballed gradient used to mimic a drop shadow on the start side only.
70 71 72 73
      colors: <Color>[
        Color(0x00000000),
        Color(0x04000000),
        Color(0x12000000),
74
        Color(0x38000000),
75
      ],
76
      stops: <double>[0.0, 0.3, 0.6, 1.0],
77
    ),
78 79 80
  ),
);

81 82
/// A mixin that replaces the entire screen with an iOS transition for a
/// [PageRoute].
83
///
84
/// {@template flutter.cupertino.cupertinoRouteTransitionMixin}
85 86
/// 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.
87
///
88 89
/// The page slides in from the bottom and exits in reverse with no parallax
/// effect for fullscreen dialogs.
90
/// {@endtemplate}
91
///
92 93
/// See also:
///
94 95 96 97
///  * [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> {
98
  /// Builds the primary contents of the route.
99
  WidgetBuilder get builder;
100

101
  /// {@template flutter.cupertino.cupertinoRouteTransitionMixin.title}
102 103
  /// A title string for this route.
  ///
104
  /// Used to auto-populate [CupertinoNavigationBar] and
105 106
  /// [CupertinoSliverNavigationBar]'s `middle`/`largeTitle` widgets when
  /// one is not manually supplied.
107 108 109
  /// {@endtemplate}
  String get title;

110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136

  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) {
137 138 139
    final String previousTitleString = previousRoute is CupertinoRouteTransitionMixin
      ? previousRoute.title
      : null;
140
    if (_previousTitle == null) {
141
      _previousTitle = ValueNotifier<String>(previousTitleString);
142 143 144 145 146 147
    } else {
      _previousTitle.value = previousTitleString;
    }
    super.didChangePrevious(previousRoute);
  }

148
  @override
149 150
  // A relatively rigorous eyeball estimation.
  Duration get transitionDuration => const Duration(milliseconds: 400);
151 152

  @override
153
  Color get barrierColor => null;
154

155 156 157
  @override
  String get barrierLabel => null;

158
  @override
159 160
  bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
    // Don't perform outgoing animation if the next route is a fullscreen dialog.
161
    return nextRoute is CupertinoRouteTransitionMixin && !nextRoute.fullscreenDialog;
162 163
  }

164 165 166
  /// True if an iOS-style back swipe pop gesture is currently underway for [route].
  ///
  /// This just check the route's [NavigatorState.userGestureInProgress].
167
  ///
168 169 170 171
  /// See also:
  ///
  ///  * [popGestureEnabled], which returns true if a user-triggered pop gesture
  ///    would be allowed.
172 173 174
  static bool isPopGestureInProgress(PageRoute<dynamic> route) {
    return route.navigator.userGestureInProgress;
  }
175

176
  /// True if an iOS-style back swipe pop gesture is currently underway for this route.
177 178 179
  ///
  /// See also:
  ///
180 181 182 183 184
  ///  * [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);
185

186
  /// Whether a pop gesture can be started by the user.
187
  ///
188
  /// Returns true if the user can edge-swipe to a previous route.
189
  ///
190 191
  /// Returns false once [isPopGestureInProgress] is true, but
  /// [isPopGestureInProgress] can only become true if [popGestureEnabled] was
192
  /// true first.
193
  ///
194
  /// This should only be used between frames, not during build.
195 196 197
  bool get popGestureEnabled => _isPopGestureEnabled(this);

  static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
198 199 200 201 202 203 204 205
    // 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;
206 207
    // 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.
208 209
    if (route.hasScopedWillPopCallback)
      return false;
210
    // Fullscreen dialogs aren't dismissible by back swipe.
211
    if (route.fullscreenDialog)
212 213
      return false;
    // If we're in an animation already, we cannot be manually swiped.
214 215 216 217 218 219
    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)
220 221
      return false;
    // If we're in a gesture already, we cannot start another.
222
    if (isPopGestureInProgress(route))
223
      return false;
224

225 226 227
    // Looks like a back gesture would be welcome!
    return true;
  }
228

229
  @override
230
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
231
    final Widget child = builder(context);
232
    final Widget result = Semantics(
233 234
      scopesRoute: true,
      explicitChildNodes: true,
235
      child: child,
236
    );
237
    assert(() {
238 239 240 241 242
      if (child == null) {
        throw FlutterError.fromParts(<DiagnosticsNode>[
          ErrorSummary('The builder for route "${settings.name}" returned null.'),
          ErrorDescription('Route builders must never return null.'),
        ]);
243 244
      }
      return true;
245
    }());
246 247
    return result;
  }
248

249
  // Called by _CupertinoBackGestureDetector when a pop ("back") drag start
xster's avatar
xster committed
250
  // gesture is detected. The returned controller handles all of the subsequent
251 252 253 254
  // drag events.
  static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
    assert(_isPopGestureEnabled(route));

255 256
    return _CupertinoBackGestureController<T>(
      navigator: route.navigator,
257
      controller: route.controller, // protected access
258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281
    );
  }

  /// 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,
  ) {
282 283 284 285 286 287
    // 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);
288
    if (route.fullscreenDialog) {
289
      return CupertinoFullscreenDialogTransition(
290 291
        primaryRouteAnimation: animation,
        secondaryRouteAnimation: secondaryAnimation,
292
        child: child,
293
        linearTransition: linearTransition,
294
      );
295
    } else {
296
      return CupertinoPageTransition(
297 298
        primaryRouteAnimation: animation,
        secondaryRouteAnimation: secondaryAnimation,
299
        linearTransition: linearTransition,
300
        child: _CupertinoBackGestureDetector<T>(
301 302
          enabledCallback: () => _isPopGestureEnabled<T>(route),
          onStartPopGesture: () => _startPopGesture<T>(route),
303 304
          child: child,
        ),
305
      );
306
    }
307
  }
308

309 310 311 312
  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
    return buildPageTransitions<T>(this, context, animation, secondaryAnimation, child);
  }
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362
}

/// 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,
    RouteSettings settings,
    this.maintainState = true,
    bool fullscreenDialog = false,
  }) : assert(builder != null),
       assert(maintainState != null),
       assert(fullscreenDialog != null),
       assert(opaque),
       super(settings: settings, fullscreenDialog: fullscreenDialog);

  @override
  final WidgetBuilder builder;

  @override
  final String title;

  @override
  final bool maintainState;
363

364 365
  @override
  String get debugLabel => '${super.debugLabel}(${settings.name})';
366 367
}

368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445
// 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,
  }) : assert(page != null),
       assert(opaque),
       super(settings: page);

  CupertinoPage<T> get _page => settings as CupertinoPage<T>;

  @override
  WidgetBuilder get builder => _page.builder;

  @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.builder,
    this.maintainState = true,
    this.title,
    this.fullscreenDialog = false,
    LocalKey key,
    String name,
    Object arguments,
  }) : assert(builder != null),
       assert(maintainState != null),
       assert(fullscreenDialog != null),
       super(key: key, name: name, arguments: arguments);

  /// Builds the primary contents of the route.
  final WidgetBuilder builder;

  /// {@macro flutter.cupertino.cupertinoRouteTransitionMixin.title}
  final String title;

  /// {@macro flutter.widgets.modalRoute.maintainState}
  final bool maintainState;

  /// {@macro flutter.widgets.pageRoute.fullscreenDialog}
  final bool fullscreenDialog;

  @override
  Route<T> createRoute(BuildContext context) {
    return _PageBasedCupertinoPageRoute<T>(page: this);
  }
}

446
/// Provides an iOS-style page transition animation.
447 448 449
///
/// 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.
450
class CupertinoPageTransition extends StatelessWidget {
451 452 453 454 455 456
  /// 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.
457
  ///  * `linearTransition` is whether to perform the transitions linearly.
458
  ///    Used to precisely track back gesture drags.
459 460
  CupertinoPageTransition({
    Key key,
461 462
    @required Animation<double> primaryRouteAnimation,
    @required Animation<double> secondaryRouteAnimation,
463
    @required this.child,
Ian Hickson's avatar
Ian Hickson committed
464 465
    @required bool linearTransition,
  }) : assert(linearTransition != null),
466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497
       _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),
Ian Hickson's avatar
Ian Hickson committed
498
       super(key: key);
499 500

  // When this page is coming in to cover another page.
501
  final Animation<Offset> _primaryPositionAnimation;
502
  // When this page is becoming covered by another page.
503
  final Animation<Offset> _secondaryPositionAnimation;
504
  final Animation<Decoration> _primaryShadowAnimation;
505 506

  /// The widget below this widget in the tree.
507 508 509 510
  final Widget child;

  @override
  Widget build(BuildContext context) {
Ian Hickson's avatar
Ian Hickson committed
511 512
    assert(debugCheckHasDirectionality(context));
    final TextDirection textDirection = Directionality.of(context);
513
    return SlideTransition(
514
      position: _secondaryPositionAnimation,
Ian Hickson's avatar
Ian Hickson committed
515
      textDirection: textDirection,
516
      transformHitTests: false,
517
      child: SlideTransition(
518
        position: _primaryPositionAnimation,
Ian Hickson's avatar
Ian Hickson committed
519
        textDirection: textDirection,
520
        child: DecoratedBoxTransition(
521
          decoration: _primaryShadowAnimation,
522 523 524
          child: child,
        ),
      ),
525 526 527 528
    );
  }
}

529 530 531 532
/// 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.
533
class CupertinoFullscreenDialogTransition extends StatelessWidget {
534
  /// Creates an iOS-style transition used for summoning fullscreen dialogs.
535 536 537 538 539 540 541
  ///
  ///  * `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.
542 543
  CupertinoFullscreenDialogTransition({
    Key key,
544 545
    @required Animation<double> primaryRouteAnimation,
    @required Animation<double> secondaryRouteAnimation,
546
    @required this.child,
547
    @required bool linearTransition,
548
  }) : _positionAnimation = CurvedAnimation(
549
         parent: primaryRouteAnimation,
550 551 552 553 554
         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),
555 556 557 558 559 560 561 562 563
       _secondaryPositionAnimation =
           (linearTransition
             ? secondaryRouteAnimation
             : CurvedAnimation(
                 parent: secondaryRouteAnimation,
                 curve: Curves.linearToEaseOut,
                 reverseCurve: Curves.easeInToLinear,
               )
           ).drive(_kMiddleLeftTween),
564
       super(key: key);
565

566
  final Animation<Offset> _positionAnimation;
567 568
  // When this page is becoming covered by another page.
  final Animation<Offset> _secondaryPositionAnimation;
569 570

  /// The widget below this widget in the tree.
571
  final Widget child;
572 573

  @override
574
  Widget build(BuildContext context) {
575 576
    assert(debugCheckHasDirectionality(context));
    final TextDirection textDirection = Directionality.of(context);
577
    return SlideTransition(
578 579 580 581 582 583 584
      position: _secondaryPositionAnimation,
      textDirection: textDirection,
      transformHitTests: false,
      child: SlideTransition(
        position: _positionAnimation,
        child: child,
      ),
585
    );
586 587 588
  }
}

589 590 591 592 593
/// 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.
Ian Hickson's avatar
Ian Hickson committed
594 595 596
///
/// The gesture data is converted from absolute coordinates to logical
/// coordinates by this widget.
597 598 599 600
///
/// The type `T` specifies the return type of the route with which this gesture
/// detector is associated.
class _CupertinoBackGestureDetector<T> extends StatefulWidget {
601 602 603 604 605 606 607 608 609 610 611 612 613 614
  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;

615
  final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture;
616 617

  @override
618
  _CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>();
619 620
}

621 622
class _CupertinoBackGestureDetectorState<T> extends State<_CupertinoBackGestureDetector<T>> {
  _CupertinoBackGestureController<T> _backGestureController;
623 624 625 626 627 628

  HorizontalDragGestureRecognizer _recognizer;

  @override
  void initState() {
    super.initState();
629
    _recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650
      ..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);
Ian Hickson's avatar
Ian Hickson committed
651
    _backGestureController.dragUpdate(_convertToLogical(details.primaryDelta / context.size.width));
652 653 654 655 656
  }

  void _handleDragEnd(DragEndDetails details) {
    assert(mounted);
    assert(_backGestureController != null);
Ian Hickson's avatar
Ian Hickson committed
657
    _backGestureController.dragEnd(_convertToLogical(details.velocity.pixelsPerSecond.dx / context.size.width));
658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673
    _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);
  }

Ian Hickson's avatar
Ian Hickson committed
674 675 676 677 678 679 680 681 682 683
  double _convertToLogical(double value) {
    switch (Directionality.of(context)) {
      case TextDirection.rtl:
        return -value;
      case TextDirection.ltr:
        return value;
    }
    return null;
  }

684 685
  @override
  Widget build(BuildContext context) {
Ian Hickson's avatar
Ian Hickson committed
686
    assert(debugCheckHasDirectionality(context));
687 688 689 690 691 692
    // 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);
693
    return Stack(
694 695 696
      fit: StackFit.passthrough,
      children: <Widget>[
        widget.child,
697
        PositionedDirectional(
Ian Hickson's avatar
Ian Hickson committed
698
          start: 0.0,
699
          width: dragAreaWidth,
700 701
          top: 0.0,
          bottom: 0.0,
702
          child: Listener(
703 704 705
            onPointerDown: _handlePointerDown,
            behavior: HitTestBehavior.translucent,
          ),
706 707 708 709 710 711
        ),
      ],
    );
  }
}

712 713
/// A controller for an iOS-style back gesture.
///
714 715 716 717
/// 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.
Ian Hickson's avatar
Ian Hickson committed
718 719 720
///
/// This class works entirely in logical coordinates (0.0 is new page dismissed,
/// 1.0 is new page on top).
721 722 723 724
///
/// The type `T` specifies the return type of the route with which this gesture
/// detector controller is associated.
class _CupertinoBackGestureController<T> {
725 726 727
  /// Creates a controller for an iOS-style back gesture.
  ///
  /// The [navigator] and [controller] arguments must not be null.
728
  _CupertinoBackGestureController({
729
    @required this.navigator,
730
    @required this.controller,
731 732 733
  }) : assert(navigator != null),
       assert(controller != null) {
    navigator.didStartUserGesture();
734 735
  }

736
  final AnimationController controller;
737
  final NavigatorState navigator;
738 739 740

  /// The drag gesture has changed by [fractionalDelta]. The total range of the
  /// drag should be 0.0 to 1.0.
741 742 743 744
  void dragUpdate(double delta) {
    controller.value -= delta;
  }

745 746 747 748 749 750
  /// 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.
751 752 753 754 755 756 757 758 759 760
    //
    // 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)
761
      animateForward = velocity <= 0;
762
    else
763
      animateForward = controller.value > 0.5;
764

765 766 767 768
    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.
769 770 771 772
      final int droppedPageForwardAnimationTime = min(
        lerpDouble(_kMaxDroppedSwipePageForwardAnimationTime, 0, controller.value).floor(),
        _kMaxPageBackAnimationTime,
      );
773
      controller.animateTo(1.0, duration: Duration(milliseconds: droppedPageForwardAnimationTime), curve: animationCurve);
774
    } else {
775 776 777 778 779 780 781 782 783
      // 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);
      }
784
    }
785

786
    if (controller.isAnimating) {
787 788 789 790 791 792 793 794 795
      // 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);
796 797
    } else {
      navigator.didStopUserGesture();
798
    }
799 800
  }
}
801

Ian Hickson's avatar
Ian Hickson committed
802 803 804 805 806 807 808 809 810
// 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).
811 812 813
class _CupertinoEdgeShadowDecoration extends Decoration {
  const _CupertinoEdgeShadowDecoration({ this.edgeGradient });

814 815
  // An edge shadow decoration where the shadow is null. This is used
  // for interpolating from no shadow.
816
  static const _CupertinoEdgeShadowDecoration none =
817
      _CupertinoEdgeShadowDecoration();
818

819 820 821
  // 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.
822 823
  final LinearGradient edgeGradient;

824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840
  // 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].
841 842 843
  static _CupertinoEdgeShadowDecoration lerp(
    _CupertinoEdgeShadowDecoration a,
    _CupertinoEdgeShadowDecoration b,
844
    double t,
845
  ) {
846
    assert(t != null);
847 848
    if (a == null && b == null)
      return null;
849
    return _CupertinoEdgeShadowDecoration(
850 851 852 853 854 855
      edgeGradient: LinearGradient.lerp(a?.edgeGradient, b?.edgeGradient, t),
    );
  }

  @override
  _CupertinoEdgeShadowDecoration lerpFrom(Decoration a, double t) {
856 857 858
    if (a is _CupertinoEdgeShadowDecoration)
      return _CupertinoEdgeShadowDecoration.lerp(a, this, t);
    return _CupertinoEdgeShadowDecoration.lerp(null, this, t);
859 860 861 862
  }

  @override
  _CupertinoEdgeShadowDecoration lerpTo(Decoration b, double t) {
863 864 865
    if (b is _CupertinoEdgeShadowDecoration)
      return _CupertinoEdgeShadowDecoration.lerp(this, b, t);
    return _CupertinoEdgeShadowDecoration.lerp(this, null, t);
866 867 868
  }

  @override
869
  _CupertinoEdgeShadowPainter createBoxPainter([ VoidCallback onChanged ]) {
870
    return _CupertinoEdgeShadowPainter(this, onChanged);
871 872 873
  }

  @override
874 875
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType)
876
      return false;
877 878
    return other is _CupertinoEdgeShadowDecoration
        && other.edgeGradient == edgeGradient;
879 880 881
  }

  @override
Ian Hickson's avatar
Ian Hickson committed
882
  int get hashCode => edgeGradient.hashCode;
883 884

  @override
885 886
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
887
    properties.add(DiagnosticsProperty<LinearGradient>('edgeGradient', edgeGradient));
888
  }
889 890 891 892 893 894
}

/// A [BoxPainter] used to draw the page transition shadow using gradients.
class _CupertinoEdgeShadowPainter extends BoxPainter {
  _CupertinoEdgeShadowPainter(
    this._decoration,
Ian Hickson's avatar
Ian Hickson committed
895
    VoidCallback onChange,
896 897 898 899 900 901 902 903 904 905 906
  ) : 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
Ian Hickson's avatar
Ian Hickson committed
907 908 909 910 911 912 913 914 915 916 917 918 919
    // 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);
920
    final Paint paint = Paint()
Ian Hickson's avatar
Ian Hickson committed
921
      ..shader = gradient.createShader(rect, textDirection: textDirection);
922 923 924 925

    canvas.drawRect(rect, paint);
  }
}
926

927 928
class _CupertinoModalPopupRoute<T> extends PopupRoute<T> {
  _CupertinoModalPopupRoute({
929
    this.barrierColor,
930 931
    this.barrierLabel,
    this.builder,
Dan Field's avatar
Dan Field committed
932
    bool semanticsDismissible,
933
    ImageFilter filter,
934
    RouteSettings settings,
935 936 937
  }) : super(
         filter: filter,
         settings: settings,
Dan Field's avatar
Dan Field committed
938 939 940
       ) {
    _semanticsDismissible = semanticsDismissible;
  }
941 942

  final WidgetBuilder builder;
Dan Field's avatar
Dan Field committed
943
  bool _semanticsDismissible;
944 945 946 947 948

  @override
  final String barrierLabel;

  @override
949
  final Color barrierColor;
950 951 952 953 954

  @override
  bool get barrierDismissible => true;

  @override
Dan Field's avatar
Dan Field committed
955
  bool get semanticsDismissible => _semanticsDismissible ?? false;
956 957 958 959 960 961 962 963 964 965 966

  @override
  Duration get transitionDuration => _kModalPopupTransitionDuration;

  Animation<double> _animation;

  Tween<Offset> _offsetTween;

  @override
  Animation<double> createAnimation() {
    assert(_animation == null);
967
    _animation = CurvedAnimation(
968
      parent: super.createAnimation(),
969 970 971 972 973

      // 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,
974
    );
975
    _offsetTween = Tween<Offset>(
976 977 978 979 980 981 982 983
      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) {
984 985 986 987
    return CupertinoUserInterfaceLevel(
      data: CupertinoUserInterfaceLevelData.elevated,
      child: Builder(builder: builder),
    );
988 989 990 991
  }

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
992
    return Align(
993
      alignment: Alignment.bottomCenter,
994
      child: FractionalTranslation(
995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010
        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.
///
1011 1012 1013 1014
/// 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.
///
1015
/// The `semanticsDismissible` argument is used to determine whether the
Dan Field's avatar
Dan Field committed
1016 1017
/// semantics of the modal barrier are included in the semantics tree.
///
1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029
/// 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:
///
Dan Field's avatar
Dan Field committed
1030 1031
///  * [CupertinoActionSheet], which is the widget usually returned by the
///    `builder` argument to [showCupertinoModalPopup].
1032 1033 1034 1035
///  * <https://developer.apple.com/design/human-interface-guidelines/ios/views/action-sheets/>
Future<T> showCupertinoModalPopup<T>({
  @required BuildContext context,
  @required WidgetBuilder builder,
1036
  ImageFilter filter,
1037
  bool useRootNavigator = true,
Dan Field's avatar
Dan Field committed
1038
  bool semanticsDismissible,
1039
}) {
1040 1041
  assert(useRootNavigator != null);
  return Navigator.of(context, rootNavigator: useRootNavigator).push(
1042
    _CupertinoModalPopupRoute<T>(
1043
      barrierColor: CupertinoDynamicColor.resolve(_kModalBarrierColor, context),
1044 1045 1046
      barrierLabel: 'Dismiss',
      builder: builder,
      filter: filter,
Dan Field's avatar
Dan Field committed
1047
      semanticsDismissible: semanticsDismissible,
1048 1049 1050 1051
    ),
  );
}

1052
// The curve and initial scale values were mostly eyeballed from iOS, however
1053
// they reuse the same animation curve that was modeled after native page
1054 1055 1056
// transitions.
final Animatable<double> _dialogScaleTween = Tween<double>(begin: 1.3, end: 1.0)
  .chain(CurveTween(curve: Curves.linearToEaseOut));
1057

1058
Widget _buildCupertinoDialogTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
1059
  final CurvedAnimation fadeAnimation = CurvedAnimation(
1060 1061 1062 1063
    parent: animation,
    curve: Curves.easeInOut,
  );
  if (animation.status == AnimationStatus.reverse) {
1064
    return FadeTransition(
1065 1066 1067 1068
      opacity: fadeAnimation,
      child: child,
    );
  }
1069
  return FadeTransition(
1070 1071 1072
    opacity: fadeAnimation,
    child: ScaleTransition(
      child: child,
1073
      scale: animation.drive(_dialogScaleTween),
1074 1075 1076 1077 1078 1079
    ),
  );
}

/// Displays an iOS-style dialog above the current contents of the app, with
/// iOS-style entrance and exit animations, modal barrier color, and modal
1080 1081
/// barrier behavior (by default, the dialog is not dismissible with a tap on
/// the barrier).
1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093
///
/// 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.
///
1094 1095 1096 1097
/// 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.
1098 1099 1100 1101 1102
///
/// 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)`.
///
1103 1104 1105
/// Returns a [Future] that resolves to the value (if any) that was passed to
/// [Navigator.pop] when the dialog was closed.
///
1106
/// See also:
1107
///
1108 1109 1110 1111 1112 1113 1114 1115
///  * [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,
1116
  bool useRootNavigator = true,
1117
  bool barrierDismissible = false,
1118
  RouteSettings routeSettings,
1119 1120
}) {
  assert(builder != null);
1121
  assert(useRootNavigator != null);
1122 1123
  return showGeneralDialog(
    context: context,
1124 1125
    barrierDismissible: barrierDismissible,
    barrierLabel: CupertinoLocalizations.of(context).modalBarrierDismissLabel,
1126
    barrierColor: CupertinoDynamicColor.resolve(_kModalBarrierColor, context),
1127 1128
    // This transition duration was eyeballed comparing with iOS
    transitionDuration: const Duration(milliseconds: 250),
1129 1130 1131 1132
    pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
      return builder(context);
    },
    transitionBuilder: _buildCupertinoDialogTransitions,
1133
    useRootNavigator: useRootNavigator,
1134
    routeSettings: routeSettings,
1135
  );
1136
}