Update Cupertino desktop text selection toolbar (#121829)
Visual fidelity of the right-click context menu on MacOS.
This commit is contained in:
parent
8a815c1d1d
commit
f86b9220a3
@ -182,7 +182,6 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin
|
||||
}
|
||||
|
||||
items.add(CupertinoDesktopTextSelectionToolbarButton.text(
|
||||
context: context,
|
||||
onPressed: onPressed,
|
||||
text: text,
|
||||
));
|
||||
|
@ -2,6 +2,9 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'colors.dart';
|
||||
@ -10,23 +13,33 @@ import 'colors.dart';
|
||||
// the screen.
|
||||
const double _kToolbarScreenPadding = 8.0;
|
||||
|
||||
// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on
|
||||
// a Macbook Pro.
|
||||
// These values were measured from a screenshot of the native context menu on
|
||||
// macOS 13.2 on a Macbook Pro.
|
||||
const double _kToolbarSaturationBoost = 3;
|
||||
const double _kToolbarBlurSigma = 20;
|
||||
const double _kToolbarWidth = 222.0;
|
||||
const Radius _kToolbarBorderRadius = Radius.circular(4.0);
|
||||
const EdgeInsets _kToolbarPadding = EdgeInsets.symmetric(
|
||||
vertical: 3.0,
|
||||
);
|
||||
const Radius _kToolbarBorderRadius = Radius.circular(8.0);
|
||||
const EdgeInsets _kToolbarPadding = EdgeInsets.all(6.0);
|
||||
const List<BoxShadow> _kToolbarShadow = <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Color.fromARGB(60, 0, 0, 0),
|
||||
blurRadius: 10.0,
|
||||
spreadRadius: 0.5,
|
||||
offset: Offset(0.0, 4.0),
|
||||
),
|
||||
];
|
||||
|
||||
// These values were measured from a screenshot of TextEdit on macOS 10.16 on a
|
||||
// Macbook Pro.
|
||||
const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFBBBBBB),
|
||||
darkColor: Color(0xFF505152),
|
||||
// These values were measured from a screenshot of the native context menu on
|
||||
// macOS 13.2 on a Macbook Pro.
|
||||
const CupertinoDynamicColor _kToolbarBorderColor =
|
||||
CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFB8B8B8),
|
||||
darkColor: Color(0xFF5B5B5B),
|
||||
);
|
||||
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xffECE8E6),
|
||||
darkColor: Color(0xff302928),
|
||||
const CupertinoDynamicColor _kToolbarBackgroundColor =
|
||||
CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xB2FFFFFF),
|
||||
darkColor: Color(0xB2303030),
|
||||
);
|
||||
|
||||
/// A macOS-style text selection toolbar.
|
||||
@ -53,6 +66,23 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
|
||||
required this.children,
|
||||
}) : assert(children.length > 0);
|
||||
|
||||
/// Creates a 5x5 matrix that increases saturation when used with [ColorFilter.matrix].
|
||||
///
|
||||
/// The numbers were taken from this comment:
|
||||
/// [Cupertino blurs should boost saturation](https://github.com/flutter/flutter/issues/29483#issuecomment-477334981).
|
||||
static List<double> _matrixWithSaturation(double saturation) {
|
||||
final double r = 0.213 * (1 - saturation);
|
||||
final double g = 0.715 * (1 - saturation);
|
||||
final double b = 0.072 * (1 - saturation);
|
||||
|
||||
return <double>[
|
||||
r + saturation, g, b, 0, 0, //
|
||||
r, g + saturation, b, 0, 0, //
|
||||
r, g, b + saturation, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
/// {@macro flutter.material.DesktopTextSelectionToolbar.anchor}
|
||||
final Offset anchor;
|
||||
|
||||
@ -68,6 +98,29 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
|
||||
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
|
||||
return Container(
|
||||
width: _kToolbarWidth,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(
|
||||
boxShadow: _kToolbarShadow,
|
||||
borderRadius: BorderRadius.all(_kToolbarBorderRadius),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
// Flutter web doesn't support ImageFilter.compose on CanvasKit yet
|
||||
// (https://github.com/flutter/flutter/issues/120123).
|
||||
filter: kIsWeb
|
||||
? ImageFilter.blur(
|
||||
sigmaX: _kToolbarBlurSigma,
|
||||
sigmaY: _kToolbarBlurSigma,
|
||||
)
|
||||
: ImageFilter.compose(
|
||||
outer: ColorFilter.matrix(
|
||||
_matrixWithSaturation(_kToolbarSaturationBoost),
|
||||
),
|
||||
inner: ImageFilter.blur(
|
||||
sigmaX: _kToolbarBlurSigma,
|
||||
sigmaY: _kToolbarBlurSigma,
|
||||
),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: _kToolbarBackgroundColor.resolveFrom(context),
|
||||
border: Border.all(
|
||||
@ -79,6 +132,8 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
|
||||
padding: _kToolbarPadding,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -86,7 +141,8 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
assert(debugCheckHasMediaQuery(context));
|
||||
|
||||
final double paddingAbove = MediaQuery.paddingOf(context).top + _kToolbarScreenPadding;
|
||||
final double paddingAbove =
|
||||
MediaQuery.paddingOf(context).top + _kToolbarScreenPadding;
|
||||
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
|
||||
|
||||
return Padding(
|
||||
|
@ -10,8 +10,8 @@ import 'colors.dart';
|
||||
import 'text_selection_toolbar_button.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
|
||||
// a Macbook Pro.
|
||||
// These values were measured from a screenshot of the native context menu on
|
||||
// macOS 13.2 on a Macbook Pro.
|
||||
const TextStyle _kToolbarButtonFontStyle = TextStyle(
|
||||
inherit: false,
|
||||
fontSize: 14.0,
|
||||
@ -19,13 +19,13 @@ const TextStyle _kToolbarButtonFontStyle = TextStyle(
|
||||
fontWeight: FontWeight.w400,
|
||||
);
|
||||
|
||||
// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
|
||||
// Macbook Pro.
|
||||
// This value was measured from a screenshot of the native context menu on
|
||||
// macOS 13.2 on a Macbook Pro.
|
||||
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
|
||||
20.0,
|
||||
0.0,
|
||||
20.0,
|
||||
3.0,
|
||||
8.0,
|
||||
2.0,
|
||||
8.0,
|
||||
5.0,
|
||||
);
|
||||
|
||||
/// A button in the style of the Mac context menu buttons.
|
||||
@ -37,26 +37,17 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required Widget this.child,
|
||||
}) : buttonItem = null;
|
||||
}) : buttonItem = null,
|
||||
text = null;
|
||||
|
||||
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
|
||||
/// a [Text] widget styled like the default Mac context menu button.
|
||||
CupertinoDesktopTextSelectionToolbarButton.text({
|
||||
const CupertinoDesktopTextSelectionToolbarButton.text({
|
||||
super.key,
|
||||
required BuildContext context,
|
||||
required this.onPressed,
|
||||
required String text,
|
||||
required this.text,
|
||||
}) : buttonItem = null,
|
||||
child = Text(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _kToolbarButtonFontStyle.copyWith(
|
||||
color: const CupertinoDynamicColor.withBrightness(
|
||||
color: CupertinoColors.black,
|
||||
darkColor: CupertinoColors.white,
|
||||
).resolveFrom(context),
|
||||
),
|
||||
);
|
||||
child = null;
|
||||
|
||||
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] from
|
||||
/// the given [ContextMenuButtonItem].
|
||||
@ -66,6 +57,7 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
|
||||
super.key,
|
||||
required ContextMenuButtonItem this.buttonItem,
|
||||
}) : onPressed = buttonItem.onPressed,
|
||||
text = null,
|
||||
child = null;
|
||||
|
||||
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
|
||||
@ -77,11 +69,16 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
|
||||
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
|
||||
final ContextMenuButtonItem? buttonItem;
|
||||
|
||||
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.text}
|
||||
final String? text;
|
||||
|
||||
@override
|
||||
State<CupertinoDesktopTextSelectionToolbarButton> createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
|
||||
State<CupertinoDesktopTextSelectionToolbarButton> createState() =>
|
||||
_CupertinoDesktopTextSelectionToolbarButtonState();
|
||||
}
|
||||
|
||||
class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDesktopTextSelectionToolbarButton> {
|
||||
class _CupertinoDesktopTextSelectionToolbarButtonState
|
||||
extends State<CupertinoDesktopTextSelectionToolbarButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
void _onEnter(PointerEnterEvent event) {
|
||||
@ -98,16 +95,24 @@ class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDe
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget child = widget.child ?? Text(
|
||||
CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!),
|
||||
final Widget child = widget.child ??
|
||||
Text(
|
||||
widget.text ??
|
||||
CupertinoTextSelectionToolbarButton.getButtonLabel(
|
||||
context,
|
||||
widget.buttonItem!,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _kToolbarButtonFontStyle.copyWith(
|
||||
color: const CupertinoDynamicColor.withBrightness(
|
||||
color: _isHovered
|
||||
? CupertinoTheme.of(context).primaryContrastingColor
|
||||
: const CupertinoDynamicColor.withBrightness(
|
||||
color: CupertinoColors.black,
|
||||
darkColor: CupertinoColors.white,
|
||||
).resolveFrom(context),
|
||||
),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: MouseRegion(
|
||||
@ -115,7 +120,7 @@ class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDe
|
||||
onExit: _onExit,
|
||||
child: CupertinoButton(
|
||||
alignment: Alignment.centerLeft,
|
||||
borderRadius: null,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
||||
color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
|
||||
minSize: 0.0,
|
||||
onPressed: widget.onPressed,
|
||||
|
@ -83,8 +83,10 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
|
||||
/// {@endtemplate}
|
||||
final ContextMenuButtonItem? buttonItem;
|
||||
|
||||
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.text}
|
||||
/// The text used in the button's label when using
|
||||
/// [CupertinoTextSelectionToolbarButton.text].
|
||||
/// {@endtemplate}
|
||||
final String? text;
|
||||
|
||||
/// Returns the default button label String for the button of the given
|
||||
|
@ -271,7 +271,6 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
|
||||
case TargetPlatform.macOS:
|
||||
return buttonItems.map((ContextMenuButtonItem buttonItem) {
|
||||
return CupertinoDesktopTextSelectionToolbarButton.text(
|
||||
context: context,
|
||||
onPressed: buttonItem.onPressed,
|
||||
text: getButtonLabel(context, buttonItem),
|
||||
);
|
||||
|
@ -3,6 +3,7 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
@ -29,6 +30,46 @@ void main() {
|
||||
expect(pressed, true);
|
||||
});
|
||||
|
||||
testWidgets('keeps contrast with background on hover',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
home: Center(
|
||||
child: CupertinoDesktopTextSelectionToolbarButton.text(
|
||||
text: 'Tap me',
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final BuildContext context =
|
||||
tester.element(find.byType(CupertinoDesktopTextSelectionToolbarButton));
|
||||
|
||||
// The Text color is a CupertinoDynamicColor so we have to compare the color
|
||||
// values instead of just comparing the colors themselves.
|
||||
expect(
|
||||
(tester.firstWidget(find.text('Tap me')) as Text).style!.color!.value,
|
||||
CupertinoColors.black.value,
|
||||
);
|
||||
|
||||
// Hover gesture
|
||||
final TestGesture gesture =
|
||||
await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await gesture.addPointer(location: Offset.zero);
|
||||
addTearDown(gesture.removePointer);
|
||||
await tester.pump();
|
||||
await gesture.moveTo(tester
|
||||
.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The color here should be a standard Color, there's no need to use value.
|
||||
expect(
|
||||
(tester.firstWidget(find.text('Tap me')) as Text).style!.color,
|
||||
CupertinoTheme.of(context).primaryContrastingColor,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
@ -49,7 +90,8 @@ void main() {
|
||||
expect(opacity.opacity.value, 1.0);
|
||||
|
||||
// Make a "down" gesture on the button.
|
||||
final Offset center = tester.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton));
|
||||
final Offset center = tester
|
||||
.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton));
|
||||
final TestGesture gesture = await tester.startGesture(center);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
@ -2,12 +2,120 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('has correct backdrop filters', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
home: Center(
|
||||
child: CupertinoDesktopTextSelectionToolbar(
|
||||
anchor: Offset.zero,
|
||||
children: <Widget>[
|
||||
CupertinoDesktopTextSelectionToolbarButton(
|
||||
child: const Text('Tap me'),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final BackdropFilter toolbarFilter = tester.firstWidget<BackdropFilter>(
|
||||
find.descendant(
|
||||
of: find.byType(CupertinoDesktopTextSelectionToolbar),
|
||||
matching: find.byType(BackdropFilter),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
toolbarFilter.filter.runtimeType,
|
||||
// _ComposeImageFilter is internal so we can't test if its filters are
|
||||
// for blur and saturation, but checking if it's a _ComposeImageFilter
|
||||
// should be enough. Outer and inner parameters don't matter, we just need
|
||||
// a new _ComposeImageFilter to get its runtimeType.
|
||||
//
|
||||
// As web doesn't support ImageFilter.compose, we use just blur when
|
||||
// kIsWeb.
|
||||
kIsWeb
|
||||
? ImageFilter.blur().runtimeType
|
||||
: ImageFilter.compose(
|
||||
outer: ImageFilter.blur(),
|
||||
inner: ImageFilter.blur(),
|
||||
).runtimeType,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('has shadow', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
home: Center(
|
||||
child: CupertinoDesktopTextSelectionToolbar(
|
||||
anchor: Offset.zero,
|
||||
children: <Widget>[
|
||||
CupertinoDesktopTextSelectionToolbarButton(
|
||||
child: const Text('Tap me'),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final DecoratedBox decoratedBox = tester.firstWidget<DecoratedBox>(
|
||||
find.descendant(
|
||||
of: find.byType(CupertinoDesktopTextSelectionToolbar),
|
||||
matching: find.byType(DecoratedBox),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
(decoratedBox.decoration as BoxDecoration).boxShadow,
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('is translucent', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
home: Center(
|
||||
child: CupertinoDesktopTextSelectionToolbar(
|
||||
anchor: Offset.zero,
|
||||
children: <Widget>[
|
||||
CupertinoDesktopTextSelectionToolbarButton(
|
||||
child: const Text('Tap me'),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final DecoratedBox decoratedBox = tester
|
||||
.widgetList<DecoratedBox>(
|
||||
find.descendant(
|
||||
of: find.byType(CupertinoDesktopTextSelectionToolbar),
|
||||
matching: find.byType(DecoratedBox),
|
||||
),
|
||||
)
|
||||
// The second DecoratedBox should be the one with color.
|
||||
.elementAt(1);
|
||||
|
||||
expect(
|
||||
(decoratedBox.decoration as BoxDecoration).color!.opacity,
|
||||
lessThan(1.0),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('positions itself at the anchor', (WidgetTester tester) async {
|
||||
// An arbitrary point on the screen to position at.
|
||||
const Offset anchor = Offset(30.0, 40.0);
|
||||
@ -29,7 +137,8 @@ void main() {
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)),
|
||||
tester
|
||||
.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)),
|
||||
// Greater than due to padding internal to the toolbar.
|
||||
greaterThan(anchor),
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user