// 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/cupertino.dart'; import 'package:flutter/gestures.dart' show kMinFlingVelocity; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; double valueFromPercentageInRange( {required final double min, max, percentage}) { return percentage * (max - min) + min; } double percentageFromValueInRange({required final double min, max, value}) { return (value - min) / (max - min); } const double _kOpenScale = 1.025; const Color _borderColor = CupertinoDynamicColor.withBrightness( color: Color(0xFFA9A9AF), darkColor: Color(0xFF57585A), ); typedef _DismissCallback = void Function( BuildContext context, double scale, double opacity, ); typedef ViewablePreviewBuilder = Widget Function( BuildContext context, Animation animation, Widget child, ); typedef _ViewablePreviewBuilderChildless = Widget Function( BuildContext context, Animation animation, ); 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; } enum _ViewableLocation { center, left, right, } class Viewable extends StatefulWidget { const Viewable({ super.key, required this.view, required this.tile, this.actions = const [], this.previewBuilder, }); final Widget tile; final Widget view; final List actions; final ViewablePreviewBuilder? previewBuilder; @override State createState() => _ViewableState(); } class _ViewableState extends State with TickerProviderStateMixin { final GlobalKey _childGlobalKey = GlobalKey(); bool _childHidden = false; late AnimationController _openController; Rect? _decoyChildEndRect; OverlayEntry? _lastOverlayEntry; _ViewableRoute? _route; @override void initState() { super.initState(); _openController = AnimationController( duration: const Duration(milliseconds: 100), vsync: this, ); _openController.addStatusListener(_onDecoyAnimationStatusChange); } _ViewableLocation 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 _ViewableLocation.center; } if (childRect.center.dx > center) { return _ViewableLocation.right; } return _ViewableLocation.left; } void _openContextMenu() { setState(() { _childHidden = true; }); _route = _ViewableRoute( actions: widget.actions, barrierLabel: 'Dismiss', filter: ui.ImageFilter.blur( sigmaX: 5.0, sigmaY: 5.0, ), contextMenuLocation: _contextMenuLocation, previousChildRect: _decoyChildEndRect!, builder: (BuildContext context, Animation animation) { return ClipRRect( borderRadius: BorderRadius.circular(16.0), child: Material( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16.0), child: Stack( children: [ Opacity( opacity: animation.status == AnimationStatus.forward ? Curves.easeOutCirc.transform(animation.value) : Curves.easeInCirc.transform(animation.value), child: widget.view, ), Opacity( opacity: 1 - (animation.status == AnimationStatus.forward ? Curves.easeOutCirc.transform(animation.value) : Curves.easeInCirc.transform(animation.value)), child: widget.tile, ), ], ), ), ); }, ); Navigator.of(context, rootNavigator: true).push(_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(); SchedulerBinding.instance.addPostFrameCallback((Duration _) { _lastOverlayEntry?.remove(); _lastOverlayEntry = null; _openController.reset(); }); break; case AnimationStatus.forward: case AnimationStatus.reverse: return; } } void _routeAnimationStatusListener(AnimationStatus status) { if (status != AnimationStatus.dismissed) { return; } setState(() { _childHidden = false; }); _route!.animation!.removeStatusListener(_routeAnimationStatusListener); _route = null; } void _onTap() { _onTapDown(TapDownDetails(), anim: false); } void _onTapDown(TapDownDetails details, {anim = true}) { setState(() { _childHidden = true; }); final Rect childRect = _getRect(_childGlobalKey); _decoyChildEndRect = Rect.fromCenter( center: childRect.center, width: childRect.width * _kOpenScale, height: childRect.height * _kOpenScale, ); _lastOverlayEntry = OverlayEntry( builder: (BuildContext context) { return _DecoyChild( beginRect: childRect, controller: _openController, endRect: _decoyChildEndRect, child: widget.tile, ); }, ); Overlay.of(context, rootOverlay: true).insert(_lastOverlayEntry!); _openController.forward(from: anim ? 0.0 : 1.0); } @override Widget build(BuildContext context) { return GestureDetector( onTap: _onTap, child: TickerMode( enabled: !_childHidden, child: Opacity( key: _childGlobalKey, opacity: _childHidden ? 0.0 : 1.0, child: widget.tile, ), ), ); } @override void dispose() { _openController.dispose(); super.dispose(); } } class _DecoyChild extends StatefulWidget { const _DecoyChild({ this.beginRect, required this.controller, this.endRect, this.child, }); final Rect? beginRect; final AnimationController controller; final Rect? endRect; final Widget? child; @override _DecoyChildState createState() => _DecoyChildState(); } class _DecoyChildState extends State<_DecoyChild> with TickerProviderStateMixin { static const Color _lightModeMaskColor = Color(0xFF888888); static const Color _masklessColor = Color(0xFFFFFFFF); final GlobalKey _childGlobalKey = GlobalKey(); late Animation _mask; late Animation _rect; @override void initState() { super.initState(); _mask = _OnOffAnimation( 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(>[ TweenSequenceItem( tween: RectTween( begin: widget.beginRect, end: midRect, ).chain(CurveTween(curve: Curves.easeInOutCubic)), weight: 1.0, ), TweenSequenceItem( tween: RectTween( begin: midRect, end: widget.endRect, ).chain(CurveTween(curve: Curves.easeOutCubic)), weight: 1.0, ), ]).animate(widget.controller); _rect.addListener(_rectListener); } 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], ).createShader(bounds); }, child: widget.child, ), ); } @override Widget build(BuildContext context) { return Stack( children: [ AnimatedBuilder( builder: _buildAnimation, animation: widget.controller, ), ], ); } } class _ViewableRoute extends PopupRoute { _ViewableRoute({ required List actions, required _ViewableLocation contextMenuLocation, this.barrierLabel, _ViewablePreviewBuilderChildless? builder, super.filter, required Rect previousChildRect, super.settings, }) : _actions = actions, _builder = builder, _contextMenuLocation = contextMenuLocation, _previousChildRect = previousChildRect; static const Color _kModalBarrierColor = Color(0x6604040F); static const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335); final List _actions; final _ViewablePreviewBuilderChildless? _builder; final GlobalKey _childGlobalKey = GlobalKey(); final _ViewableLocation _contextMenuLocation; bool _externalOffstage = false; bool _internalOffstage = false; Orientation? _lastOrientation; 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 _rectAnimatable = _rectTween.chain(_curve); static final RectTween _rectTweenReverse = RectTween(); static final Animatable _rectAnimatableReverse = _rectTweenReverse.chain( _curveReverse, ); static final RectTween _sheetRectTween = RectTween(); final Animatable _sheetRectAnimatable = _sheetRectTween.chain( _curve, ); final Animatable _sheetRectAnimatableReverse = _sheetRectTween.chain( _curveReverse, ); static final Tween _sheetScaleTween = Tween(); static final Animatable _sheetScaleAnimatable = _sheetScaleTween.chain( _curve, ); static final Animatable _sheetScaleAnimatableReverse = _sheetScaleTween.chain( _curveReverse, ); final Tween _opacityTween = Tween(begin: 0.0, end: 1.0); late Animation _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; 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; } static AlignmentDirectional getSheetAlignment( _ViewableLocation contextMenuLocation) { switch (contextMenuLocation) { case _ViewableLocation.center: return AlignmentDirectional.topCenter; case _ViewableLocation.right: return AlignmentDirectional.topEnd; case _ViewableLocation.left: return AlignmentDirectional.topStart; } } static Rect _getSheetRectBegin(Orientation? orientation, _ViewableLocation contextMenuLocation, Rect childRect, Rect sheetRect) { switch (contextMenuLocation) { case _ViewableLocation.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 _ViewableLocation.right: final Offset target = orientation == Orientation.portrait ? childRect.bottomRight : childRect.topRight; return (target - Offset(sheetRect.width, 0.0)) & sheetRect.size; case _ViewableLocation.left: 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(); } void _updateTweenRects() { final Rect childRect = _scale == null ? _getRect(_childGlobalKey) : _getScaledRect(_childGlobalKey, _scale!); _rectTween.begin = _previousChildRect; _rectTween.end = childRect; 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; 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(); SchedulerBinding.instance.addPostFrameCallback((Duration _) { _updateTweenRects(); _internalOffstage = false; _setOffstageInternally(); }); return super.didPush(); } @override Animation createAnimation() { final Animation animation = super.createAnimation(); _sheetOpacity = _opacityTween.animate(CurvedAnimation( parent: animation, curve: Curves.linear, )); return animation; } @override Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return Container(); } @override Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { return OrientationBuilder( builder: (BuildContext context, Orientation orientation) { _lastOrientation = orientation; 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: [ Positioned.fromRect( rect: sheetRect, child: FadeTransition( opacity: _sheetOpacity, child: Transform.scale( alignment: getSheetAlignment(_contextMenuLocation), scale: sheetScale, child: _ViewableSheet( key: _sheetGlobalKey, actions: _actions, ), ), ), ), Positioned.fromRect( key: _childGlobalKey, rect: rect, child: _builder!(context, animation), ), ], ); } return _ContextMenuRouteStatic( actions: _actions, childGlobalKey: _childGlobalKey, contextMenuLocation: _contextMenuLocation, onDismiss: _onDismiss, orientation: orientation, sheetGlobalKey: _sheetGlobalKey, child: _builder!(context, animation), ); }, ); } } class _ContextMenuRouteStatic extends StatefulWidget { const _ContextMenuRouteStatic({ this.actions, required this.child, this.childGlobalKey, required this.contextMenuLocation, this.onDismiss, required this.orientation, this.sheetGlobalKey, }); final List? actions; final Widget child; final GlobalKey? childGlobalKey; final _ViewableLocation contextMenuLocation; final _DismissCallback? onDismiss; final Orientation orientation; final GlobalKey? sheetGlobalKey; @override _ContextMenuRouteStaticState createState() => _ContextMenuRouteStaticState(); } class _ContextMenuRouteStaticState extends State<_ContextMenuRouteStatic> with TickerProviderStateMixin { static const double _kMinScale = 0.8; 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); late Offset _dragOffset; double _lastScale = 1.0; late AnimationController _moveController; late AnimationController _sheetController; late Animation _moveAnimation; late Animation _sheetScaleAnimation; late Animation _sheetOpacityAnimation; 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 (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( 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; } if (_lastScale == _kMinScale) { widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value); return; } _moveController.addListener(_moveListener); _moveController.reverse(); } void _moveListener() { if (_lastScale > _kSheetScaleThreshold) { _moveController.removeListener(_moveListener); if (_sheetController.status != AnimationStatus.dismissed) { _sheetController.reverse(); } } } void _flingStatusListener(AnimationStatus status) { if (status != AnimationStatus.completed) { return; } _moveController.duration = _kMoveControllerDuration; _moveController.removeStatusListener(_flingStatusListener); if (_moveAnimation.value.dy == 0.0) { return; } widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value); } Alignment _getChildAlignment( Orientation orientation, _ViewableLocation contextMenuLocation) { switch (contextMenuLocation) { case _ViewableLocation.center: return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topRight; case _ViewableLocation.right: return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topLeft; case _ViewableLocation.left: return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topRight; } } void _setDragOffset(Offset dragOffset) { 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( begin: Offset.zero, end: Offset( endX.clamp(-_kPadding, _kPadding), endY, ), ).animate( CurvedAnimation( parent: _moveController, curve: Curves.elasticIn, ), ); 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(); } }); } List _getChildren( Orientation orientation, _ViewableLocation 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 sheet = AnimatedBuilder( animation: _sheetController, builder: _buildSheetAnimation, child: _ViewableSheet( key: widget.sheetGlobalKey, actions: widget.actions!, ), ); switch (contextMenuLocation) { case _ViewableLocation.center: return [child, spacer, sheet]; case _ViewableLocation.right: return orientation == Orientation.portrait ? [child, spacer, sheet] : [sheet, spacer, child]; case _ViewableLocation.left: return [child, spacer, sheet]; } } Widget _buildSheetAnimation(BuildContext context, Widget? child) { return Transform.scale( alignment: _ViewableRoute.getSheetAlignment(widget.contextMenuLocation), scale: _sheetScaleAnimation.value, child: FadeTransition( opacity: _sheetOpacityAnimation, child: 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, ); } 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: 200), vsync: this, ); _sheetScaleAnimation = Tween( begin: 1.0, end: 0.0, ).animate( CurvedAnimation( parent: _sheetController, curve: Curves.linear, reverseCurve: Curves.easeInBack, ), ); _sheetOpacityAnimation = Tween( 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 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, ), ), ), ), ), ); } } class _ViewableSheet extends StatelessWidget { const _ViewableSheet({ super.key, required this.actions, }); final List actions; List getChildren(BuildContext context) { if (actions.isEmpty) return []; final Widget menu = Expanded( child: IntrinsicHeight( child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(13.0)), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ actions.first, for (Widget action in actions.skip(1)) DecoratedBox( decoration: BoxDecoration( border: Border( top: BorderSide( color: CupertinoDynamicColor.resolve(_borderColor, context), width: 0.5, )), ), position: DecorationPosition.foreground, child: action, ), ], ), ), ), ); return [menu]; } @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: getChildren(context), ); } } class _OnOffAnimation extends CompoundAnimation { _OnOffAnimation({ required 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(begin: offValue, end: onValue).animate( CurvedAnimation( parent: controller, curve: Interval(intervalOn, intervalOn), ), ), next: Tween(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; }