mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
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
This commit is contained in:
parent
7802c7acd8
commit
97195d1d51
@ -49,7 +49,7 @@ class ContextMenuExample extends StatelessWidget {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
trailingIcon: CupertinoIcons.share,
|
trailingIcon: CupertinoIcons.share,
|
||||||
child: const Text('Share '),
|
child: const Text('Share'),
|
||||||
),
|
),
|
||||||
CupertinoContextMenuAction(
|
CupertinoContextMenuAction(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@ -68,10 +68,7 @@ class ContextMenuExample extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
color: CupertinoColors.systemYellow,
|
||||||
color: CupertinoColors.systemYellow,
|
|
||||||
borderRadius: BorderRadius.circular(20.0),
|
|
||||||
),
|
|
||||||
child: const FlutterLogo(size: 500.0),
|
child: const FlutterLogo(size: 500.0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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 <BoxShadow>[],
|
||||||
|
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<Decoration> _boxDecorationAnimation(Animation<double> 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: <Widget>[
|
||||||
|
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<double> animation) {
|
||||||
|
final Animation<Decoration> 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
@ -6,16 +6,42 @@ import 'dart:math' as math;
|
|||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'colors.dart';
|
import 'colors.dart';
|
||||||
|
|
||||||
// The scale of the child at the time that the CupertinoContextMenu opens.
|
// 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.
|
// 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<BoxShadow> _endBoxShadow = <BoxShadow>[
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x40000000),
|
||||||
|
blurRadius: 10.0,
|
||||||
|
spreadRadius: 0.5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
const Color _borderColor = CupertinoDynamicColor.withBrightness(
|
const Color _borderColor = CupertinoDynamicColor.withBrightness(
|
||||||
color: Color(0xFFA9A9AF),
|
color: Color(0xFFA9A9AF),
|
||||||
@ -37,8 +63,9 @@ typedef ContextMenuPreviewBuilder = Widget Function(
|
|||||||
Widget child,
|
Widget child,
|
||||||
);
|
);
|
||||||
|
|
||||||
// A function that proxies to ContextMenuPreviewBuilder without the child.
|
/// A function that builds the child and handles the transition between the
|
||||||
typedef _ContextMenuPreviewBuilderChildless = Widget Function(
|
/// default child and the preview when the CupertinoContextMenu is open.
|
||||||
|
typedef CupertinoContextMenuBuilder = Widget Function(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Animation<double> animation,
|
Animation<double> animation,
|
||||||
);
|
);
|
||||||
@ -84,12 +111,19 @@ enum _ContextMenuLocation {
|
|||||||
/// Photos app on iOS.
|
/// Photos app on iOS.
|
||||||
///
|
///
|
||||||
/// {@tool dartpad}
|
/// {@tool dartpad}
|
||||||
/// This sample shows a very simple CupertinoContextMenu for an empty red
|
/// This sample shows a very simple CupertinoContextMenu for the Flutter logo.
|
||||||
/// 100x100 Container. Simply long press on it to open.
|
/// Simply long press on it to open.
|
||||||
///
|
///
|
||||||
/// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart **
|
/// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart **
|
||||||
/// {@end-tool}
|
/// {@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:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/controls/context-menus/>
|
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/controls/context-menus/>
|
||||||
@ -102,10 +136,239 @@ class CupertinoContextMenu extends StatefulWidget {
|
|||||||
CupertinoContextMenu({
|
CupertinoContextMenu({
|
||||||
super.key,
|
super.key,
|
||||||
required this.actions,
|
required this.actions,
|
||||||
required this.child,
|
required Widget this.child,
|
||||||
this.previewBuilder,
|
@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(actions != null && actions.isNotEmpty),
|
||||||
assert(child != null);
|
assert(child != null),
|
||||||
|
builder = ((BuildContext context, Animation<double> 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: <Widget>[
|
||||||
|
/// CupertinoContextMenuAction(
|
||||||
|
/// child: const Text('Action one'),
|
||||||
|
/// onPressed: () {},
|
||||||
|
/// ),
|
||||||
|
/// ],
|
||||||
|
/// builder:(BuildContext context, Animation<double> animation) {
|
||||||
|
/// final Animation<BorderRadius?> borderRadiusAnimation = BorderRadiusTween(
|
||||||
|
/// begin: BorderRadius.circular(0.0),
|
||||||
|
/// end: BorderRadius.circular(CupertinoContextMenu.kOpenBorderRadius),
|
||||||
|
/// ).animate(
|
||||||
|
/// CurvedAnimation(
|
||||||
|
/// parent: animation,
|
||||||
|
/// curve: Interval(
|
||||||
|
/// CupertinoContextMenu.animationOpensAt,
|
||||||
|
/// 1.0,
|
||||||
|
/// ),
|
||||||
|
/// ),
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// final Animation<Decoration> boxDecorationAnimation = DecorationTween(
|
||||||
|
/// begin: const BoxDecoration(
|
||||||
|
/// color: Color(0xFFFFFFFF),
|
||||||
|
/// boxShadow: <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<BoxShadow> 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: <Widget>[
|
||||||
|
/// CupertinoContextMenuAction(
|
||||||
|
/// child: const Text('Action one'),
|
||||||
|
/// onPressed: () {},
|
||||||
|
/// ),
|
||||||
|
/// ],
|
||||||
|
/// builder:(BuildContext context, Animation<double> animation) {
|
||||||
|
/// final Animation<BorderRadius?> borderRadiusAnimation = BorderRadiusTween(
|
||||||
|
/// begin: BorderRadius.circular(0.0),
|
||||||
|
/// end: BorderRadius.circular(CupertinoContextMenu.kOpenBorderRadius),
|
||||||
|
/// ).animate(
|
||||||
|
/// CurvedAnimation(
|
||||||
|
/// parent: animation,
|
||||||
|
/// curve: Interval(
|
||||||
|
/// CupertinoContextMenu.animationOpensAt,
|
||||||
|
/// 1.0,
|
||||||
|
/// ),
|
||||||
|
/// ),
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// final Animation<Decoration> boxDecorationAnimation = DecorationTween(
|
||||||
|
/// begin: const BoxDecoration(
|
||||||
|
/// color: Color(0xFFFFFFFF),
|
||||||
|
/// boxShadow: <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<double> 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].
|
/// 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
|
/// When the [CupertinoContextMenu] is "closed", this widget acts like a
|
||||||
/// [Container], i.e. it does not constrain its child's size or affect its
|
/// [Container], i.e. it does not constrain its child's size or affect its
|
||||||
/// position.
|
/// position.
|
||||||
///
|
final Widget? child;
|
||||||
/// This parameter cannot be null.
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
/// The actions that are shown in the menu.
|
/// 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
|
/// // The FittedBox in the preview here allows the image to animate its
|
||||||
/// // aspect ratio when the CupertinoContextMenu is animating its preview
|
/// // aspect ratio when the CupertinoContextMenu is animating its preview
|
||||||
/// // widget open and closed.
|
/// // widget open and closed.
|
||||||
|
/// // ignore: deprecated_member_use
|
||||||
/// previewBuilder: (BuildContext context, Animation<double> animation, Widget child) {
|
/// previewBuilder: (BuildContext context, Animation<double> animation, Widget child) {
|
||||||
/// return FittedBox(
|
/// return FittedBox(
|
||||||
/// fit: BoxFit.cover,
|
/// fit: BoxFit.cover,
|
||||||
@ -190,6 +452,10 @@ class CupertinoContextMenu extends StatefulWidget {
|
|||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// {@end-tool}
|
/// {@end-tool}
|
||||||
|
@Deprecated(
|
||||||
|
'Use CupertinoContextMenu.builder instead. '
|
||||||
|
'This feature was deprecated after v3.4.0-34.1.pre.',
|
||||||
|
)
|
||||||
final ContextMenuPreviewBuilder? previewBuilder;
|
final ContextMenuPreviewBuilder? previewBuilder;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -204,13 +470,15 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
|
|||||||
Rect? _decoyChildEndRect;
|
Rect? _decoyChildEndRect;
|
||||||
OverlayEntry? _lastOverlayEntry;
|
OverlayEntry? _lastOverlayEntry;
|
||||||
_ContextMenuRoute<void>? _route;
|
_ContextMenuRoute<void>? _route;
|
||||||
|
final double _midpoint = CupertinoContextMenu.animationOpensAt / 2;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_openController = AnimationController(
|
_openController = AnimationController(
|
||||||
duration: kLongPressTimeout,
|
duration: _previewLongPressTimeout,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
|
upperBound: CupertinoContextMenu.animationOpensAt,
|
||||||
);
|
);
|
||||||
_openController.addStatusListener(_onDecoyAnimationStatusChange);
|
_openController.addStatusListener(_onDecoyAnimationStatusChange);
|
||||||
}
|
}
|
||||||
@ -258,10 +526,11 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
|
|||||||
contextMenuLocation: _contextMenuLocation,
|
contextMenuLocation: _contextMenuLocation,
|
||||||
previousChildRect: _decoyChildEndRect!,
|
previousChildRect: _decoyChildEndRect!,
|
||||||
builder: (BuildContext context, Animation<double> animation) {
|
builder: (BuildContext context, Animation<double> animation) {
|
||||||
if (widget.previewBuilder == null) {
|
if (widget.child == null) {
|
||||||
return widget.child;
|
final Animation<double> localAnimation = Tween<double>(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<void>(_route!);
|
Navigator.of(context, rootNavigator: true).push<void>(_route!);
|
||||||
@ -316,19 +585,19 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onTap() {
|
void _onTap() {
|
||||||
if (_openController.isAnimating && _openController.value < 0.5) {
|
if (_openController.isAnimating && _openController.value < _midpoint) {
|
||||||
_openController.reverse();
|
_openController.reverse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTapCancel() {
|
void _onTapCancel() {
|
||||||
if (_openController.isAnimating && _openController.value < 0.5) {
|
if (_openController.isAnimating && _openController.value < _midpoint) {
|
||||||
_openController.reverse();
|
_openController.reverse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTapUp(TapUpDetails details) {
|
void _onTapUp(TapUpDetails details) {
|
||||||
if (_openController.isAnimating && _openController.value < 0.5) {
|
if (_openController.isAnimating && _openController.value < _midpoint) {
|
||||||
_openController.reverse();
|
_openController.reverse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -359,6 +628,7 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
|
|||||||
beginRect: childRect,
|
beginRect: childRect,
|
||||||
controller: _openController,
|
controller: _openController,
|
||||||
endRect: _decoyChildEndRect,
|
endRect: _decoyChildEndRect,
|
||||||
|
builder: widget.builder,
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -381,7 +651,7 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
|
|||||||
child: Visibility.maintain(
|
child: Visibility.maintain(
|
||||||
key: _childGlobalKey,
|
key: _childGlobalKey,
|
||||||
visible: !_childHidden,
|
visible: !_childHidden,
|
||||||
child: widget.child,
|
child: widget.builder(context, _openController),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -398,114 +668,115 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
|
|||||||
// A floating copy of the CupertinoContextMenu's child.
|
// A floating copy of the CupertinoContextMenu's child.
|
||||||
//
|
//
|
||||||
// When the child is pressed, but before the CupertinoContextMenu opens, it does
|
// When the child is pressed, but before the CupertinoContextMenu opens, it does
|
||||||
// a "bounce" animation where it shrinks and then grows. This is implemented
|
// an animation where it slowly grows. This is implemented by hiding the
|
||||||
// by hiding the original child and placing _DecoyChild on top of it in an
|
// original child and placing _DecoyChild on top of it in an Overlay. The use of
|
||||||
// Overlay. The use of an Overlay allows the _DecoyChild to appear on top of
|
// an Overlay allows the _DecoyChild to appear on top of siblings of the
|
||||||
// siblings of the original child.
|
// original child.
|
||||||
class _DecoyChild extends StatefulWidget {
|
class _DecoyChild extends StatefulWidget {
|
||||||
const _DecoyChild({
|
const _DecoyChild({
|
||||||
this.beginRect,
|
this.beginRect,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
this.endRect,
|
this.endRect,
|
||||||
this.child,
|
this.child,
|
||||||
|
this.builder,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Rect? beginRect;
|
final Rect? beginRect;
|
||||||
final AnimationController controller;
|
final AnimationController controller;
|
||||||
final Rect? endRect;
|
final Rect? endRect;
|
||||||
final Widget? child;
|
final Widget? child;
|
||||||
|
final CupertinoContextMenuBuilder? builder;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_DecoyChildState createState() => _DecoyChildState();
|
_DecoyChildState createState() => _DecoyChildState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DecoyChildState extends State<_DecoyChild> with TickerProviderStateMixin {
|
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<Color> _mask;
|
|
||||||
late Animation<Rect?> _rect;
|
late Animation<Rect?> _rect;
|
||||||
|
late Animation<Decoration> _boxDecoration;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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(
|
const double beginPause = 1.0;
|
||||||
widget.beginRect!.width * (_kOpenScale - 1.0) / 2,
|
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<Rect?>(<TweenSequenceItem<Rect?>>[
|
_rect = TweenSequence<Rect?>(<TweenSequenceItem<Rect?>>[
|
||||||
TweenSequenceItem<Rect?>(
|
TweenSequenceItem<Rect?>(
|
||||||
tween: RectTween(
|
tween: RectTween(
|
||||||
begin: widget.beginRect,
|
begin: widget.beginRect,
|
||||||
end: midRect,
|
end: widget.beginRect,
|
||||||
).chain(CurveTween(curve: Curves.easeInOutCubic)),
|
).chain(CurveTween(curve: Curves.linear)),
|
||||||
weight: 1.0,
|
weight: beginPause,
|
||||||
),
|
),
|
||||||
TweenSequenceItem<Rect?>(
|
TweenSequenceItem<Rect?>(
|
||||||
tween: RectTween(
|
tween: RectTween(
|
||||||
begin: midRect,
|
begin: widget.beginRect,
|
||||||
end: widget.endRect,
|
end: widget.endRect,
|
||||||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
).chain(CurveTween(curve: Curves.easeOutSine)),
|
||||||
weight: 1.0,
|
weight: openAnimationLength,
|
||||||
|
),
|
||||||
|
TweenSequenceItem<Rect?>(
|
||||||
|
tween: RectTween(
|
||||||
|
begin: widget.endRect,
|
||||||
|
end: widget.endRect,
|
||||||
|
).chain(CurveTween(curve: Curves.linear)),
|
||||||
|
weight: endPause,
|
||||||
),
|
),
|
||||||
]).animate(widget.controller);
|
]).animate(widget.controller);
|
||||||
_rect.addListener(_rectListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen to the _rect animation and vibrate when it reaches the halfway point
|
_boxDecoration = DecorationTween(
|
||||||
// and switches from animating down to up.
|
begin: const BoxDecoration(
|
||||||
void _rectListener() {
|
color: Color(0xFFFFFFFF),
|
||||||
if (widget.controller.value < 0.5) {
|
boxShadow: <BoxShadow>[],
|
||||||
return;
|
),
|
||||||
}
|
end: const BoxDecoration(
|
||||||
HapticFeedback.selectionClick();
|
color: Color(0xFFFFFFFF),
|
||||||
_rect.removeListener(_rectListener);
|
boxShadow: _endBoxShadow,
|
||||||
}
|
),
|
||||||
|
).animate(CurvedAnimation(
|
||||||
@override
|
parent: widget.controller,
|
||||||
void dispose() {
|
curve: Interval(0.0, CupertinoContextMenu.animationOpensAt),
|
||||||
_rect.removeListener(_rectListener);
|
),
|
||||||
super.dispose();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAnimation(BuildContext context, Widget? child) {
|
Widget _buildAnimation(BuildContext context, Widget? child) {
|
||||||
final Color color = widget.controller.status == AnimationStatus.reverse
|
|
||||||
? _masklessColor
|
|
||||||
: _mask.value;
|
|
||||||
return Positioned.fromRect(
|
return Positioned.fromRect(
|
||||||
rect: _rect.value!,
|
rect: _rect.value!,
|
||||||
child: ShaderMask(
|
child: Container(
|
||||||
key: _childGlobalKey,
|
decoration: _boxDecoration.value,
|
||||||
shaderCallback: (Rect bounds) {
|
|
||||||
return LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: <Color>[color, color],
|
|
||||||
).createShader(bounds);
|
|
||||||
},
|
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildBuilder(BuildContext context, Widget? child) {
|
||||||
|
return Positioned.fromRect(
|
||||||
|
rect: _rect.value!,
|
||||||
|
child: widget.builder!(context, widget.controller),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
builder: _buildAnimation,
|
builder: widget.child != null ? _buildAnimation : _buildBuilder,
|
||||||
animation: widget.controller,
|
animation: widget.controller,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -520,7 +791,7 @@ class _ContextMenuRoute<T> extends PopupRoute<T> {
|
|||||||
required List<Widget> actions,
|
required List<Widget> actions,
|
||||||
required _ContextMenuLocation contextMenuLocation,
|
required _ContextMenuLocation contextMenuLocation,
|
||||||
this.barrierLabel,
|
this.barrierLabel,
|
||||||
_ContextMenuPreviewBuilderChildless? builder,
|
CupertinoContextMenuBuilder? builder,
|
||||||
super.filter,
|
super.filter,
|
||||||
required Rect previousChildRect,
|
required Rect previousChildRect,
|
||||||
super.settings,
|
super.settings,
|
||||||
@ -533,13 +804,9 @@ class _ContextMenuRoute<T> extends PopupRoute<T> {
|
|||||||
|
|
||||||
// Barrier color for a Cupertino modal barrier.
|
// Barrier color for a Cupertino modal barrier.
|
||||||
static const Color _kModalBarrierColor = Color(0x6604040F);
|
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 List<Widget> _actions;
|
||||||
final _ContextMenuPreviewBuilderChildless? _builder;
|
final CupertinoContextMenuBuilder? _builder;
|
||||||
final GlobalKey _childGlobalKey = GlobalKey();
|
final GlobalKey _childGlobalKey = GlobalKey();
|
||||||
final _ContextMenuLocation _contextMenuLocation;
|
final _ContextMenuLocation _contextMenuLocation;
|
||||||
bool _externalOffstage = false;
|
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<T> extends CompoundAnimation<T> {
|
|
||||||
_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<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;
|
|
||||||
}
|
|
||||||
|
@ -10,7 +10,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
|
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
const double kOpenScale = 1.1;
|
const double kOpenScale = 1.15;
|
||||||
|
|
||||||
Widget getChild() {
|
Widget getChild() {
|
||||||
return Container(
|
return Container(
|
||||||
@ -20,6 +20,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget getBuilder(BuildContext context, Animation<double> animation) {
|
||||||
|
return getChild();
|
||||||
|
}
|
||||||
|
|
||||||
Widget getContextMenu({
|
Widget getContextMenu({
|
||||||
Alignment alignment = Alignment.center,
|
Alignment alignment = Alignment.center,
|
||||||
Size screenSize = const Size(800.0, 600.0),
|
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>[
|
||||||
|
CupertinoContextMenuAction(
|
||||||
|
child: Text('CupertinoContextMenuAction $alignment'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
builder: builder ?? getBuilder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Finds the child widget that is rendered inside of _DecoyChild.
|
// Finds the child widget that is rendered inside of _DecoyChild.
|
||||||
Finder findDecoyChild(Widget child) {
|
Finder findDecoyChild(Widget child) {
|
||||||
return find.descendant(
|
return find.descendant(
|
||||||
of: find.byType(ShaderMask),
|
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'),
|
||||||
matching: find.byWidget(child),
|
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', () {
|
group('CupertinoContextMenu before and during opening', () {
|
||||||
testWidgets('An unopened CupertinoContextMenu renders child in the same place as without', (WidgetTester tester) async {
|
testWidgets('An unopened CupertinoContextMenu renders child in the same place as without', (WidgetTester tester) async {
|
||||||
// Measure the child in the scene with no CupertinoContextMenu.
|
// Measure the child in the scene with no CupertinoContextMenu.
|
||||||
@ -101,7 +144,7 @@ void main() {
|
|||||||
await tester.pumpWidget(getContextMenu(child: child));
|
await tester.pumpWidget(getContextMenu(child: child));
|
||||||
expect(find.byWidget(child), findsOneWidget);
|
expect(find.byWidget(child), findsOneWidget);
|
||||||
final Rect childRect = tester.getRect(find.byWidget(child));
|
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.
|
// Start a press on the child.
|
||||||
final TestGesture gesture = await tester.startGesture(childRect.center);
|
final TestGesture gesture = await tester.startGesture(childRect.center);
|
||||||
@ -112,15 +155,15 @@ void main() {
|
|||||||
Rect decoyChildRect = tester.getRect(findDecoyChild(child));
|
Rect decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, equals(decoyChildRect));
|
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.
|
// 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));
|
decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, isNot(equals(decoyChildRect)));
|
expect(childRect, isNot(equals(decoyChildRect)));
|
||||||
|
|
||||||
// Eventually the decoy fully scales by _kOpenSize.
|
// 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));
|
decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, isNot(equals(decoyChildRect)));
|
expect(childRect, isNot(equals(decoyChildRect)));
|
||||||
expect(decoyChildRect.width, childRect.width * kOpenScale);
|
expect(decoyChildRect.width, childRect.width * kOpenScale);
|
||||||
@ -166,7 +209,7 @@ void main() {
|
|||||||
));
|
));
|
||||||
expect(find.byWidget(child), findsOneWidget);
|
expect(find.byWidget(child), findsOneWidget);
|
||||||
final Rect childRect = tester.getRect(find.byWidget(child));
|
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.
|
// Start a press on the child.
|
||||||
final TestGesture gesture = await tester.startGesture(childRect.center);
|
final TestGesture gesture = await tester.startGesture(childRect.center);
|
||||||
@ -177,15 +220,15 @@ void main() {
|
|||||||
Rect decoyChildRect = tester.getRect(findDecoyChild(child));
|
Rect decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, equals(decoyChildRect));
|
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.
|
// 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));
|
decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, isNot(equals(decoyChildRect)));
|
expect(childRect, isNot(equals(decoyChildRect)));
|
||||||
|
|
||||||
// Eventually the decoy fully scales by _kOpenSize.
|
// 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));
|
decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, isNot(equals(decoyChildRect)));
|
expect(childRect, isNot(equals(decoyChildRect)));
|
||||||
expect(decoyChildRect.width, childRect.width * kOpenScale);
|
expect(decoyChildRect.width, childRect.width * kOpenScale);
|
||||||
@ -197,6 +240,84 @@ void main() {
|
|||||||
expect(findStatic(), findsOneWidget);
|
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<double> 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);
|
||||||
|
|
||||||
|
// Then the CupertinoContextMenu opens.
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(findStatic(), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('CupertinoContextMenu with a builder can change the animation', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(getBuilderContextMenu(builder: (BuildContext context, Animation<double> 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 {
|
testWidgets('Hovering over Cupertino context menu updates cursor to clickable on Web', (WidgetTester tester) async {
|
||||||
final Widget child = getChild();
|
final Widget child = getChild();
|
||||||
await tester.pumpWidget(CupertinoApp(
|
await tester.pumpWidget(CupertinoApp(
|
||||||
@ -253,7 +374,7 @@ void main() {
|
|||||||
));
|
));
|
||||||
expect(find.byWidget(child), findsOneWidget);
|
expect(find.byWidget(child), findsOneWidget);
|
||||||
final Rect childRect = tester.getRect(find.byWidget(child));
|
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.
|
// Start a press on the child.
|
||||||
final TestGesture gesture = await tester.startGesture(childRect.center);
|
final TestGesture gesture = await tester.startGesture(childRect.center);
|
||||||
@ -264,15 +385,15 @@ void main() {
|
|||||||
Rect decoyChildRect = tester.getRect(findDecoyChild(child));
|
Rect decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, equals(decoyChildRect));
|
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.
|
// 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));
|
decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, isNot(equals(decoyChildRect)));
|
expect(childRect, isNot(equals(decoyChildRect)));
|
||||||
|
|
||||||
// Eventually the decoy fully scales by _kOpenSize.
|
// 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));
|
decoyChildRect = tester.getRect(findDecoyChild(child));
|
||||||
expect(childRect, isNot(equals(decoyChildRect)));
|
expect(childRect, isNot(equals(decoyChildRect)));
|
||||||
expect(decoyChildRect.width, childRect.width * kOpenScale);
|
expect(decoyChildRect.width, childRect.width * kOpenScale);
|
||||||
@ -444,6 +565,24 @@ void main() {
|
|||||||
expect(findStatic(), findsOneWidget);
|
expect(findStatic(), findsOneWidget);
|
||||||
expect(find.byType(BackdropFilter), 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", () {
|
group("Open layout differs depending on child's position on screen", () {
|
||||||
|
Loading…
Reference in New Issue
Block a user