From 2ecc6ed6373480fb628e4af09fb1070efe7a6ac3 Mon Sep 17 00:00:00 2001 From: Mitchell Goodwin <58190796+MitchellGoodwin@users.noreply.github.com> Date: Wed, 30 Nov 2022 16:28:04 -0700 Subject: [PATCH] Update CupertinoContextMenu to iOS 16 visuals (#110616) * Update CupertinoContextMenu to iOS 16 visuals * Revert some formatting * Remove space * Remove formatting changes, add more comments * Added shadow effect * Update context menu tests * Remove white spaces * Remove unused variable * Refactor type checking logic * Set default previewBuilder and update tests * Check for border radius * Remove trailing spaces * Add builder to constructor * Update previewBuilder Rebase to master * Update builder and tests * Remove trailing spaces * Update examples * Refactor builder * Update builder to use one animation * Update scale * Change deprecation message, remove white spaces * Change deprecation message * Change deprecation message * Change deprecation message * Update documentation * Update documentation * Update documentation and examples * Update documentation and examples * Remove white spaces * Remove white spaces * Remove const * Address linting errors * Seperate builder into own constructor * Remove trailing characters * Formatting changes * Remove white spaces * Change ignore comment * Add TODO * Remove whitespace --- .../cupertino_context_menu.0.dart | 7 +- .../cupertino_context_menu.1.dart | 119 +++++ .../cupertino_context_menu.1_test.dart | 26 + .../lib/src/cupertino/context_menu.dart | 474 +++++++++++++----- .../test/cupertino/context_menu_test.dart | 167 +++++- 5 files changed, 652 insertions(+), 141 deletions(-) create mode 100644 examples/api/lib/cupertino/context_menu/cupertino_context_menu.1.dart create mode 100644 examples/api/test/cupertino/context_menu/cupertino_context_menu.1_test.dart diff --git a/examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart b/examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart index 53f0f1912d100..295adfb99b62d 100644 --- a/examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart +++ b/examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart @@ -49,7 +49,7 @@ class ContextMenuExample extends StatelessWidget { Navigator.pop(context); }, trailingIcon: CupertinoIcons.share, - child: const Text('Share '), + child: const Text('Share'), ), CupertinoContextMenuAction( onPressed: () { @@ -68,10 +68,7 @@ class ContextMenuExample extends StatelessWidget { ), ], child: Container( - decoration: BoxDecoration( - color: CupertinoColors.systemYellow, - borderRadius: BorderRadius.circular(20.0), - ), + color: CupertinoColors.systemYellow, child: const FlutterLogo(size: 500.0), ), ), diff --git a/examples/api/lib/cupertino/context_menu/cupertino_context_menu.1.dart b/examples/api/lib/cupertino/context_menu/cupertino_context_menu.1.dart new file mode 100644 index 0000000000000..e89901ae3b547 --- /dev/null +++ b/examples/api/lib/cupertino/context_menu/cupertino_context_menu.1.dart @@ -0,0 +1,119 @@ +// 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. + +/// Flutter code sample for [CupertinoContextMenu]. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +final DecorationTween _tween = DecorationTween( + begin: BoxDecoration( + color: CupertinoColors.systemYellow, + boxShadow: const [], + borderRadius: BorderRadius.circular(20.0), + ), + end: BoxDecoration( + color: CupertinoColors.systemYellow, + boxShadow: CupertinoContextMenu.kEndBoxShadow, + borderRadius: BorderRadius.circular(20.0), + ), +); + +void main() => runApp(const ContextMenuApp()); + +class ContextMenuApp extends StatelessWidget { + const ContextMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.light), + home: ContextMenuExample(), + ); + } +} + +class ContextMenuExample extends StatelessWidget { + const ContextMenuExample({super.key}); + + // Or just do this inline in the builder below? + static Animation _boxDecorationAnimation(Animation animation) { + return _tween.animate( + CurvedAnimation( + parent: animation, + curve: Interval( + 0.0, + CupertinoContextMenu.animationOpensAt, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoContextMenu Sample'), + ), + child: Center( + child: SizedBox( + width: 100, + height: 100, + child: CupertinoContextMenu.builder( + actions: [ + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + isDefaultAction: true, + trailingIcon: CupertinoIcons.doc_on_clipboard_fill, + child: const Text('Copy'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + trailingIcon: CupertinoIcons.share, + child: const Text('Share'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + trailingIcon: CupertinoIcons.heart, + child: const Text('Favorite'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + isDestructiveAction: true, + trailingIcon: CupertinoIcons.delete, + child: const Text('Delete'), + ), + ], + builder:(BuildContext context, Animation animation) { + final Animation boxDecorationAnimation = + _boxDecorationAnimation(animation); + + return Container( + decoration: + animation.value < CupertinoContextMenu.animationOpensAt + ? boxDecorationAnimation.value + : null, + child: Container( + decoration: BoxDecoration( + color: CupertinoColors.systemYellow, + borderRadius: BorderRadius.circular(20.0), + ), + child: const FlutterLogo(size: 500.0), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/examples/api/test/cupertino/context_menu/cupertino_context_menu.1_test.dart b/examples/api/test/cupertino/context_menu/cupertino_context_menu.1_test.dart new file mode 100644 index 0000000000000..540656c28aa9b --- /dev/null +++ b/examples/api/test/cupertino/context_menu/cupertino_context_menu.1_test.dart @@ -0,0 +1,26 @@ +// 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 'package:flutter/material.dart'; +import 'package:flutter_api_samples/cupertino/context_menu/cupertino_context_menu.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open cupertino context menu', (WidgetTester tester) async { + await tester.pumpWidget( + const example.ContextMenuApp(), + ); + + final Offset logo = tester.getCenter(find.byType(FlutterLogo)); + expect(find.text('Favorite'), findsNothing); + + await tester.startGesture(logo); + await tester.pumpAndSettle(); + expect(find.text('Favorite'), findsOneWidget); + + await tester.tap(find.text('Favorite')); + await tester.pumpAndSettle(); + expect(find.text('Favorite'), findsNothing); + }); +} diff --git a/packages/flutter/lib/src/cupertino/context_menu.dart b/packages/flutter/lib/src/cupertino/context_menu.dart index bd24142c1af30..87dafbcf7c5b6 100644 --- a/packages/flutter/lib/src/cupertino/context_menu.dart +++ b/packages/flutter/lib/src/cupertino/context_menu.dart @@ -6,16 +6,42 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show kLongPressTimeout, kMinFlingVelocity; +import 'package:flutter/gestures.dart' show kMinFlingVelocity; import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'colors.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; +const double _kOpenScale = 1.15; + +// The ratio for the borderRadius of the context menu preview image. This value +// was eyeballed by overlapping the CupertinoContextMenu with a context menu +// from iOS 16.0 in the XCode iPhone simulator. +const double _previewBorderRadiusRatio = 12.0; + +// The duration of the transition used when a modal popup is shown. Eyeballed +// from a physical device running iOS 13.1.2. +const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335); + +// The duration it takes for the CupertinoContextMenu to open. +// This value was eyeballed from the XCode simulator running iOS 16.0. +const Duration _previewLongPressTimeout = Duration(milliseconds: 800); + +// The total length of the combined animations until the menu is fully open. +final int _animationDuration = + _previewLongPressTimeout.inMilliseconds + _kModalPopupTransitionDuration.inMilliseconds; + +// The final box shadow for the opening child widget. +// This value was eyeballed from the XCode simulator running iOS 16.0. +const List _endBoxShadow = [ + BoxShadow( + color: Color(0x40000000), + blurRadius: 10.0, + spreadRadius: 0.5, + ), +]; const Color _borderColor = CupertinoDynamicColor.withBrightness( color: Color(0xFFA9A9AF), @@ -37,8 +63,9 @@ typedef ContextMenuPreviewBuilder = Widget Function( Widget child, ); -// A function that proxies to ContextMenuPreviewBuilder without the child. -typedef _ContextMenuPreviewBuilderChildless = Widget Function( +/// A function that builds the child and handles the transition between the +/// default child and the preview when the CupertinoContextMenu is open. +typedef CupertinoContextMenuBuilder = Widget Function( BuildContext context, Animation animation, ); @@ -84,12 +111,19 @@ enum _ContextMenuLocation { /// Photos app on iOS. /// /// {@tool dartpad} -/// This sample shows a very simple CupertinoContextMenu for an empty red -/// 100x100 Container. Simply long press on it to open. +/// This sample shows a very simple CupertinoContextMenu for the Flutter logo. +/// Simply long press on it to open. /// /// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This sample shows a similar CupertinoContextMenu, this time using [builder] +/// to add a border radius to the widget. +/// +/// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.1.dart ** +/// {@end-tool} +/// /// See also: /// /// * @@ -102,10 +136,239 @@ class CupertinoContextMenu extends StatefulWidget { CupertinoContextMenu({ super.key, required this.actions, - required this.child, - this.previewBuilder, + required Widget this.child, + @Deprecated( + 'Use CupertinoContextMenu.builder instead. ' + 'This feature was deprecated after v3.4.0-34.1.pre.', + ) + this.previewBuilder = _defaultPreviewBuilder, }) : assert(actions != null && actions.isNotEmpty), - assert(child != null); + assert(child != null), + builder = ((BuildContext context, Animation animation) => child); + + /// Creates a context menu with a custom [builder] controlling the widget. + /// + /// Use instead of the default constructor when it is needed to have a more + /// custom animation. + /// + /// [actions] is required and cannot be null or empty. + /// + /// [builder] is required. + CupertinoContextMenu.builder({ + super.key, + required this.actions, + required this.builder, + }) : assert(actions != null && actions.isNotEmpty), + child = null, + previewBuilder = null; + + /// Exposes the default border radius for matching iOS 16.0 behavior. This + /// value was eyeballed from the iOS simulator running iOS 16.0. + /// + /// {@tool snippet} + /// + /// Below is example code in order to match the default border radius for an + /// iOS 16.0 open preview. + /// + /// ```dart + /// CupertinoContextMenu.builder( + /// actions: [ + /// CupertinoContextMenuAction( + /// child: const Text('Action one'), + /// onPressed: () {}, + /// ), + /// ], + /// builder:(BuildContext context, Animation animation) { + /// final Animation borderRadiusAnimation = BorderRadiusTween( + /// begin: BorderRadius.circular(0.0), + /// end: BorderRadius.circular(CupertinoContextMenu.kOpenBorderRadius), + /// ).animate( + /// CurvedAnimation( + /// parent: animation, + /// curve: Interval( + /// CupertinoContextMenu.animationOpensAt, + /// 1.0, + /// ), + /// ), + /// ); + /// + /// final Animation boxDecorationAnimation = DecorationTween( + /// begin: const BoxDecoration( + /// color: Color(0xFFFFFFFF), + /// boxShadow: [], + /// ), + /// end: const BoxDecoration( + /// color: Color(0xFFFFFFFF), + /// boxShadow: CupertinoContextMenu.kEndBoxShadow, + /// ), + /// ).animate( + /// CurvedAnimation( + /// parent: animation, + /// curve: Interval( + /// 0.0, + /// CupertinoContextMenu.animationOpensAt, + /// ), + /// ) + /// ); + /// + /// return Container( + /// decoration: + /// animation.value < CupertinoContextMenu.animationOpensAt ? boxDecorationAnimation.value : null, + /// child: FittedBox( + /// fit: BoxFit.cover, + /// child: ClipRRect( + /// borderRadius: borderRadiusAnimation.value ?? BorderRadius.circular(0.0), + /// child: SizedBox( + /// height: 150, + /// width: 150, + /// child: Image.network('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg'), + /// ), + /// ), + /// ) + /// ); + /// }, + /// ) + /// ``` + /// + /// {@end-tool} + static const double kOpenBorderRadius = _previewBorderRadiusRatio; + + /// Exposes the final box shadow of the opening animation of the child widget + /// to match the default behavior of the native iOS widget. This value was + /// eyeballed from the iOS simulator running iOS 16.0. + static const List kEndBoxShadow = _endBoxShadow; + + /// The point at which the CupertinoContextMenu begins to animate + /// into the open position. + /// + /// A value between 0.0 and 1.0 corresponding to a point in [builder]'s + /// animation. When passing in an animation to [builder] the range before + /// [animationOpensAt] will correspond to the animation when the widget is + /// pressed and held, and the range after is the animation as the menu is + /// fully opening. For an example, see the documentation for [builder]. + static final double animationOpensAt = + _previewLongPressTimeout.inMilliseconds / _animationDuration; + + /// A function that returns a widget to be used alternatively from [child]. + /// + /// The widget returned by the function will be shown at all times: when the + /// [CupertinoContextMenu] is closed, when it is in the middle of opening, + /// and when it is fully open. This will overwrite the default animation that + /// matches the behavior of an iOS 16.0 context menu. + /// + /// This builder can be used instead of the child when either the intended + /// child has a property that would conflict with the default animation, like + /// a border radius or a shadow, or if simply a more custom animation is + /// needed. + /// + /// In addition to the current [BuildContext], the function is also called + /// with an [Animation]. The complete 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 widget in sync with this opening and closing. + /// + /// The animation works in two stages. The first happens on press and hold of + /// the widget from 0 to [animationOpensAt], and the second stage for when the + /// widget fully opens up to the menu, from [animationOpensAt] to 1. + /// + /// {@tool snippet} + /// + /// Below is an example of using [builder] to show an image tile setup to be + /// opened in the default way to match a native iOS 16.0 app. The behavior + /// will match what will happen if the simple child image was passed as just + /// the [child] parameter, instead of [builder]. This can be manipulated to + /// add more custamizability to the widget's animation. + /// + /// ```dart + /// CupertinoContextMenu.builder( + /// actions: [ + /// CupertinoContextMenuAction( + /// child: const Text('Action one'), + /// onPressed: () {}, + /// ), + /// ], + /// builder:(BuildContext context, Animation animation) { + /// final Animation borderRadiusAnimation = BorderRadiusTween( + /// begin: BorderRadius.circular(0.0), + /// end: BorderRadius.circular(CupertinoContextMenu.kOpenBorderRadius), + /// ).animate( + /// CurvedAnimation( + /// parent: animation, + /// curve: Interval( + /// CupertinoContextMenu.animationOpensAt, + /// 1.0, + /// ), + /// ), + /// ); + /// + /// final Animation boxDecorationAnimation = DecorationTween( + /// begin: const BoxDecoration( + /// color: Color(0xFFFFFFFF), + /// boxShadow: [], + /// ), + /// end: const BoxDecoration( + /// color: Color(0xFFFFFFFF), + /// boxShadow: CupertinoContextMenu.kEndBoxShadow, + /// ), + /// ).animate( + /// CurvedAnimation( + /// parent: animation, + /// curve: Interval( + /// 0.0, + /// CupertinoContextMenu.animationOpensAt, + /// ), + /// ), + /// ); + /// + /// return Container( + /// decoration: + /// animation.value < CupertinoContextMenu.animationOpensAt ? boxDecorationAnimation.value : null, + /// child: FittedBox( + /// fit: BoxFit.cover, + /// child: ClipRRect( + /// borderRadius: borderRadiusAnimation.value ?? BorderRadius.circular(0.0), + /// child: SizedBox( + /// height: 150, + /// width: 150, + /// child: Image.network('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg'), + /// ), + /// ), + /// ), + /// ); + /// }, + /// ) + /// ``` + /// + /// {@end-tool} + /// + /// {@tool dartpad} + /// Additionally below is an example of a real world use case for [builder]. + /// + /// If a widget is passed to the [child] parameter with properties that + /// conflict with the default animation, in this case the border radius, + /// unwanted behaviors can arise. Here a boxed shadow will wrap the widget as + /// it is expanded. To handle this, a more custom animation and widget can be + /// passed to the builder, using values exposed by [CupertinoContextMenu], + /// like [CupertinoContextMenu.kEndBoxShadow], to match the native iOS + /// animation as close as desired. + /// + /// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.1.dart ** + /// {@end-tool} + final CupertinoContextMenuBuilder builder; + + /// The default preview builder if none is provided. It makes a rectangle + /// around the child widget with rounded borders, matching the iOS 16 opened + /// context menu eyeballed on the XCode iOS simulator. + static Widget _defaultPreviewBuilder(BuildContext context, Animation animation, Widget child) { + return FittedBox( + fit: BoxFit.cover, + child: ClipRRect( + borderRadius: BorderRadius.circular(_previewBorderRadiusRatio * animation.value), + child: child, + ), + ); + } + + // TODO(mitchgoodwin): deprecate [child] with builder refactor https://github.com/flutter/flutter/issues/116306 /// The widget that can be "opened" with the [CupertinoContextMenu]. /// @@ -118,9 +381,7 @@ class CupertinoContextMenu extends StatefulWidget { /// 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; + final Widget? child; /// The actions that are shown in the menu. /// @@ -163,6 +424,7 @@ class CupertinoContextMenu extends StatefulWidget { /// // 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. + /// // ignore: deprecated_member_use /// previewBuilder: (BuildContext context, Animation animation, Widget child) { /// return FittedBox( /// fit: BoxFit.cover, @@ -190,6 +452,10 @@ class CupertinoContextMenu extends StatefulWidget { /// ``` /// /// {@end-tool} + @Deprecated( + 'Use CupertinoContextMenu.builder instead. ' + 'This feature was deprecated after v3.4.0-34.1.pre.', + ) final ContextMenuPreviewBuilder? previewBuilder; @override @@ -204,13 +470,15 @@ class _CupertinoContextMenuState extends State with Ticker Rect? _decoyChildEndRect; OverlayEntry? _lastOverlayEntry; _ContextMenuRoute? _route; + final double _midpoint = CupertinoContextMenu.animationOpensAt / 2; @override void initState() { super.initState(); _openController = AnimationController( - duration: kLongPressTimeout, + duration: _previewLongPressTimeout, vsync: this, + upperBound: CupertinoContextMenu.animationOpensAt, ); _openController.addStatusListener(_onDecoyAnimationStatusChange); } @@ -258,10 +526,11 @@ class _CupertinoContextMenuState extends State with Ticker contextMenuLocation: _contextMenuLocation, previousChildRect: _decoyChildEndRect!, builder: (BuildContext context, Animation animation) { - if (widget.previewBuilder == null) { - return widget.child; + if (widget.child == null) { + final Animation localAnimation = Tween(begin: CupertinoContextMenu.animationOpensAt, end: 1).animate(animation); + return widget.builder(context, localAnimation); } - return widget.previewBuilder!(context, animation, widget.child); + return widget.previewBuilder!(context, animation, widget.child!); }, ); Navigator.of(context, rootNavigator: true).push(_route!); @@ -316,19 +585,19 @@ class _CupertinoContextMenuState extends State with Ticker } void _onTap() { - if (_openController.isAnimating && _openController.value < 0.5) { + if (_openController.isAnimating && _openController.value < _midpoint) { _openController.reverse(); } } void _onTapCancel() { - if (_openController.isAnimating && _openController.value < 0.5) { + if (_openController.isAnimating && _openController.value < _midpoint) { _openController.reverse(); } } void _onTapUp(TapUpDetails details) { - if (_openController.isAnimating && _openController.value < 0.5) { + if (_openController.isAnimating && _openController.value < _midpoint) { _openController.reverse(); } } @@ -359,6 +628,7 @@ class _CupertinoContextMenuState extends State with Ticker beginRect: childRect, controller: _openController, endRect: _decoyChildEndRect, + builder: widget.builder, child: widget.child, ); }, @@ -381,7 +651,7 @@ class _CupertinoContextMenuState extends State with Ticker child: Visibility.maintain( key: _childGlobalKey, visible: !_childHidden, - child: widget.child, + child: widget.builder(context, _openController), ), ), ), @@ -398,114 +668,115 @@ class _CupertinoContextMenuState extends State with Ticker // 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. +// an animation where it slowly 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({ this.beginRect, required this.controller, this.endRect, this.child, + this.builder, }); final Rect? beginRect; final AnimationController controller; final Rect? endRect; final Widget? child; + final CupertinoContextMenuBuilder? builder; @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(); - late Animation _mask; late Animation _rect; + late Animation _boxDecoration; @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( - 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, - ); + const double beginPause = 1.0; + const double openAnimationLength = 5.0; + const double totalOpenAnimationLength = beginPause + openAnimationLength; + final double endPause = + ((totalOpenAnimationLength * _animationDuration) / _previewLongPressTimeout.inMilliseconds) - totalOpenAnimationLength; + + // The timing on the animation was eyeballed from the XCode iOS simulator + // running iOS 16.0. + // Because the animation no longer goes from 0.0 to 1.0, but to a number + // depending on the ratio between the press animation time and the opening + // animation time, a pause needs to be added to the end of the tween + // sequence that completes that ratio. This is to allow the animation to + // fully complete as expected without doing crazy math to the _kOpenScale + // value. This change was necessary from the inclusion of the builder and + // the complete animation value that it passes along. _rect = TweenSequence(>[ TweenSequenceItem( tween: RectTween( begin: widget.beginRect, - end: midRect, - ).chain(CurveTween(curve: Curves.easeInOutCubic)), - weight: 1.0, + end: widget.beginRect, + ).chain(CurveTween(curve: Curves.linear)), + weight: beginPause, + ), + TweenSequenceItem( + tween: RectTween( + begin: widget.beginRect, + end: widget.endRect, + ).chain(CurveTween(curve: Curves.easeOutSine)), + weight: openAnimationLength, ), TweenSequenceItem( tween: RectTween( - begin: midRect, + begin: widget.endRect, end: widget.endRect, - ).chain(CurveTween(curve: Curves.easeOutCubic)), - weight: 1.0, + ).chain(CurveTween(curve: Curves.linear)), + weight: endPause, ), ]).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(); + _boxDecoration = DecorationTween( + begin: const BoxDecoration( + color: Color(0xFFFFFFFF), + boxShadow: [], + ), + end: const BoxDecoration( + color: Color(0xFFFFFFFF), + boxShadow: _endBoxShadow, + ), + ).animate(CurvedAnimation( + parent: widget.controller, + curve: Interval(0.0, CupertinoContextMenu.animationOpensAt), + ), + ); } 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: Container( + decoration: _boxDecoration.value, child: widget.child, ), ); } + Widget _buildBuilder(BuildContext context, Widget? child) { + return Positioned.fromRect( + rect: _rect.value!, + child: widget.builder!(context, widget.controller), + ); + } + @override Widget build(BuildContext context) { return Stack( children: [ AnimatedBuilder( - builder: _buildAnimation, + builder: widget.child != null ? _buildAnimation : _buildBuilder, animation: widget.controller, ), ], @@ -520,7 +791,7 @@ class _ContextMenuRoute extends PopupRoute { required List actions, required _ContextMenuLocation contextMenuLocation, this.barrierLabel, - _ContextMenuPreviewBuilderChildless? builder, + CupertinoContextMenuBuilder? builder, super.filter, required Rect previousChildRect, super.settings, @@ -533,13 +804,9 @@ class _ContextMenuRoute extends PopupRoute { // 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 _actions; - final _ContextMenuPreviewBuilderChildless? _builder; + final CupertinoContextMenuBuilder? _builder; final GlobalKey _childGlobalKey = GlobalKey(); final _ContextMenuLocation _contextMenuLocation; bool _externalOffstage = false; @@ -1218,40 +1485,3 @@ class _ContextMenuSheet extends StatelessWidget { ); } } - -// 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 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; -} diff --git a/packages/flutter/test/cupertino/context_menu_test.dart b/packages/flutter/test/cupertino/context_menu_test.dart index 777577d65ca91..c28dc5c33d80a 100644 --- a/packages/flutter/test/cupertino/context_menu_test.dart +++ b/packages/flutter/test/cupertino/context_menu_test.dart @@ -10,7 +10,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); - const double kOpenScale = 1.1; + const double kOpenScale = 1.15; Widget getChild() { return Container( @@ -20,6 +20,10 @@ void main() { ); } + Widget getBuilder(BuildContext context, Animation animation) { + return getChild(); + } + Widget getContextMenu({ Alignment alignment = Alignment.center, Size screenSize = const Size(800.0, 600.0), @@ -45,10 +49,35 @@ void main() { ); } + Widget getBuilderContextMenu({ + Alignment alignment = Alignment.center, + Size screenSize = const Size(800.0, 600.0), + CupertinoContextMenuBuilder? builder, + }) { + return CupertinoApp( + home: CupertinoPageScaffold( + child: MediaQuery( + data: MediaQueryData(size: screenSize), + child: Align( + alignment: alignment, + child: CupertinoContextMenu.builder( + actions: [ + CupertinoContextMenuAction( + child: Text('CupertinoContextMenuAction $alignment'), + ), + ], + builder: builder ?? getBuilder, + ), + ), + ), + ), + ); + } + // Finds the child widget that is rendered inside of _DecoyChild. Finder findDecoyChild(Widget child) { return find.descendant( - of: find.byType(ShaderMask), + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), matching: find.byWidget(child), ); } @@ -75,6 +104,20 @@ void main() { ); } + Finder findFittedBox() { + return find.descendant( + of: findStatic(), + matching: find.byType(FittedBox), + ); + } + + Finder findStaticDefaultPreview() { + return find.descendant( + of: findFittedBox(), + matching: find.byType(ClipRRect), + ); + } + 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. @@ -101,7 +144,7 @@ void main() { await tester.pumpWidget(getContextMenu(child: child)); expect(find.byWidget(child), findsOneWidget); final Rect childRect = tester.getRect(find.byWidget(child)); - expect(find.byType(ShaderMask), findsNothing); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsNothing); // Start a press on the child. final TestGesture gesture = await tester.startGesture(childRect.center); @@ -112,15 +155,15 @@ void main() { Rect decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, equals(decoyChildRect)); - expect(find.byType(ShaderMask), findsOneWidget); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsOneWidget); // After a small delay, the _DecoyChild has begun to animate. - await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 400)); decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, isNot(equals(decoyChildRect))); // Eventually the decoy fully scales by _kOpenSize. - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 800)); decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, isNot(equals(decoyChildRect))); expect(decoyChildRect.width, childRect.width * kOpenScale); @@ -166,7 +209,7 @@ void main() { )); expect(find.byWidget(child), findsOneWidget); final Rect childRect = tester.getRect(find.byWidget(child)); - expect(find.byType(ShaderMask), findsNothing); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsNothing); // Start a press on the child. final TestGesture gesture = await tester.startGesture(childRect.center); @@ -177,15 +220,53 @@ void main() { Rect decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, equals(decoyChildRect)); - expect(find.byType(ShaderMask), findsOneWidget); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsOneWidget); // After a small delay, the _DecoyChild has begun to animate. - await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 400)); decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, isNot(equals(decoyChildRect))); // Eventually the decoy fully scales by _kOpenSize. - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 800)); + 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); + }); + + testWidgets('CupertinoContextMenu with a basic builder opens and closes the same as when providing a child', (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget(getBuilderContextMenu(builder: (BuildContext context, Animation animation) { + return child; + })); + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), 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)); + + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsOneWidget); + + // After a small delay, the _DecoyChild has begun to animate. + await tester.pump(const Duration(milliseconds: 400)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + + // Eventually the decoy fully scales by _kOpenSize. + await tester.pump(const Duration(milliseconds: 800)); decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, isNot(equals(decoyChildRect))); expect(decoyChildRect.width, childRect.width * kOpenScale); @@ -197,6 +278,46 @@ void main() { expect(findStatic(), findsOneWidget); }); + testWidgets('CupertinoContextMenu with a builder can change the animation', (WidgetTester tester) async { + await tester.pumpWidget(getBuilderContextMenu(builder: (BuildContext context, Animation animation) { + return Container( + width: 300.0, + height: 100.0, + decoration: BoxDecoration( + color: CupertinoColors.activeOrange, + borderRadius: BorderRadius.circular(25.0 * animation.value) + ), + ); + })); + + final Widget child = find.descendant(of: find.byType(TickerMode), matching: find.byType(Container)).evaluate().single.widget; + final Rect childRect = tester.getRect(find.byWidget(child)); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsNothing); + + // Start a press on the child. + await tester.startGesture(childRect.center); + await tester.pump(); + + Finder findBuilderDecoyChild() { + return find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + matching: find.byType(Container), + ); + } + + final Container decoyContainer = tester.firstElement(findBuilderDecoyChild()).widget as Container; + final BoxDecoration? decoyDecoration = decoyContainer.decoration as BoxDecoration?; + expect(decoyDecoration?.borderRadius, equals(BorderRadius.circular(0))); + + expect(findBuilderDecoyChild(), findsOneWidget); + + // After a small delay, the _DecoyChild has begun to animate with a different border radius. + await tester.pump(const Duration(milliseconds: 500)); + final Container decoyLaterContainer = tester.firstElement(findBuilderDecoyChild()).widget as Container; + final BoxDecoration? decoyLaterDecoration = decoyLaterContainer.decoration as BoxDecoration?; + expect(decoyLaterDecoration?.borderRadius, isNot(equals(BorderRadius.circular(0)))); + }); + testWidgets('Hovering over Cupertino context menu updates cursor to clickable on Web', (WidgetTester tester) async { final Widget child = getChild(); await tester.pumpWidget(CupertinoApp( @@ -253,7 +374,7 @@ void main() { )); expect(find.byWidget(child), findsOneWidget); final Rect childRect = tester.getRect(find.byWidget(child)); - expect(find.byType(ShaderMask), findsNothing); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsNothing); // Start a press on the child. final TestGesture gesture = await tester.startGesture(childRect.center); @@ -264,15 +385,15 @@ void main() { Rect decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, equals(decoyChildRect)); - expect(find.byType(ShaderMask), findsOneWidget); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsOneWidget); // After a small delay, the _DecoyChild has begun to animate. - await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 400)); decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, isNot(equals(decoyChildRect))); // Eventually the decoy fully scales by _kOpenSize. - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 800)); decoyChildRect = tester.getRect(findDecoyChild(child)); expect(childRect, isNot(equals(decoyChildRect))); expect(decoyChildRect.width, childRect.width * kOpenScale); @@ -444,6 +565,24 @@ void main() { expect(findStatic(), findsOneWidget); expect(find.byType(BackdropFilter), findsOneWidget); }); + + testWidgets('Preview widget should have the correct border radius', (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); + + // Check border radius. + expect(findStaticDefaultPreview(), findsOneWidget); + final ClipRRect previewWidget = tester.firstWidget(findStaticDefaultPreview()) as ClipRRect; + expect(previewWidget.borderRadius, equals(BorderRadius.circular(12.0))); + }); }); group("Open layout differs depending on child's position on screen", () {