| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:math' as math; |
| import 'dart:ui' as ui; |
| import 'package:flutter/gestures.dart' show kMinFlingVelocity, kLongPressTimeout; |
| import 'package:flutter/foundation.dart' show kIsWeb; |
| 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() as RenderBox; |
| 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 imports |
| /// import 'package:flutter/cupertino.dart'; |
| /// ``` |
| /// |
| /// ```dart |
| /// Widget build(BuildContext context) { |
| /// return Scaffold( |
| /// 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 snippet} |
| /// |
| /// 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 solvable by adding a widget to Scaffold that's |
| // underneath 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, |
| // TODO(justinmc): When ShaderMask is supported on web, remove this |
| // conditional and use ShaderMask everywhere. |
| // https://github.com/flutter/flutter/issues/52967. |
| child: kIsWeb |
| ? Container(key: _childGlobalKey, child: widget.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); |
| |
| 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) as double, |
| 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, |
| ), |
| ), |
| ); |
| const SizedBox spacer = SizedBox( |
| 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; |
| } |