CupertinoContextMenu (iOS 13) (#43918)

Adds the CupertinoContextMenu widget for iOS 13 support.
diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart
index b20a6a0..7c12c7c 100644
--- a/packages/flutter/lib/cupertino.dart
+++ b/packages/flutter/lib/cupertino.dart
@@ -15,6 +15,8 @@
 export 'src/cupertino/bottom_tab_bar.dart';
 export 'src/cupertino/button.dart';
 export 'src/cupertino/colors.dart';
+export 'src/cupertino/context_menu.dart';
+export 'src/cupertino/context_menu_action.dart';
 export 'src/cupertino/date_picker.dart';
 export 'src/cupertino/dialog.dart';
 export 'src/cupertino/icon_theme_data.dart';
diff --git a/packages/flutter/lib/src/cupertino/context_menu.dart b/packages/flutter/lib/src/cupertino/context_menu.dart
new file mode 100644
index 0000000..2576a0d
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/context_menu.dart
@@ -0,0 +1,1283 @@
+// Copyright 2019 The Chromium 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' as math;
+import 'dart:ui' as ui;
+import 'package:flutter/gestures.dart' show kMinFlingVelocity, kLongPressTimeout;
+import 'package:flutter/rendering.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+
+// The scale of the child at the time that the CupertinoContextMenu opens.
+// This value was eyeballed from a physical device running iOS 13.1.2.
+const double _kOpenScale = 1.1;
+
+typedef _DismissCallback = void Function(
+  BuildContext context,
+  double scale,
+  double opacity,
+);
+
+/// A function that produces the preview when the CupertinoContextMenu is open.
+///
+/// Called every time the animation value changes.
+typedef ContextMenuPreviewBuilder = Widget Function(
+  BuildContext context,
+  Animation<double> animation,
+  Widget child,
+);
+
+// A function that proxies to ContextMenuPreviewBuilder without the child.
+typedef _ContextMenuPreviewBuilderChildless = Widget Function(
+  BuildContext context,
+  Animation<double> animation,
+);
+
+// Given a GlobalKey, return the Rect of the corresponding RenderBox's
+// paintBounds in global coordinates.
+Rect _getRect(GlobalKey globalKey) {
+  assert(globalKey.currentContext != null);
+  final RenderBox renderBoxContainer = globalKey.currentContext.findRenderObject();
+  final Offset containerOffset = renderBoxContainer.localToGlobal(
+    renderBoxContainer.paintBounds.topLeft,
+  );
+  return containerOffset & renderBoxContainer.paintBounds.size;
+}
+
+// The context menu arranges itself slightly differently based on the location
+// on the screen of [CupertinoContextMenu.child] before the
+// [CupertinoContextMenu] opens.
+enum _ContextMenuLocation {
+  center,
+  left,
+  right,
+}
+
+/// A full-screen modal route that opens when the [child] is long-pressed.
+///
+/// When open, the [CupertinoContextMenu] shows the child, or the widget returned
+/// by [previewBuilder] if given, in a large full-screen [Overlay] with a list
+/// of buttons specified by [actions]. The child/preview is placed in an
+/// [Expanded] widget so that it will grow to fill the Overlay if its size is
+/// unconstrained.
+///
+/// When closed, the CupertinoContextMenu simply displays the child as if the
+/// CupertinoContextMenu were not there. Sizing and positioning is unaffected.
+/// The menu can be closed like other [PopupRoute]s, such as by tapping the
+/// background or by calling `Navigator.pop(context)`. Unlike PopupRoute, it can
+/// also be closed by swiping downwards.
+///
+/// The [previewBuilder] parameter is most commonly used to display a slight
+/// variation of [child]. See [previewBuilder] for an example of rounding the
+/// child's corners and allowing its aspect ratio to expand, similar to the
+/// Photos app on iOS.
+///
+/// {@tool dartpad --template=stateless_widget_material}
+///
+/// This sample shows a very simple CupertinoContextMenu for an empty red
+/// 100x100 Container. Simply long press on it to open.
+///
+/// ```dart
+/// Widget build(BuildContext context) {
+///   return Scaffold(
+///     key: scaffoldKey,
+///     body: Center(
+///       child: Container(
+///         width: 100,
+///         height: 100,
+///         child: CupertinoContextMenu(
+///           child: Container(
+///             color: Colors.red,
+///           ),
+///           actions: <Widget>[
+///             CupertinoContextMenuAction(
+///               child: const Text('Action one'),
+///               onPressed: () {
+///                 Navigator.pop(context);
+///               },
+///             ),
+///             CupertinoContextMenuAction(
+///               child: const Text('Action two'),
+///               onPressed: () {
+///                 Navigator.pop(context);
+///               },
+///             ),
+///           ],
+///         ),
+///       ),
+///     ),
+///   );
+/// }
+/// ```
+/// {@end-tool}
+///
+/// See also:
+///
+///   * [Apple's HIG for Context Menus](https://developer.apple.com/design/human-interface-guidelines/ios/controls/context-menus/)
+class CupertinoContextMenu extends StatefulWidget {
+  /// Create a context menu.
+  ///
+  /// [actions] is required and cannot be null or empty.
+  ///
+  /// [child] is required and cannot be null.
+  CupertinoContextMenu({
+    Key key,
+    @required this.actions,
+    @required this.child,
+    this.previewBuilder,
+  }) : assert(actions != null && actions.isNotEmpty),
+       assert(child != null),
+       super(key: key);
+
+  /// The widget that can be "opened" with the [CupertinoContextMenu].
+  ///
+  /// When the [CupertinoContextMenu] is long-pressed, the menu will open and
+  /// this widget (or the widget returned by [previewBuilder], if provided) will
+  /// be moved to the new route and placed inside of an [Expanded] widget. This
+  /// allows the child to resize to fit in its place in the new route, if it
+  /// doesn't size itself.
+  ///
+  /// When the [CupertinoContextMenu] is "closed", this widget acts like a
+  /// [Container], i.e. it does not constrain its child's size or affect its
+  /// position.
+  ///
+  /// This parameter cannot be null.
+  final Widget child;
+
+  /// The actions that are shown in the menu.
+  ///
+  /// These actions are typically [CupertinoContextMenuAction]s.
+  ///
+  /// This parameter cannot be null or empty.
+  final List<Widget> actions;
+
+  /// A function that returns an alternative widget to show when the
+  /// [CupertinoContextMenu] is open.
+  ///
+  /// If not specified, [child] will be shown.
+  ///
+  /// The preview is often used to show a slight variation of the [child]. For
+  /// example, the child could be given rounded corners in the preview but have
+  /// sharp corners when in the page.
+  ///
+  /// In addition to the current [BuildContext], the function is also called
+  /// with an [Animation] and the [child]. The animation goes from 0 to 1 when
+  /// the CupertinoContextMenu opens, and from 1 to 0 when it closes, and it can
+  /// be used to animate the preview in sync with this opening and closing. The
+  /// child parameter provides access to the child displayed when the
+  /// CupertinoContextMenu is closed.
+  ///
+  /// {@tool sample}
+  ///
+  /// Below is an example of using `previewBuilder` to show an image tile that's
+  /// similar to each tile in the iOS iPhoto app's context menu. Several of
+  /// these could be used in a GridView for a similar effect.
+  ///
+  /// When opened, the child animates to show its full aspect ratio and has
+  /// rounded corners. The larger size of the open CupertinoContextMenu allows
+  /// the FittedBox to fit the entire image, even when it has a very tall or
+  /// wide aspect ratio compared to the square of a GridView, so this animates
+  /// into view as the CupertinoContextMenu is opened. The preview is swapped in
+  /// right when the open animation begins, which includes the rounded corners.
+  ///
+  /// ```dart
+  /// CupertinoContextMenu(
+  ///   child: FittedBox(
+  ///     fit: BoxFit.cover,
+  ///     child: Image.asset('assets/photo.jpg'),
+  ///   ),
+  ///   // The FittedBox in the preview here allows the image to animate its
+  ///   // aspect ratio when the CupertinoContextMenu is animating its preview
+  ///   // widget open and closed.
+  ///   previewBuilder: (BuildContext context, Animation<double> animation, Widget child) {
+  ///     return FittedBox(
+  ///       fit: BoxFit.cover,
+  ///       // This ClipRRect rounds the corners of the image when the
+  ///       // CupertinoContextMenu is open, even though it's not rounded when
+  ///       // it's closed. It uses the given animation to animate the corners
+  ///       // in sync with the opening animation.
+  ///       child: ClipRRect(
+  ///         borderRadius: BorderRadius.circular(64.0 * animation.value),
+  ///         child: Image.asset('assets/photo.jpg'),
+  ///       ),
+  ///     );
+  ///   },
+  ///   actions: <Widget>[
+  ///     CupertinoContextMenuAction(
+  ///       child: const Text('Action one'),
+  ///       onPressed: () {},
+  ///     ),
+  ///   ],
+  /// ),
+  /// ```
+  ///
+  /// {@end-tool}
+  final ContextMenuPreviewBuilder previewBuilder;
+
+  @override
+  _CupertinoContextMenuState createState() => _CupertinoContextMenuState();
+}
+
+class _CupertinoContextMenuState extends State<CupertinoContextMenu> with TickerProviderStateMixin {
+  final GlobalKey _childGlobalKey = GlobalKey();
+  bool _childHidden = false;
+  // Animates the child while it's opening.
+  AnimationController _openController;
+  Rect _decoyChildEndRect;
+  OverlayEntry _lastOverlayEntry;
+  _ContextMenuRoute<void> _route;
+
+  @override
+  void initState() {
+    super.initState();
+    _openController = AnimationController(
+      duration: kLongPressTimeout,
+      vsync: this,
+    );
+    _openController.addStatusListener(_onDecoyAnimationStatusChange);
+  }
+
+  // Determine the _ContextMenuLocation based on the location of the original
+  // child in the screen.
+  //
+  // The location of the original child is used to determine how to horizontally
+  // align the content of the open CupertinoContextMenu. For example, if the
+  // child is near the center of the screen, it will also appear in the center
+  // of the screen when the menu is open, and the actions will be centered below
+  // it.
+  _ContextMenuLocation get _contextMenuLocation {
+    final Rect childRect = _getRect(_childGlobalKey);
+    final double screenWidth = MediaQuery.of(context).size.width;
+
+    final double center = screenWidth / 2;
+    final bool centerDividesChild = childRect.left < center
+      && childRect.right > center;
+    final double distanceFromCenter = (center - childRect.center.dx).abs();
+    if (centerDividesChild && distanceFromCenter <= childRect.width / 4) {
+      return _ContextMenuLocation.center;
+    }
+
+    if (childRect.center.dx > center) {
+      return _ContextMenuLocation.right;
+    }
+
+    return _ContextMenuLocation.left;
+  }
+
+  // Push the new route and open the CupertinoContextMenu overlay.
+  void _openContextMenu() {
+    setState(() {
+      _childHidden = true;
+    });
+
+    _route = _ContextMenuRoute<void>(
+      actions: widget.actions,
+      barrierLabel: 'Dismiss',
+      filter: ui.ImageFilter.blur(
+        sigmaX: 5.0,
+        sigmaY: 5.0,
+      ),
+      contextMenuLocation: _contextMenuLocation,
+      previousChildRect: _decoyChildEndRect,
+      builder: (BuildContext context, Animation<double> animation) {
+        if (widget.previewBuilder == null) {
+          return widget.child;
+        }
+        return widget.previewBuilder(context, animation, widget.child);
+      },
+    );
+    Navigator.of(context, rootNavigator: true).push<void>(_route);
+    _route.animation.addStatusListener(_routeAnimationStatusListener);
+  }
+
+  void _onDecoyAnimationStatusChange(AnimationStatus animationStatus) {
+    switch (animationStatus) {
+      case AnimationStatus.dismissed:
+        if (_route == null) {
+          setState(() {
+            _childHidden = false;
+          });
+        }
+        _lastOverlayEntry?.remove();
+        _lastOverlayEntry = null;
+        break;
+
+      case AnimationStatus.completed:
+        setState(() {
+          _childHidden = true;
+        });
+        _openContextMenu();
+        // Keep the decoy on the screen for one extra frame. We have to do this
+        // because _ContextMenuRoute renders its first frame offscreen.
+        // Otherwise there would be a visible flash when nothing is rendered for
+        // one frame.
+        SchedulerBinding.instance.addPostFrameCallback((Duration _) {
+          _lastOverlayEntry?.remove();
+          _lastOverlayEntry = null;
+          _openController.reset();
+        });
+        break;
+
+      default:
+        return;
+    }
+  }
+
+  // Watch for when _ContextMenuRoute is closed and return to the state where
+  // the CupertinoContextMenu just behaves as a Container.
+  void _routeAnimationStatusListener(AnimationStatus status) {
+    if (status != AnimationStatus.dismissed) {
+      return;
+    }
+    setState(() {
+      _childHidden = false;
+    });
+    _route.animation.removeStatusListener(_routeAnimationStatusListener);
+    _route = null;
+  }
+
+  void _onTap() {
+    if (_openController.isAnimating && _openController.value < 0.5) {
+      _openController.reverse();
+    }
+  }
+
+  void _onTapCancel() {
+    if (_openController.isAnimating && _openController.value < 0.5) {
+      _openController.reverse();
+    }
+  }
+
+  void _onTapUp(TapUpDetails details) {
+    if (_openController.isAnimating && _openController.value < 0.5) {
+      _openController.reverse();
+    }
+  }
+
+  void _onTapDown(TapDownDetails details) {
+    setState(() {
+      _childHidden = true;
+    });
+
+    final Rect childRect = _getRect(_childGlobalKey);
+    _decoyChildEndRect = Rect.fromCenter(
+      center: childRect.center,
+      width: childRect.width * _kOpenScale,
+      height: childRect.height * _kOpenScale,
+    );
+
+    // Create a decoy child in an overlay directly on top of the original child.
+    // TODO(justinmc): There is a known inconsistency with native here, due to
+    // doing the bounce animation using a decoy in the top level Overlay. The
+    // decoy will pop on top of the AppBar if the child is partially behind it,
+    // such as a top item in a partially scrolled view. However, if we don't use
+    // an overlay, then the decoy will appear behind its neighboring widget when
+    // it expands. This may be solveable by adding a widget to Scaffold that's
+    // undernearth the AppBar.
+    _lastOverlayEntry = OverlayEntry(
+      opaque: false,
+      builder: (BuildContext context) {
+        return _DecoyChild(
+          beginRect: childRect,
+          child: widget.child,
+          controller: _openController,
+          endRect: _decoyChildEndRect,
+        );
+      },
+    );
+    Overlay.of(context).insert(_lastOverlayEntry);
+    _openController.forward();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      onTapCancel: _onTapCancel,
+      onTapDown: _onTapDown,
+      onTapUp: _onTapUp,
+      onTap: _onTap,
+      child: TickerMode(
+        enabled: !_childHidden,
+        child: Opacity(
+          key: _childGlobalKey,
+          opacity: _childHidden ? 0.0 : 1.0,
+          child: widget.child,
+        ),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _openController.dispose();
+    super.dispose();
+  }
+}
+
+// A floating copy of the CupertinoContextMenu's child.
+//
+// When the child is pressed, but before the CupertinoContextMenu opens, it does
+// a "bounce" animation where it shrinks and then grows. This is implemented
+// by hiding the original child and placing _DecoyChild on top of it in an
+// Overlay. The use of an Overlay allows the _DecoyChild to appear on top of
+// siblings of the original child.
+class _DecoyChild extends StatefulWidget {
+  const _DecoyChild({
+    Key key,
+    this.beginRect,
+    this.controller,
+    this.endRect,
+    this.child,
+  }) : super(key: key);
+
+  final Rect beginRect;
+  final AnimationController controller;
+  final Rect endRect;
+  final Widget child;
+
+  @override
+  _DecoyChildState createState() => _DecoyChildState();
+}
+
+class _DecoyChildState extends State<_DecoyChild> with TickerProviderStateMixin {
+  // TODO(justinmc): Dark mode support.
+  // See https://github.com/flutter/flutter/issues/43211.
+  static const Color _lightModeMaskColor = Color(0xFF888888);
+  static const Color _masklessColor = Color(0xFFFFFFFF);
+
+  final GlobalKey _childGlobalKey = GlobalKey();
+  Animation<Color> _mask;
+  Animation<Rect> _rect;
+
+  @override
+  void initState() {
+    super.initState();
+    // Change the color of the child during the initial part of the decoy bounce
+    // animation. The interval was eyeballed from a physical iOS 13.1.2 device.
+    _mask = _OnOffAnimation<Color>(
+      controller: widget.controller,
+      onValue: _lightModeMaskColor,
+      offValue: _masklessColor,
+      intervalOn: 0.0,
+      intervalOff: 0.5,
+    );
+
+    final Rect midRect =  widget.beginRect.deflate(
+      widget.beginRect.width * (_kOpenScale - 1.0) / 2,
+    );
+    _rect = TweenSequence<Rect>(<TweenSequenceItem<Rect>>[
+      TweenSequenceItem<Rect>(
+        tween: RectTween(
+          begin: widget.beginRect,
+          end: midRect,
+        ).chain(CurveTween(curve: Curves.easeInOutCubic)),
+        weight: 1.0,
+      ),
+      TweenSequenceItem<Rect>(
+        tween: RectTween(
+          begin: midRect,
+          end: widget.endRect,
+        ).chain(CurveTween(curve: Curves.easeOutCubic)),
+        weight: 1.0,
+      ),
+    ]).animate(widget.controller);
+    _rect.addListener(_rectListener);
+  }
+
+  // Listen to the _rect animation and vibrate when it reaches the halfway point
+  // and switches from animating down to up.
+  void _rectListener() {
+    if (widget.controller.value < 0.5) {
+      return;
+    }
+    HapticFeedback.selectionClick();
+    _rect.removeListener(_rectListener);
+  }
+
+  @override
+  void dispose() {
+    _rect.removeListener(_rectListener);
+    super.dispose();
+  }
+
+  Widget _buildAnimation(BuildContext context, Widget child) {
+    final Color color = widget.controller.status == AnimationStatus.reverse
+      ? _masklessColor
+      : _mask.value;
+    return Positioned.fromRect(
+      rect: _rect.value,
+      child: ShaderMask(
+        key: _childGlobalKey,
+        shaderCallback: (Rect bounds) {
+          return LinearGradient(
+            begin: Alignment.topLeft,
+            end: Alignment.bottomRight,
+            colors: <Color>[color, color],
+          ).createShader(bounds);
+        },
+        child: widget.child,
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: <Widget>[
+        AnimatedBuilder(
+          builder: _buildAnimation,
+          animation: widget.controller,
+        ),
+      ],
+    );
+  }
+}
+
+// The open CupertinoContextMenu modal.
+class _ContextMenuRoute<T> extends PopupRoute<T> {
+  // Build a _ContextMenuRoute.
+  _ContextMenuRoute({
+    @required List<Widget> actions,
+    @required _ContextMenuLocation contextMenuLocation,
+    this.barrierLabel,
+    _ContextMenuPreviewBuilderChildless builder,
+    ui.ImageFilter filter,
+    Rect previousChildRect,
+    RouteSettings settings,
+  }) : assert(actions != null && actions.isNotEmpty),
+       assert(contextMenuLocation != null),
+       _actions = actions,
+       _builder = builder,
+       _contextMenuLocation = contextMenuLocation,
+       _previousChildRect = previousChildRect,
+       super(
+         filter: filter,
+         settings: settings,
+       );
+
+  // Barrier color for a Cupertino modal barrier.
+  static const Color _kModalBarrierColor = Color(0x6604040F);
+  // The duration of the transition used when a modal popup is shown. Eyeballed
+  // from a physical device running iOS 13.1.2.
+  static const Duration _kModalPopupTransitionDuration =
+    Duration(milliseconds: 335);
+
+  final List<Widget> _actions;
+  final _ContextMenuPreviewBuilderChildless _builder;
+  final GlobalKey _childGlobalKey = GlobalKey();
+  final _ContextMenuLocation _contextMenuLocation;
+  bool _externalOffstage = false;
+  bool _internalOffstage = false;
+  Orientation _lastOrientation;
+  // The Rect of the child at the moment that the CupertinoContextMenu opens.
+  final Rect _previousChildRect;
+  double _scale = 1.0;
+  final GlobalKey _sheetGlobalKey = GlobalKey();
+
+  static final CurveTween _curve = CurveTween(
+    curve: Curves.easeOutBack,
+  );
+  static final CurveTween _curveReverse = CurveTween(
+    curve: Curves.easeInBack,
+  );
+  static final RectTween _rectTween = RectTween();
+  static final Animatable<Rect> _rectAnimatable = _rectTween.chain(_curve);
+  static final RectTween _rectTweenReverse = RectTween();
+  static final Animatable<Rect> _rectAnimatableReverse = _rectTweenReverse
+    .chain(
+      _curveReverse,
+    );
+  static final RectTween _sheetRectTween = RectTween();
+  final Animatable<Rect> _sheetRectAnimatable = _sheetRectTween.chain(
+    _curve,
+  );
+  final Animatable<Rect> _sheetRectAnimatableReverse = _sheetRectTween.chain(
+    _curveReverse,
+  );
+  static final Tween<double> _sheetScaleTween = Tween<double>();
+  static final Animatable<double> _sheetScaleAnimatable = _sheetScaleTween
+    .chain(
+      _curve,
+    );
+  static final Animatable<double> _sheetScaleAnimatableReverse =
+    _sheetScaleTween.chain(
+      _curveReverse,
+    );
+  final Tween<double> _opacityTween = Tween<double>(begin: 0.0, end: 1.0);
+  Animation<double> _sheetOpacity;
+
+  @override
+  final String barrierLabel;
+
+  @override
+  Color get barrierColor => _kModalBarrierColor;
+
+  @override
+  bool get barrierDismissible => true;
+
+  @override
+  bool get semanticsDismissible => false;
+
+  @override
+  Duration get transitionDuration => _kModalPopupTransitionDuration;
+
+  // Getting the RenderBox doesn't include the scale from the Transform.scale,
+  // so it's manually accounted for here.
+  static Rect _getScaledRect(GlobalKey globalKey, double scale) {
+    final Rect childRect = _getRect(globalKey);
+    final Size sizeScaled = childRect.size * scale;
+    final Offset offsetScaled = Offset(
+      childRect.left + (childRect.size.width - sizeScaled.width) / 2,
+      childRect.top + (childRect.size.height - sizeScaled.height) / 2,
+    );
+    return offsetScaled & sizeScaled;
+  }
+
+  // Get the alignment for the _ContextMenuSheet's Transform.scale based on the
+  // contextMenuLocation.
+  static AlignmentDirectional getSheetAlignment(_ContextMenuLocation contextMenuLocation) {
+    switch (contextMenuLocation) {
+      case _ContextMenuLocation.center:
+        return AlignmentDirectional.topCenter;
+      case _ContextMenuLocation.right:
+        return AlignmentDirectional.topEnd;
+      default:
+        return AlignmentDirectional.topStart;
+    }
+  }
+
+  // The place to start the sheetRect animation from.
+  static Rect _getSheetRectBegin(Orientation orientation, _ContextMenuLocation contextMenuLocation, Rect childRect, Rect sheetRect) {
+    switch (contextMenuLocation) {
+      case _ContextMenuLocation.center:
+        final Offset target = orientation == Orientation.portrait
+          ? childRect.bottomCenter
+          : childRect.topCenter;
+        final Offset centered = target - Offset(sheetRect.width / 2, 0.0);
+        return centered & sheetRect.size;
+      case _ContextMenuLocation.right:
+        final Offset target = orientation == Orientation.portrait
+          ? childRect.bottomRight
+          : childRect.topRight;
+        return (target - Offset(sheetRect.width, 0.0)) & sheetRect.size;
+      default:
+        final Offset target = orientation == Orientation.portrait
+          ? childRect.bottomLeft
+          : childRect.topLeft;
+        return target & sheetRect.size;
+    }
+  }
+
+  void _onDismiss(BuildContext context, double scale, double opacity) {
+    _scale = scale;
+    _opacityTween.end = opacity;
+    _sheetOpacity = _opacityTween.animate(CurvedAnimation(
+      parent: animation,
+      curve: const Interval(0.9, 1.0),
+    ));
+    Navigator.of(context).pop();
+  }
+
+  // Take measurements on the child and _ContextMenuSheet and update the
+  // animation tweens to match.
+  void _updateTweenRects() {
+    final Rect childRect = _scale == null
+      ? _getRect(_childGlobalKey)
+      : _getScaledRect(_childGlobalKey, _scale);
+    _rectTween.begin = _previousChildRect;
+    _rectTween.end = childRect;
+
+    // When opening, the transition happens from the end of the child's bounce
+    // animation to the final state. When closing, it goes from the final state
+    // to the original position before the bounce.
+    final Rect childRectOriginal = Rect.fromCenter(
+      center: _previousChildRect.center,
+      width: _previousChildRect.width / _kOpenScale,
+      height: _previousChildRect.height / _kOpenScale,
+    );
+
+    final Rect sheetRect = _getRect(_sheetGlobalKey);
+    final Rect sheetRectBegin = _getSheetRectBegin(
+      _lastOrientation,
+      _contextMenuLocation,
+      childRectOriginal,
+      sheetRect,
+    );
+    _sheetRectTween.begin = sheetRectBegin;
+    _sheetRectTween.end = sheetRect;
+    _sheetScaleTween.begin = 0.0;
+    _sheetScaleTween.end = _scale;
+
+    _rectTweenReverse.begin = childRectOriginal;
+    _rectTweenReverse.end = childRect;
+  }
+
+  void _setOffstageInternally() {
+    super.offstage = _externalOffstage || _internalOffstage;
+    // It's necessary to call changedInternalState to get the backdrop to
+    // update.
+    changedInternalState();
+  }
+
+  @override
+  bool didPop(T result) {
+    _updateTweenRects();
+    return super.didPop(result);
+  }
+
+  @override
+  set offstage(bool value) {
+    _externalOffstage = value;
+    _setOffstageInternally();
+  }
+
+  @override
+  TickerFuture didPush() {
+    _internalOffstage = true;
+    _setOffstageInternally();
+
+    // Render one frame offstage in the final position so that we can take
+    // measurements of its layout and then animate to them.
+    SchedulerBinding.instance.addPostFrameCallback((Duration _) {
+      _updateTweenRects();
+      _internalOffstage = false;
+      _setOffstageInternally();
+    });
+    return super.didPush();
+  }
+
+  @override
+  Animation<double> createAnimation() {
+    final Animation<double> animation = super.createAnimation();
+    _sheetOpacity = _opacityTween.animate(CurvedAnimation(
+      parent: animation,
+      curve: Curves.linear,
+    ));
+    return animation;
+  }
+
+  @override
+  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
+    // This is usually used to build the "page", which is then passed to
+    // buildTransitions as child, the idea being that buildTransitions will
+    // animate the entire page into the scene. In the case of _ContextMenuRoute,
+    // two individual pieces of the page are animated into the scene in
+    // buildTransitions, and null is returned here.
+    return null;
+  }
+
+  @override
+  Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
+    return OrientationBuilder(
+      builder: (BuildContext context, Orientation orientation) {
+        _lastOrientation = orientation;
+
+        // While the animation is running, render everything in a Stack so that
+        // they're movable.
+        if (!animation.isCompleted) {
+          final bool reverse = animation.status == AnimationStatus.reverse;
+          final Rect rect = reverse
+            ? _rectAnimatableReverse.evaluate(animation)
+            : _rectAnimatable.evaluate(animation);
+          final Rect sheetRect = reverse
+            ? _sheetRectAnimatableReverse.evaluate(animation)
+            : _sheetRectAnimatable.evaluate(animation);
+          final double sheetScale = reverse
+            ? _sheetScaleAnimatableReverse.evaluate(animation)
+            : _sheetScaleAnimatable.evaluate(animation);
+          return Stack(
+            children: <Widget>[
+              Positioned.fromRect(
+                rect: sheetRect,
+                child: Opacity(
+                  opacity: _sheetOpacity.value,
+                  child: Transform.scale(
+                    alignment: getSheetAlignment(_contextMenuLocation),
+                    scale: sheetScale,
+                    child: _ContextMenuSheet(
+                      key: _sheetGlobalKey,
+                      actions: _actions,
+                      contextMenuLocation: _contextMenuLocation,
+                      orientation: orientation,
+                    ),
+                  ),
+                ),
+              ),
+              Positioned.fromRect(
+                key: _childGlobalKey,
+                rect: rect,
+                child: _builder(context, animation),
+              ),
+            ],
+          );
+        }
+
+        // When the animation is done, just render everything in a static layout
+        // in the final position.
+        return _ContextMenuRouteStatic(
+          actions: _actions,
+          child: _builder(context, animation),
+          childGlobalKey: _childGlobalKey,
+          contextMenuLocation: _contextMenuLocation,
+          onDismiss: _onDismiss,
+          orientation: orientation,
+          sheetGlobalKey: _sheetGlobalKey,
+        );
+      },
+    );
+  }
+}
+
+// The final state of the _ContextMenuRoute after animating in and before
+// animating out.
+class _ContextMenuRouteStatic extends StatefulWidget {
+  const _ContextMenuRouteStatic({
+    Key key,
+    this.actions,
+    @required this.child,
+    this.childGlobalKey,
+    @required this.contextMenuLocation,
+    this.onDismiss,
+    @required this.orientation,
+    this.sheetGlobalKey,
+  }) : assert(contextMenuLocation != null),
+       assert(orientation != null),
+       super(key: key);
+
+  final List<Widget> actions;
+  final Widget child;
+  final GlobalKey childGlobalKey;
+  final _ContextMenuLocation contextMenuLocation;
+  final _DismissCallback onDismiss;
+  final Orientation orientation;
+  final GlobalKey sheetGlobalKey;
+
+  @override
+  _ContextMenuRouteStaticState createState() => _ContextMenuRouteStaticState();
+}
+
+class _ContextMenuRouteStaticState extends State<_ContextMenuRouteStatic> with TickerProviderStateMixin {
+  // The child is scaled down as it is dragged down until it hits this minimum
+  // value.
+  static const double _kMinScale = 0.8;
+  // The CupertinoContextMenuSheet disappears at this scale.
+  static const double _kSheetScaleThreshold = 0.9;
+  static const double _kPadding = 20.0;
+  static const double _kDamping = 400.0;
+  static const Duration _kMoveControllerDuration = Duration(milliseconds: 600);
+
+  final GlobalKey _childGlobalKey = GlobalKey();
+
+  Offset _dragOffset;
+  double _lastScale = 1.0;
+  AnimationController _moveController;
+  AnimationController _sheetController;
+  Animation<Offset> _moveAnimation;
+  Animation<double> _sheetScaleAnimation;
+  Animation<double> _sheetOpacityAnimation;
+
+  // The scale of the child changes as a function of the distance it is dragged.
+  static double _getScale(Orientation orientation, double maxDragDistance, double dy) {
+    final double dyDirectional = dy <= 0.0 ? dy : -dy;
+    return math.max(
+      _kMinScale,
+      (maxDragDistance + dyDirectional) / maxDragDistance,
+    );
+  }
+
+  void _onPanStart(DragStartDetails details) {
+    _moveController.value = 1.0;
+    _setDragOffset(Offset.zero);
+  }
+
+  void _onPanUpdate(DragUpdateDetails details) {
+    _setDragOffset(_dragOffset + details.delta);
+  }
+
+  void _onPanEnd(DragEndDetails details) {
+    // If flung, animate a bit before handling the potential dismiss.
+    if (details.velocity.pixelsPerSecond.dy.abs() >= kMinFlingVelocity) {
+      final bool flingIsAway = details.velocity.pixelsPerSecond.dy > 0;
+      final double finalPosition = flingIsAway
+        ? _moveAnimation.value.dy + 100.0
+        : 0.0;
+
+      if (flingIsAway && _sheetController.status != AnimationStatus.forward) {
+        _sheetController.forward();
+      } else if (!flingIsAway && _sheetController.status != AnimationStatus.reverse) {
+        _sheetController.reverse();
+      }
+
+      _moveAnimation = Tween<Offset>(
+        begin: Offset(0.0, _moveAnimation.value.dy),
+        end: Offset(0.0, finalPosition),
+      ).animate(_moveController);
+      _moveController.reset();
+      _moveController.duration = const Duration(
+        milliseconds: 64,
+      );
+      _moveController.forward();
+      _moveController.addStatusListener(_flingStatusListener);
+      return;
+    }
+
+    // Dismiss if the drag is enough to scale down all the way.
+    if (_lastScale == _kMinScale) {
+      widget.onDismiss(context, _lastScale, _sheetOpacityAnimation.value);
+      return;
+    }
+
+    // Otherwise animate back home.
+    _moveController.addListener(_moveListener);
+    _moveController.reverse();
+  }
+
+  void _moveListener() {
+    // When the scale passes the threshold, animate the sheet back in.
+    if (_lastScale > _kSheetScaleThreshold) {
+      _moveController.removeListener(_moveListener);
+      if (_sheetController.status != AnimationStatus.dismissed) {
+        _sheetController.reverse();
+      }
+    }
+  }
+
+  void _flingStatusListener(AnimationStatus status) {
+    if (status != AnimationStatus.completed) {
+      return;
+    }
+
+    // Reset the duration back to its original value.
+    _moveController.duration = _kMoveControllerDuration;
+
+    _moveController.removeStatusListener(_flingStatusListener);
+    // If it was a fling back to the start, it has reset itself, and it should
+    // not be dismissed.
+    if (_moveAnimation.value.dy == 0.0) {
+      return;
+    }
+    widget.onDismiss(context, _lastScale, _sheetOpacityAnimation.value);
+  }
+
+  Alignment _getChildAlignment(Orientation orientation, _ContextMenuLocation contextMenuLocation) {
+    switch (contextMenuLocation) {
+      case _ContextMenuLocation.center:
+        return orientation == Orientation.portrait
+          ? Alignment.bottomCenter
+          : Alignment.topRight;
+      case _ContextMenuLocation.right:
+        return orientation == Orientation.portrait
+          ? Alignment.bottomCenter
+          : Alignment.topLeft;
+      default:
+        return orientation == Orientation.portrait
+          ? Alignment.bottomCenter
+          : Alignment.topRight;
+    }
+  }
+
+  void _setDragOffset(Offset dragOffset) {
+    // Allow horizontal and negative vertical movement, but damp it.
+    final double endX = _kPadding * dragOffset.dx / _kDamping;
+    final double endY = dragOffset.dy >= 0.0
+      ? dragOffset.dy
+      : _kPadding * dragOffset.dy / _kDamping;
+    setState(() {
+      _dragOffset = dragOffset;
+      _moveAnimation = Tween<Offset>(
+        begin: Offset.zero,
+        end: Offset(
+          endX.clamp(-_kPadding, _kPadding),
+          endY,
+        ),
+      ).animate(
+        CurvedAnimation(
+          parent: _moveController,
+          curve: Curves.elasticIn,
+        ),
+      );
+
+      // Fade the _ContextMenuSheet out or in, if needed.
+      if (_lastScale <= _kSheetScaleThreshold
+          && _sheetController.status != AnimationStatus.forward
+          && _sheetScaleAnimation.value != 0.0) {
+        _sheetController.forward();
+      } else if (_lastScale > _kSheetScaleThreshold
+          && _sheetController.status != AnimationStatus.reverse
+          && _sheetScaleAnimation.value != 1.0) {
+        _sheetController.reverse();
+      }
+    });
+  }
+
+  // The order and alignment of the _ContextMenuSheet and the child depend on
+  // both the orientation of the screen as well as the position on the screen of
+  // the original child.
+  List<Widget> _getChildren(Orientation orientation, _ContextMenuLocation contextMenuLocation) {
+    final Expanded child = Expanded(
+      child: Align(
+        alignment: _getChildAlignment(
+          widget.orientation,
+          widget.contextMenuLocation,
+        ),
+        child: AnimatedBuilder(
+          animation: _moveController,
+          builder: _buildChildAnimation,
+          child: widget.child,
+        ),
+      ),
+    );
+    final Container spacer = Container(
+      width: _kPadding,
+      height: _kPadding,
+    );
+    final Expanded sheet = Expanded(
+      child: AnimatedBuilder(
+        animation: _sheetController,
+        builder: _buildSheetAnimation,
+        child: _ContextMenuSheet(
+          key: widget.sheetGlobalKey,
+          actions: widget.actions,
+          contextMenuLocation: widget.contextMenuLocation,
+          orientation: widget.orientation,
+        ),
+      ),
+    );
+
+    switch (contextMenuLocation) {
+      case _ContextMenuLocation.center:
+        return <Widget>[child, spacer, sheet];
+      case _ContextMenuLocation.right:
+        return orientation == Orientation.portrait
+          ? <Widget>[child, spacer, sheet]
+          : <Widget>[sheet, spacer, child];
+      default:
+        return <Widget>[child, spacer, sheet];
+    }
+  }
+
+  // Build the animation for the _ContextMenuSheet.
+  Widget _buildSheetAnimation(BuildContext context, Widget child) {
+    return Transform.scale(
+      alignment: _ContextMenuRoute.getSheetAlignment(widget.contextMenuLocation),
+      scale: _sheetScaleAnimation.value,
+      child: Opacity(
+        opacity: _sheetOpacityAnimation.value,
+        child: child,
+      ),
+    );
+  }
+
+  // Build the animation for the child.
+  Widget _buildChildAnimation(BuildContext context, Widget child) {
+    _lastScale = _getScale(
+      widget.orientation,
+      MediaQuery.of(context).size.height,
+      _moveAnimation.value.dy,
+    );
+    return Transform.scale(
+      key: widget.childGlobalKey,
+      scale: _lastScale,
+      child: child,
+    );
+  }
+
+  // Build the animation for the overall draggable dismissable content.
+  Widget _buildAnimation(BuildContext context, Widget child) {
+    return Transform.translate(
+      offset: _moveAnimation.value,
+      child: child,
+    );
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    _moveController = AnimationController(
+      duration: _kMoveControllerDuration,
+      value: 1.0,
+      vsync: this,
+    );
+    _sheetController = AnimationController(
+      duration: const Duration(milliseconds: 100),
+      reverseDuration: const Duration(milliseconds: 300),
+      vsync: this,
+    );
+    _sheetScaleAnimation = Tween<double>(
+      begin: 1.0,
+      end: 0.0,
+    ).animate(
+      CurvedAnimation(
+        parent: _sheetController,
+        curve: Curves.linear,
+        reverseCurve: Curves.easeInBack,
+      ),
+    );
+    _sheetOpacityAnimation = Tween<double>(
+      begin: 1.0,
+      end: 0.0,
+    ).animate(_sheetController);
+    _setDragOffset(Offset.zero);
+  }
+
+  @override
+  void dispose() {
+    _moveController.dispose();
+    _sheetController.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final List<Widget> children = _getChildren(
+      widget.orientation,
+      widget.contextMenuLocation,
+    );
+
+    return SafeArea(
+      child: Padding(
+        padding: const EdgeInsets.all(_kPadding),
+        child: Align(
+          alignment: Alignment.topLeft,
+          child: GestureDetector(
+            onPanEnd: _onPanEnd,
+            onPanStart: _onPanStart,
+            onPanUpdate: _onPanUpdate,
+            child: AnimatedBuilder(
+              animation: _moveController,
+              builder: _buildAnimation,
+              child: widget.orientation == Orientation.portrait
+                ? Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: children,
+                )
+                : Row(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: children,
+                ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+// The menu that displays when CupertinoContextMenu is open. It consists of a
+// list of actions that are typically CupertinoContextMenuActions.
+class _ContextMenuSheet extends StatelessWidget {
+  _ContextMenuSheet({
+    Key key,
+    @required this.actions,
+    @required _ContextMenuLocation contextMenuLocation,
+    @required Orientation orientation,
+  }) : assert(actions != null && actions.isNotEmpty),
+       assert(contextMenuLocation != null),
+       assert(orientation != null),
+       _contextMenuLocation = contextMenuLocation,
+       _orientation = orientation,
+       super(key: key);
+
+  final List<Widget> actions;
+  final _ContextMenuLocation _contextMenuLocation;
+  final Orientation _orientation;
+
+  // Get the children, whose order depends on orientation and
+  // contextMenuLocation.
+  List<Widget> get children {
+    final Flexible menu = Flexible(
+      fit: FlexFit.tight,
+      flex: 2,
+      child: IntrinsicHeight(
+        child: ClipRRect(
+          borderRadius: BorderRadius.circular(13.0),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.stretch,
+            children: actions,
+          ),
+        ),
+      ),
+    );
+
+    switch (_contextMenuLocation) {
+      case _ContextMenuLocation.center:
+        return _orientation == Orientation.portrait
+          ? <Widget>[
+            const Spacer(
+              flex: 1,
+            ),
+            menu,
+            const Spacer(
+              flex: 1,
+            ),
+          ]
+        : <Widget>[
+            menu,
+            const Spacer(
+              flex: 1,
+            ),
+          ];
+      case _ContextMenuLocation.right:
+        return <Widget>[
+          const Spacer(
+            flex: 1,
+          ),
+          menu,
+        ];
+      default:
+        return <Widget>[
+          menu,
+          const Spacer(
+            flex: 1,
+          ),
+        ];
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: children,
+    );
+  }
+}
+
+// An animation that switches between two colors.
+//
+// The transition is immediate, so there are no intermediate values or
+// interpolation. The color switches from offColor to onColor and back to
+// offColor at the times given by intervalOn and intervalOff.
+class _OnOffAnimation<T> extends CompoundAnimation<T> {
+  _OnOffAnimation({
+    AnimationController controller,
+    @required T onValue,
+    @required T offValue,
+    @required double intervalOn,
+    @required double intervalOff,
+  }) : _offValue = offValue,
+       assert(intervalOn >= 0.0 && intervalOn <= 1.0),
+       assert(intervalOff >= 0.0 && intervalOff <= 1.0),
+       assert(intervalOn <= intervalOff),
+       super(
+        first: Tween<T>(begin: offValue, end: onValue).animate(
+          CurvedAnimation(
+            parent: controller,
+            curve: Interval(intervalOn, intervalOn),
+          ),
+        ),
+        next: Tween<T>(begin: onValue, end: offValue).animate(
+          CurvedAnimation(
+            parent: controller,
+            curve: Interval(intervalOff, intervalOff),
+          ),
+        ),
+       );
+
+  final T _offValue;
+
+  @override
+  T get value => next.value == _offValue ? next.value : first.value;
+}
diff --git a/packages/flutter/lib/src/cupertino/context_menu_action.dart b/packages/flutter/lib/src/cupertino/context_menu_action.dart
new file mode 100644
index 0000000..b70ddaf
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/context_menu_action.dart
@@ -0,0 +1,148 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+import 'colors.dart';
+
+/// A button in a _ContextMenuSheet.
+///
+/// A typical use case is to pass a [Text] as the [child] here, but be sure to
+/// use [TextOverflow.ellipsis] for the [Text.overflow] field if the text may be
+/// long, as without it the text will wrap to the next line.
+class CupertinoContextMenuAction extends StatefulWidget {
+  /// Construct a CupertinoContextMenuAction.
+  const CupertinoContextMenuAction({
+    Key key,
+    @required this.child,
+    this.isDefaultAction = false,
+    this.isDestructiveAction = false,
+    this.onPressed,
+    this.trailingIcon,
+  }) : assert(child != null),
+       assert(isDefaultAction != null),
+       assert(isDestructiveAction != null),
+       super(key: key);
+
+  /// The widget that will be placed inside the action.
+  final Widget child;
+
+  /// Indicates whether this action should receive the style of an emphasized,
+  /// default action.
+  final bool isDefaultAction;
+
+  /// Indicates whether this action should receive the style of a destructive
+  /// action.
+  final bool isDestructiveAction;
+
+  /// Called when the action is pressed.
+  final VoidCallback onPressed;
+
+  /// An optional icon to display to the right of the child.
+  ///
+  /// Will be colored in the same way as the [TextStyle] used for [child] (for
+  /// example, if using [isDestructiveAction]).
+  final IconData trailingIcon;
+
+  @override
+  _CupertinoContextMenuActionState createState() => _CupertinoContextMenuActionState();
+}
+
+class _CupertinoContextMenuActionState extends State<CupertinoContextMenuAction> {
+  static const Color _kBackgroundColor = Color(0xFFEEEEEE);
+  static const Color _kBackgroundColorPressed = Color(0xFFDDDDDD);
+  static const double _kButtonHeight = 56.0;
+  static const TextStyle _kActionSheetActionStyle = TextStyle(
+    fontFamily: '.SF UI Text',
+    inherit: false,
+    fontSize: 20.0,
+    fontWeight: FontWeight.w400,
+    color: CupertinoColors.black,
+    textBaseline: TextBaseline.alphabetic,
+  );
+
+  final GlobalKey _globalKey = GlobalKey();
+  bool _isPressed = false;
+
+  void onTapDown(TapDownDetails details) {
+    setState(() {
+      _isPressed = true;
+    });
+  }
+
+  void onTapUp(TapUpDetails details) {
+    setState(() {
+      _isPressed = false;
+    });
+  }
+
+  void onTapCancel() {
+    setState(() {
+      _isPressed = false;
+    });
+  }
+
+  TextStyle get _textStyle {
+    if (widget.isDefaultAction) {
+      return _kActionSheetActionStyle.copyWith(
+        fontWeight: FontWeight.w600,
+      );
+    }
+    if (widget.isDestructiveAction) {
+      return _kActionSheetActionStyle.copyWith(
+        color: CupertinoColors.destructiveRed,
+      );
+    }
+    return _kActionSheetActionStyle;
+  }
+
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      key: _globalKey,
+      onTapDown: onTapDown,
+      onTapUp: onTapUp,
+      onTapCancel: onTapCancel,
+      onTap: widget.onPressed,
+      behavior: HitTestBehavior.opaque,
+      child: ConstrainedBox(
+        constraints: const BoxConstraints(
+          minHeight: _kButtonHeight,
+        ),
+        child: Semantics(
+          button: true,
+          child: Container(
+            decoration: BoxDecoration(
+              color: _isPressed ? _kBackgroundColorPressed : _kBackgroundColor,
+              border: const Border(
+                bottom: BorderSide(width: 1.0, color: _kBackgroundColorPressed),
+              ),
+            ),
+            padding: const EdgeInsets.symmetric(
+              vertical: 16.0,
+              horizontal: 10.0,
+            ),
+            child: DefaultTextStyle(
+              style: _textStyle,
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: <Widget>[
+                  Flexible(
+                    child: widget.child,
+                  ),
+                  if (widget.trailingIcon != null)
+                    Icon(
+                      widget.trailingIcon,
+                      color: CupertinoColors.destructiveRed,
+                    ),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart
index 19e3c95..cde64c8 100644
--- a/packages/flutter/lib/src/cupertino/route.dart
+++ b/packages/flutter/lib/src/cupertino/route.dart
@@ -4,7 +4,7 @@
 
 import 'dart:async';
 import 'dart:math';
-import 'dart:ui' show lerpDouble;
+import 'dart:ui' show lerpDouble, ImageFilter;
 
 import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
@@ -794,11 +794,15 @@
 
 class _CupertinoModalPopupRoute<T> extends PopupRoute<T> {
   _CupertinoModalPopupRoute({
-    this.builder,
-    this.barrierLabel,
     this.barrierColor,
+    this.barrierLabel,
+    this.builder,
+    ImageFilter filter,
     RouteSettings settings,
-  }) : super(settings: settings);
+  }) : super(
+         filter: filter,
+         settings: settings,
+       );
 
   final WidgetBuilder builder;
 
@@ -890,14 +894,16 @@
 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>(
-      builder: builder,
-      barrierLabel: 'Dismiss',
       barrierColor: CupertinoDynamicColor.resolve(_kModalBarrierColor, context),
+      barrierLabel: 'Dismiss',
+      builder: builder,
+      filter: filter,
     ),
   );
 }
diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart
index 1d2f04b..4c01364 100644
--- a/packages/flutter/lib/src/widgets/routes.dart
+++ b/packages/flutter/lib/src/widgets/routes.dart
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:ui' as ui;
 
 import 'package:flutter/foundation.dart';
 
@@ -735,7 +736,15 @@
   /// Creates a route that blocks interaction with previous routes.
   ModalRoute({
     RouteSettings settings,
-  }) : super(settings: settings);
+    ui.ImageFilter filter,
+  }) : _filter = filter,
+       super(settings: settings);
+
+  /// The filter to add to the barrier.
+  ///
+  /// If given, this filter will be applied to the modal barrier using
+  /// [BackdropFilter]. This allows blur effects, for example.
+  final ui.ImageFilter _filter;
 
   // The API for general users of this class
 
@@ -1286,6 +1295,12 @@
         barrierSemanticsDismissible: semanticsDismissible,
       );
     }
+    if (_filter != null) {
+      barrier = BackdropFilter(
+        filter: _filter,
+        child: barrier,
+      );
+    }
     return IgnorePointer(
       ignoring: animation.status == AnimationStatus.reverse || // changedInternalState is called when this updates
                 animation.status == AnimationStatus.dismissed, // dismissed is possible when doing a manual pop gesture
@@ -1321,7 +1336,11 @@
   /// Initializes the [PopupRoute].
   PopupRoute({
     RouteSettings settings,
-  }) : super(settings: settings);
+    ui.ImageFilter filter,
+  }) : super(
+         filter: filter,
+         settings: settings,
+       );
 
   @override
   bool get opaque => false;
diff --git a/packages/flutter/test/cupertino/context_menu_action_test.dart b/packages/flutter/test/cupertino/context_menu_action_test.dart
new file mode 100644
index 0000000..5fe243b
--- /dev/null
+++ b/packages/flutter/test/cupertino/context_menu_action_test.dart
@@ -0,0 +1,64 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/cupertino.dart';
+
+void main() {
+  // Constants taken from _ContextMenuActionState.
+  const Color _kBackgroundColor = Color(0xFFEEEEEE);
+  const Color _kBackgroundColorPressed = Color(0xFFDDDDDD);
+
+  Widget _getApp([VoidCallback onPressed]) {
+    final UniqueKey actionKey = UniqueKey();
+    final CupertinoContextMenuAction action = CupertinoContextMenuAction(
+      key: actionKey,
+      child: const Text('I am a CupertinoContextMenuAction'),
+      onPressed: onPressed,
+    );
+
+    return CupertinoApp(
+      home: CupertinoPageScaffold(
+        child: Center(
+          child: action,
+        ),
+      ),
+    );
+  }
+
+  BoxDecoration _getDecoration(WidgetTester tester) {
+    final Finder finder = find.descendant(
+      of: find.byType(CupertinoContextMenuAction),
+      matching: find.byType(Container),
+    );
+    expect(finder, findsOneWidget);
+    final Container container = tester.widget(finder);
+    return container.decoration;
+  }
+
+  testWidgets('responds to taps', (WidgetTester tester) async {
+    bool wasPressed = false;
+    await tester.pumpWidget(_getApp(() {
+      wasPressed = true;
+    }));
+
+    expect(wasPressed, false);
+    await tester.tap(find.byType(CupertinoContextMenuAction));
+    expect(wasPressed, true);
+  });
+
+  testWidgets('turns grey when pressed and held', (WidgetTester tester) async {
+    await tester.pumpWidget(_getApp());
+    expect(_getDecoration(tester).color, _kBackgroundColor);
+
+    final Offset actionCenter = tester.getCenter(find.byType(CupertinoContextMenuAction));
+    final TestGesture gesture = await tester.startGesture(actionCenter);
+    await tester.pump();
+    expect(_getDecoration(tester).color, _kBackgroundColorPressed);
+
+    await gesture.up();
+    await tester.pump();
+    expect(_getDecoration(tester).color, _kBackgroundColor);
+  });
+}
diff --git a/packages/flutter/test/cupertino/context_menu_test.dart b/packages/flutter/test/cupertino/context_menu_test.dart
new file mode 100644
index 0000000..a542756
--- /dev/null
+++ b/packages/flutter/test/cupertino/context_menu_test.dart
@@ -0,0 +1,366 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/cupertino.dart';
+
+void main() {
+  final TestWidgetsFlutterBinding binding =
+    TestWidgetsFlutterBinding.ensureInitialized();
+  const double _kOpenScale = 1.1;
+
+  Widget _getChild() {
+    return Container(
+      width: 300.0,
+      height: 100.0,
+      color: CupertinoColors.activeOrange,
+    );
+  }
+
+  Widget _getContextMenu({
+    Alignment alignment = Alignment.center,
+    Size screenSize = const Size(800.0, 600.0),
+    Widget child,
+  }) {
+    return CupertinoApp(
+      home: CupertinoPageScaffold(
+        child: MediaQuery(
+          data: MediaQueryData(size: screenSize),
+          child: Align(
+            alignment: alignment,
+            child: CupertinoContextMenu(
+              actions: <CupertinoContextMenuAction>[
+                CupertinoContextMenuAction(
+                  child: Text('CupertinoContextMenuAction $alignment'),
+                ),
+              ],
+              child: child ?? _getChild(),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  // Finds the child widget that is rendered inside of _DecoyChild.
+  Finder _findDecoyChild(Widget child) {
+    return find.descendant(
+      of: find.byType(ShaderMask),
+      matching: find.byWidget(child),
+    );
+  }
+
+  // Finds the child widget rendered inside of _ContextMenuRouteStatic.
+  Finder _findStatic() {
+    return find.descendant(
+      of: find.byType(CupertinoApp),
+      matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_ContextMenuRouteStatic'),
+    );
+  }
+
+  Finder _findStaticChild(Widget child) {
+    return find.descendant(
+      of: _findStatic(),
+      matching: find.byWidget(child),
+    );
+  }
+
+  group('CupertinoContextMenu before and during opening', () {
+    testWidgets('An unopened CupertinoContextMenu renders child in the same place as without', (WidgetTester tester) async {
+      // Measure the child in the scene with no CupertinoContextMenu.
+      final Widget child = _getChild();
+      await tester.pumpWidget(
+        CupertinoApp(
+          home: CupertinoPageScaffold(
+            child: Center(
+              child: child,
+            ),
+          ),
+        ),
+      );
+      final Rect childRect = tester.getRect(find.byWidget(child));
+
+      // When wrapped in a CupertinoContextMenu, the child is rendered in the same Rect.
+      await tester.pumpWidget(_getContextMenu(child: child));
+      expect(find.byWidget(child), findsOneWidget);
+      expect(tester.getRect(find.byWidget(child)), childRect);
+    });
+
+    testWidgets('Can open CupertinoContextMenu by tap and hold', (WidgetTester tester) async {
+      final Widget child = _getChild();
+      await tester.pumpWidget(_getContextMenu(child: child));
+      expect(find.byWidget(child), findsOneWidget);
+      final Rect childRect = tester.getRect(find.byWidget(child));
+      expect(find.byType(ShaderMask), findsNothing);
+
+      // Start a press on the child.
+      final TestGesture gesture = await tester.startGesture(childRect.center);
+      await tester.pump();
+
+      // The _DecoyChild is showing directly on top of the child.
+      expect(_findDecoyChild(child), findsOneWidget);
+      Rect decoyChildRect = tester.getRect(_findDecoyChild(child));
+      expect(childRect, equals(decoyChildRect));
+
+      // After a small delay, the _DecoyChild has begun to animate.
+      await tester.pump(const Duration(milliseconds: 100));
+      decoyChildRect = tester.getRect(_findDecoyChild(child));
+      expect(childRect, isNot(equals(decoyChildRect)));
+
+      // Eventually the decoy fully scales by _kOpenSize.
+      await tester.pump(const Duration(milliseconds: 500));
+      decoyChildRect = tester.getRect(_findDecoyChild(child));
+      expect(childRect, isNot(equals(decoyChildRect)));
+      expect(decoyChildRect.width, childRect.width * _kOpenScale);
+
+      // Then the CupertinoContextMenu opens.
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsOneWidget);
+    }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44152
+  });
+
+  group('CupertinoContextMenu when open', () {
+    testWidgets('Can close CupertinoContextMenu by background tap', (WidgetTester tester) async {
+      final Widget child = _getChild();
+      await tester.pumpWidget(_getContextMenu(child: child));
+
+      // Open the CupertinoContextMenu
+      final Rect childRect = tester.getRect(find.byWidget(child));
+      final TestGesture gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsOneWidget);
+
+      // Tap and ensure that the CupertinoContextMenu is closed.
+      await tester.tapAt(const Offset(1.0, 1.0));
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsNothing);
+    }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44152
+
+    testWidgets('Can close CupertinoContextMenu by dragging down', (WidgetTester tester) async {
+      final Widget child = _getChild();
+      await tester.pumpWidget(_getContextMenu(child: child));
+
+      // Open the CupertinoContextMenu
+      final Rect childRect = tester.getRect(find.byWidget(child));
+      final TestGesture gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsOneWidget);
+
+      // Drag down not far enough and it bounces back and doesn't close.
+      expect(_findStaticChild(child), findsOneWidget);
+      Offset staticChildCenter = tester.getCenter(_findStaticChild(child));
+      TestGesture swipeGesture = await tester.startGesture(staticChildCenter);
+      await swipeGesture.moveBy(
+        const Offset(0.0, 100.0),
+        timeStamp: const Duration(milliseconds: 100),
+      );
+      await tester.pump();
+      await swipeGesture.up();
+      await tester.pump();
+      expect(tester.getCenter(_findStaticChild(child)).dy, greaterThan(staticChildCenter.dy));
+      await tester.pumpAndSettle();
+      expect(tester.getCenter(_findStaticChild(child)), equals(staticChildCenter));
+      expect(_findStatic(), findsOneWidget);
+
+      // Drag down far enough and it does close.
+      expect(_findStaticChild(child), findsOneWidget);
+      staticChildCenter = tester.getCenter(_findStaticChild(child));
+      swipeGesture = await tester.startGesture(staticChildCenter);
+      await swipeGesture.moveBy(
+        const Offset(0.0, 200.0),
+        timeStamp: const Duration(milliseconds: 100),
+      );
+      await tester.pump();
+      await swipeGesture.up();
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsNothing);
+    }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44152
+
+    testWidgets('Can close CupertinoContextMenu by flinging down', (WidgetTester tester) async {
+      final Widget child = _getChild();
+      await tester.pumpWidget(_getContextMenu(child: child));
+
+      // Open the CupertinoContextMenu
+      final Rect childRect = tester.getRect(find.byWidget(child));
+      final TestGesture gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsOneWidget);
+
+      // Fling up and nothing happens.
+      expect(_findStaticChild(child), findsOneWidget);
+      await tester.fling(_findStaticChild(child), const Offset(0.0, -100.0), 1000.0);
+      await tester.pumpAndSettle();
+      expect(_findStaticChild(child), findsOneWidget);
+
+      // Fling down to close the menu.
+      expect(_findStaticChild(child), findsOneWidget);
+      await tester.fling(_findStaticChild(child), const Offset(0.0, 100.0), 1000.0);
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsNothing);
+    }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44152
+
+    testWidgets('Backdrop is added using ModalRoute\'s filter parameter', (WidgetTester tester) async {
+      final Widget child = _getChild();
+      await tester.pumpWidget(_getContextMenu(child: child));
+      expect(find.byType(BackdropFilter), findsNothing);
+
+      // Open the CupertinoContextMenu
+      final Rect childRect = tester.getRect(find.byWidget(child));
+      final TestGesture gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsOneWidget);
+      expect(find.byType(BackdropFilter), findsOneWidget);
+    }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44152
+  });
+
+  group('Open layout differs depending on child\'s position on screen', () {
+    testWidgets('Portrait', (WidgetTester tester) async {
+      const Size portraitScreenSize = Size(600.0, 800.0);
+      await binding.setSurfaceSize(portraitScreenSize);
+
+      // Pump a CupertinoContextMenu in the center of the screen and open it.
+      final Widget child = _getChild();
+      await tester.pumpWidget(_getContextMenu(
+        alignment: Alignment.center,
+        screenSize: portraitScreenSize,
+        child: child,
+      ));
+      expect(find.byType(CupertinoContextMenuAction), findsNothing);
+      Rect childRect = tester.getRect(find.byWidget(child));
+      TestGesture gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+
+      // The position of the action is in the center of the screen.
+      expect(find.byType(CupertinoContextMenuAction), findsOneWidget);
+      final Offset center = tester.getTopLeft(find.byType(CupertinoContextMenuAction));
+
+      // Close the CupertinoContextMenu.
+      await tester.tapAt(const Offset(1.0, 1.0));
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsNothing);
+
+      // Pump a CupertinoContextMenu on the left of the screen and open it.
+      await tester.pumpWidget(_getContextMenu(
+        alignment: Alignment.centerLeft,
+        screenSize: portraitScreenSize,
+        child: child,
+      ));
+      expect(find.byType(CupertinoContextMenuAction), findsNothing);
+      await tester.pumpAndSettle();
+      childRect = tester.getRect(find.byWidget(child));
+      gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+
+      // The position of the action is on the left of the screen.
+      expect(find.byType(CupertinoContextMenuAction), findsOneWidget);
+      final Offset left = tester.getTopLeft(find.byType(CupertinoContextMenuAction));
+      expect(left.dx, lessThan(center.dx));
+
+      // Close the CupertinoContextMenu.
+      await tester.tapAt(const Offset(1.0, 1.0));
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsNothing);
+
+      // Pump a CupertinoContextMenu on the right of the screen and open it.
+      await tester.pumpWidget(_getContextMenu(
+        alignment: Alignment.centerRight,
+        screenSize: portraitScreenSize,
+        child: child,
+      ));
+      expect(find.byType(CupertinoContextMenuAction), findsNothing);
+      childRect = tester.getRect(find.byWidget(child));
+      gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+
+      // The position of the action is on the right of the screen.
+      expect(find.byType(CupertinoContextMenuAction), findsOneWidget);
+      final Offset right = tester.getTopLeft(find.byType(CupertinoContextMenuAction));
+      expect(right.dx, greaterThan(center.dx));
+
+      // Set the screen back to its normal size.
+      await binding.setSurfaceSize(const Size(800.0, 600.0));
+    }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44152
+
+    testWidgets('Landscape', (WidgetTester tester) async {
+      // Pump a CupertinoContextMenu in the center of the screen and open it.
+      final Widget child = _getChild();
+      await tester.pumpWidget(_getContextMenu(
+        alignment: Alignment.center,
+        child: child,
+      ));
+      expect(find.byType(CupertinoContextMenuAction), findsNothing);
+      Rect childRect = tester.getRect(find.byWidget(child));
+      TestGesture gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+
+      // Landscape doesn't support a centered action list, so the action is on
+      // the left side of the screen.
+      expect(find.byType(CupertinoContextMenuAction), findsOneWidget);
+      final Offset center = tester.getTopLeft(find.byType(CupertinoContextMenuAction));
+
+      // Close the CupertinoContextMenu.
+      await tester.tapAt(const Offset(1.0, 1.0));
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsNothing);
+
+      // Pump a CupertinoContextMenu on the left of the screen and open it.
+      await tester.pumpWidget(_getContextMenu(
+        alignment: Alignment.centerLeft,
+        child: child,
+      ));
+      expect(find.byType(CupertinoContextMenuAction), findsNothing);
+      childRect = tester.getRect(find.byWidget(child));
+      gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+
+      // The position of the action is on the right of the screen, which is the
+      // same as for center aligned children in landscape.
+      expect(find.byType(CupertinoContextMenuAction), findsOneWidget);
+      final Offset left = tester.getTopLeft(find.byType(CupertinoContextMenuAction));
+      expect(left.dx, equals(center.dx));
+
+      // Close the CupertinoContextMenu.
+      await tester.tapAt(const Offset(1.0, 1.0));
+      await tester.pumpAndSettle();
+      expect(_findStatic(), findsNothing);
+
+      // Pump a CupertinoContextMenu on the right of the screen and open it.
+      await tester.pumpWidget(_getContextMenu(
+        alignment: Alignment.centerRight,
+        child: child,
+      ));
+      expect(find.byType(CupertinoContextMenuAction), findsNothing);
+      childRect = tester.getRect(find.byWidget(child));
+      gesture = await tester.startGesture(childRect.center);
+      await tester.pumpAndSettle();
+      await gesture.up();
+      await tester.pumpAndSettle();
+
+      // The position of the action is on the left of the screen.
+      expect(find.byType(CupertinoContextMenuAction), findsOneWidget);
+      final Offset right = tester.getTopLeft(find.byType(CupertinoContextMenuAction));
+      expect(right.dx, lessThan(left.dx));
+    }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44152
+  });
+}