iOS TextSelectionToolbar fidelity (#127757)

CupertinoTextSelectionToolbar is different from the native one, with some UI and UX issues. More details on the linked issue.

https://github.com/flutter/flutter/issues/127756

Currently the only problem that I listed on the linked issue that I couldn't fix was the horizontal scrolling, but to workaround this I added a GestureDetector to change pages when swiping the toolbar. It's not exactly the same as native as there is no scroll animation, but it works.

I'm creating this PR a little early to have some feedback as these changes were more complex than the ones in my last PR. Probably best if @justinmc is involved 😅

|Version|Video|
|-|-|
|Flutter Old|<video src="https://github.com/flutter/flutter/assets/12024080/7cf81075-46ec-4970-b118-cc27b60ddac0"></video>|
|Flutter New|<video src="https://github.com/flutter/flutter/assets/12024080/c9e27a53-f94c-4cb0-9b76-e47b73841dcb"></video>|
|Native|<video src="https://github.com/flutter/flutter/assets/12024080/468c7d5b-ba93-4bd4-8f6e-8ec2644b9866"></video>|
This commit is contained in:
Luccas Clezar 2023-06-27 14:50:07 -03:00 committed by GitHub
parent 3e66c86ae4
commit a90c33fd61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 446 additions and 276 deletions

View file

@ -15,7 +15,7 @@ import 'theme.dart';
// Values extracted from https://developer.apple.com/design/resources/.
// The height of the toolbar, including the arrow.
const double _kToolbarHeight = 43.0;
const double _kToolbarHeight = 45.0;
// Vertical distance between the tip of the arrow and the line of text the arrow
// is pointing to. The value used here is eyeballed.
const double _kToolbarContentDistance = 8.0;
@ -28,15 +28,29 @@ const double _kArrowScreenPadding = 26.0;
// Values extracted from https://developer.apple.com/design/resources/.
const Radius _kToolbarBorderRadius = Radius.circular(8);
const CupertinoDynamicColor _kToolbarDividerColor = CupertinoDynamicColor.withBrightness(
// This value was extracted from a screenshot of iOS 16.0.3, as light mode
// didn't appear in the Apple design resources assets linked below.
color: Color(0xFFB6B6B6),
// Color extracted from https://developer.apple.com/design/resources/.
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
darkColor: Color(0xFF808080),
// Color was measured from a screenshot of iOS 16.0.2
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFF6F6F6),
darkColor: Color(0xFF222222),
);
const double _kToolbarChevronSize = 10;
const double _kToolbarChevronThickness = 2;
// Color was measured from a screenshot of iOS 16.0.2.
const CupertinoDynamicColor _kToolbarDividerColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFD6D6D6),
darkColor: Color(0xFF424242),
);
const CupertinoDynamicColor _kToolbarTextColor = CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
);
const Duration _kToolbarTransitionDuration = Duration(milliseconds: 125);
/// The type for a Function that builds a toolbar's container with the given
/// child.
///
@ -54,13 +68,6 @@ typedef CupertinoToolbarBuilder = Widget Function(
Widget child,
);
class _CupertinoToolbarButtonDivider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(width: 1.0 / MediaQuery.devicePixelRatioOf(context));
}
}
/// An iOS-style text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
@ -117,29 +124,14 @@ class CupertinoTextSelectionToolbar extends StatelessWidget {
/// * [TextSelectionToolbar], which uses this same value as well.
static const double kToolbarScreenPadding = 8.0;
// Add the visual vertical line spacer between children buttons.
static List<Widget> _addChildrenSpacers(List<Widget> children) {
final List<Widget> nextChildren = <Widget>[];
for (int i = 0; i < children.length; i++) {
final Widget child = children[i];
if (i != 0) {
nextChildren.add(_CupertinoToolbarButtonDivider());
}
nextChildren.add(child);
}
return nextChildren;
}
// Builds a toolbar just like the default iOS toolbar, with the right color
// background and a rounded cutout with an arrow.
static Widget _defaultToolbarBuilder(BuildContext context, Offset anchor, bool isAbove, Widget child) {
final Widget outputChild = _CupertinoTextSelectionToolbarShape(
anchor: anchor,
isAbove: isAbove,
child: DecoratedBox(
decoration: BoxDecoration(
color: _kToolbarDividerColor.resolveFrom(context),
),
child: ColoredBox(
color: _kToolbarBackgroundColor.resolveFrom(context),
child: child,
),
);
@ -209,7 +201,7 @@ class CupertinoTextSelectionToolbar extends StatelessWidget {
anchor: fitsAbove ? anchorAboveAdjusted : anchorBelowAdjusted,
isAbove: fitsAbove,
toolbarBuilder: toolbarBuilder,
children: _addChildrenSpacers(children),
children: children,
),
),
);
@ -449,19 +441,43 @@ class _CupertinoTextSelectionToolbarContent extends StatefulWidget {
class _CupertinoTextSelectionToolbarContentState extends State<_CupertinoTextSelectionToolbarContent> with TickerProviderStateMixin {
// Controls the fading of the buttons within the menu during page transitions.
late AnimationController _controller;
int _page = 0;
int? _nextPage;
int _page = 0;
final GlobalKey _toolbarItemsKey = GlobalKey();
void _onHorizontalDragEnd(DragEndDetails details) {
final double? velocity = details.primaryVelocity;
if (velocity != null && velocity != 0) {
if (velocity > 0) {
_handlePreviousPage();
} else {
_handleNextPage();
}
}
}
void _handleNextPage() {
_controller.reverse();
_controller.addStatusListener(_statusListener);
_nextPage = _page + 1;
final RenderBox? renderToolbar =
_toolbarItemsKey.currentContext?.findRenderObject() as RenderBox?;
if (renderToolbar is _RenderCupertinoTextSelectionToolbarItems && renderToolbar.hasNextPage) {
_controller.reverse();
_controller.addStatusListener(_statusListener);
_nextPage = _page + 1;
}
}
void _handlePreviousPage() {
_controller.reverse();
_controller.addStatusListener(_statusListener);
_nextPage = _page - 1;
final RenderBox? renderToolbar =
_toolbarItemsKey.currentContext?.findRenderObject() as RenderBox?;
if (renderToolbar is _RenderCupertinoTextSelectionToolbarItems && renderToolbar.hasPreviousPage) {
_controller.reverse();
_controller.addStatusListener(_statusListener);
_nextPage = _page - 1;
}
}
void _statusListener(AnimationStatus status) {
@ -484,7 +500,7 @@ class _CupertinoTextSelectionToolbarContentState extends State<_CupertinoTextSel
value: 1.0,
vsync: this,
// This was eyeballed on a physical iOS device running iOS 13.
duration: const Duration(milliseconds: 150),
duration: _kToolbarTransitionDuration,
);
}
@ -506,53 +522,137 @@ class _CupertinoTextSelectionToolbarContentState extends State<_CupertinoTextSel
super.dispose();
}
Widget _createChevron({required bool isLeft}) {
final Color color = _kToolbarTextColor.resolveFrom(context);
return IgnorePointer(
child: Center(
// If widthFactor is not set to 0, the button is given unbounded width.
widthFactor: 0,
child: CustomPaint(
painter: isLeft
? _LeftCupertinoChevronPainter(color: color)
: _RightCupertinoChevronPainter(color: color),
size: const Size.square(_kToolbarChevronSize),
),
),
);
}
@override
Widget build(BuildContext context) {
return widget.toolbarBuilder(context, widget.anchor, widget.isAbove, FadeTransition(
opacity: _controller,
child: _CupertinoTextSelectionToolbarItems(
page: _page,
backButton: CupertinoTextSelectionToolbarButton.text(
onPressed: _handlePreviousPage,
text: '',
child: AnimatedSize(
duration: _kToolbarTransitionDuration,
curve: Curves.decelerate,
child: GestureDetector(
onHorizontalDragEnd: _onHorizontalDragEnd,
child: _CupertinoTextSelectionToolbarItems(
key: _toolbarItemsKey,
page: _page,
backButton: CupertinoTextSelectionToolbarButton(
onPressed: _handlePreviousPage,
child: _createChevron(isLeft: true),
),
dividerColor: _kToolbarDividerColor.resolveFrom(context),
dividerWidth: 1.0 / MediaQuery.devicePixelRatioOf(context),
nextButton: CupertinoTextSelectionToolbarButton(
onPressed: _handleNextPage,
child: _createChevron(isLeft: false),
),
children: widget.children,
),
),
dividerWidth: 1.0 / MediaQuery.devicePixelRatioOf(context),
nextButton: CupertinoTextSelectionToolbarButton.text(
onPressed: _handleNextPage,
text: '',
),
nextButtonDisabled: const CupertinoTextSelectionToolbarButton.text(
text: '',
),
children: widget.children,
),
));
}
}
// These classes help to test the chevrons. As _CupertinoChevronPainter must be
// private, it's possible to check the runtimeType of each chevron to know if
// they should be pointing left or right.
class _LeftCupertinoChevronPainter extends _CupertinoChevronPainter {
_LeftCupertinoChevronPainter({required super.color}) : super(isLeft: true);
}
class _RightCupertinoChevronPainter extends _CupertinoChevronPainter {
_RightCupertinoChevronPainter({required super.color}) : super(isLeft: false);
}
abstract class _CupertinoChevronPainter extends CustomPainter {
_CupertinoChevronPainter({
required this.color,
required this.isLeft,
});
final Color color;
/// If this is true the chevron will point left, else it will point right.
final bool isLeft;
@override
void paint(Canvas canvas, Size size) {
assert(size.height == size.width, 'size must have the same height and width');
final double iconSize = size.height;
// The chevron is half of a square rotated 45˚, so it needs a margin of 1/4
// its size on each side to be centered horizontally.
//
// If pointing left, it means the left half of a square is being used and
// the offset is positive. If pointing right, the right half is being used
// and the offset is negative.
final Offset centerOffset = Offset(
iconSize / 4 * (isLeft ? 1 : -1),
0,
);
final Offset firstPoint = Offset(iconSize / 2, 0) + centerOffset;
final Offset middlePoint = Offset(isLeft ? 0 : iconSize, iconSize / 2) + centerOffset;
final Offset lowerPoint = Offset(iconSize / 2, iconSize) + centerOffset;
final Paint paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = _kToolbarChevronThickness
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
// `drawLine` is used here because it's testable. When using `drawPath`,
// there's no way to test that the chevron points to the correct side.
canvas.drawLine(firstPoint, middlePoint, paint);
canvas.drawLine(middlePoint, lowerPoint, paint);
}
@override
bool shouldRepaint(_CupertinoChevronPainter oldDelegate) =>
oldDelegate.color != color || oldDelegate.isLeft != isLeft;
}
// The custom RenderObjectWidget that, together with
// _RenderCupertinoTextSelectionToolbarItems and
// _CupertinoTextSelectionToolbarItemsElement, paginates the menu items.
class _CupertinoTextSelectionToolbarItems extends RenderObjectWidget {
_CupertinoTextSelectionToolbarItems({
super.key,
required this.page,
required this.children,
required this.backButton,
required this.dividerColor,
required this.dividerWidth,
required this.nextButton,
required this.nextButtonDisabled,
}) : assert(children.isNotEmpty);
final Widget backButton;
final List<Widget> children;
final Color dividerColor;
final double dividerWidth;
final Widget nextButton;
final Widget nextButtonDisabled;
final int page;
@override
_RenderCupertinoTextSelectionToolbarItems createRenderObject(BuildContext context) {
return _RenderCupertinoTextSelectionToolbarItems(
dividerColor: dividerColor,
dividerWidth: dividerWidth,
page: page,
);
@ -562,6 +662,7 @@ class _CupertinoTextSelectionToolbarItems extends RenderObjectWidget {
void updateRenderObject(BuildContext context, _RenderCupertinoTextSelectionToolbarItems renderObject) {
renderObject
..page = page
..dividerColor = dividerColor
..dividerWidth = dividerWidth;
}
@ -591,8 +692,6 @@ class _CupertinoTextSelectionToolbarItemsElement extends RenderObjectElement {
renderObject.backButton = child;
case _CupertinoTextSelectionToolbarItemsSlot.nextButton:
renderObject.nextButton = child;
case _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled:
renderObject.nextButtonDisabled = child;
}
}
@ -683,7 +782,6 @@ class _CupertinoTextSelectionToolbarItemsElement extends RenderObjectElement {
final _CupertinoTextSelectionToolbarItems toolbarItems = widget as _CupertinoTextSelectionToolbarItems;
_mountChild(toolbarItems.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton);
_mountChild(toolbarItems.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
_mountChild(toolbarItems.nextButtonDisabled, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
// Mount list children.
_children = List<Element>.filled(toolbarItems.children.length, _NullElement.instance);
@ -718,7 +816,6 @@ class _CupertinoTextSelectionToolbarItemsElement extends RenderObjectElement {
final _CupertinoTextSelectionToolbarItems toolbarItems = widget as _CupertinoTextSelectionToolbarItems;
_mountChild(toolbarItems.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton);
_mountChild(toolbarItems.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
_mountChild(toolbarItems.nextButtonDisabled, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
// Update list children.
_children = updateChildren(_children, toolbarItems.children, forgottenChildren: _forgottenChildren);
@ -729,14 +826,19 @@ class _CupertinoTextSelectionToolbarItemsElement extends RenderObjectElement {
// The custom RenderBox that helps paginate the menu items.
class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with ContainerRenderObjectMixin<RenderBox, ToolbarItemsParentData>, RenderBoxContainerDefaultsMixin<RenderBox, ToolbarItemsParentData> {
_RenderCupertinoTextSelectionToolbarItems({
required Color dividerColor,
required double dividerWidth,
required int page,
}) : _dividerWidth = dividerWidth,
}) : _dividerColor = dividerColor,
_dividerWidth = dividerWidth,
_page = page,
super();
final Map<_CupertinoTextSelectionToolbarItemsSlot, RenderBox> slottedChildren = <_CupertinoTextSelectionToolbarItemsSlot, RenderBox>{};
late bool hasNextPage;
late bool hasPreviousPage;
RenderBox? _updateChild(RenderBox? oldChild, RenderBox? newChild, _CupertinoTextSelectionToolbarItemsSlot slot) {
if (oldChild != null) {
dropChild(oldChild);
@ -750,7 +852,7 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
}
bool _isSlottedChild(RenderBox child) {
return child == _backButton || child == _nextButton || child == _nextButtonDisabled;
return child == _backButton || child == _nextButton;
}
int _page;
@ -763,6 +865,16 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
markNeedsLayout();
}
Color _dividerColor;
Color get dividerColor => _dividerColor;
set dividerColor(Color value) {
if (value == _dividerColor) {
return;
}
_dividerColor = value;
markNeedsLayout();
}
double _dividerWidth;
double get dividerWidth => _dividerWidth;
set dividerWidth(double value) {
@ -785,12 +897,6 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
_nextButton = _updateChild(_nextButton, value, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
}
RenderBox? _nextButtonDisabled;
RenderBox? get nextButtonDisabled => _nextButtonDisabled;
set nextButtonDisabled(RenderBox? value) {
_nextButtonDisabled = _updateChild(_nextButtonDisabled, value, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
}
@override
void performLayout() {
if (firstChild == null) {
@ -801,7 +907,6 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
// Layout slotted children.
_backButton!.layout(constraints.loosen(), parentUsesSize: true);
_nextButton!.layout(constraints.loosen(), parentUsesSize: true);
_nextButtonDisabled!.layout(constraints.loosen(), parentUsesSize: true);
final double subsequentPageButtonsWidth =
_backButton!.size.width + _nextButton!.size.width;
@ -828,7 +933,7 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
// If this is the last child, it's ok to fit without a forward button.
// Note childCount doesn't include slotted children which come before the list ones.
paginationButtonsWidth =
i == childCount + 2 ? 0.0 : _nextButton!.size.width;
i == childCount + 1 ? 0.0 : _nextButton!.size.width;
} else {
paginationButtonsWidth = subsequentPageButtonsWidth;
}
@ -881,17 +986,10 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
if (currentPage > 0) {
final ToolbarItemsParentData nextButtonParentData =
_nextButton!.parentData! as ToolbarItemsParentData;
final ToolbarItemsParentData nextButtonDisabledParentData =
_nextButtonDisabled!.parentData! as ToolbarItemsParentData;
final ToolbarItemsParentData backButtonParentData =
_backButton!.parentData! as ToolbarItemsParentData;
// The forward button always shows if there is more than one page, even on
// the last page (it's just disabled).
if (page == currentPage) {
nextButtonDisabledParentData.offset = Offset(toolbarWidth, 0.0);
nextButtonDisabledParentData.shouldPaint = true;
toolbarWidth += nextButtonDisabled!.size.width;
} else {
// The forward button only shows when there's a page after this one.
if (page != currentPage) {
nextButtonParentData.offset = Offset(toolbarWidth, 0.0);
nextButtonParentData.shouldPaint = true;
toolbarWidth += nextButton!.size.width;
@ -903,6 +1001,11 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
// already been taken care of when laying out the children to
// accommodate the back button.
}
// Update previous/next page values so that we can check in the horizontal
// drag gesture callback if it's possible to navigate.
hasNextPage = page != currentPage;
hasPreviousPage = page > 0;
} else {
// No divider for the next button when there's only one page.
toolbarWidth -= dividerWidth;
@ -920,6 +1023,18 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
if (childParentData.shouldPaint) {
final Offset childOffset = childParentData.offset + offset;
context.paintChild(child, childOffset);
// backButton is a slotted child and is not in the children list, so its
// childParentData.nextSibling is null. So either when there's a
// nextSibling or when child is the backButton, draw a divider to the
// child's right.
if (childParentData.nextSibling != null || child == backButton) {
context.canvas.drawLine(
Offset(child.size.width, 0) + childOffset,
Offset(child.size.width, child.size.height) + childOffset,
Paint()..color = dividerColor,
);
}
}
});
}
@ -977,9 +1092,6 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
if (hitTestChild(nextButton, result, position: position)) {
return true;
}
if (hitTestChild(nextButtonDisabled, result, position: position)) {
return true;
}
return false;
}
@ -1023,9 +1135,6 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
if (_nextButton != null) {
visitor(_nextButton!);
}
if (_nextButtonDisabled != null) {
visitor(_nextButtonDisabled!);
}
// Visit the list children.
super.visitChildren(visitor);
}
@ -1051,8 +1160,6 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
value.add(child.toDiagnosticsNode(name: 'back button'));
} else if (child == nextButton) {
value.add(child.toDiagnosticsNode(name: 'next button'));
} else if (child == nextButtonDisabled) {
value.add(child.toDiagnosticsNode(name: 'next button disabled'));
// List children.
} else {
@ -1068,7 +1175,6 @@ class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with Container
enum _CupertinoTextSelectionToolbarItemsSlot {
backButton,
nextButton,
nextButtonDisabled,
}
class _NullElement extends Element {

View file

@ -11,30 +11,26 @@ import 'localizations.dart';
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
fontSize: 15.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
);
// Colors extracted from https://developer.apple.com/design/resources/.
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
// This value was extracted from a screenshot of iOS 16.0.3, as light mode
// didn't appear in the Apple design resources assets linked above.
color: Color(0xEBF7F7F7),
darkColor: Color(0xEB202020),
);
const CupertinoDynamicColor _kToolbarTextColor = CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
);
// Eyeballed value.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 16.0, horizontal: 18.0);
const CupertinoDynamicColor _kToolbarPressedColor = CupertinoDynamicColor.withBrightness(
color: Color(0x10000000),
darkColor: Color(0x10FFFFFF),
);
// Value measured from screenshot of iOS 16.0.2
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 18.0, horizontal: 16.0);
/// A button in the style of the iOS text selection toolbar buttons.
class CupertinoTextSelectionToolbarButton extends StatelessWidget {
class CupertinoTextSelectionToolbarButton extends StatefulWidget {
/// Create an instance of [CupertinoTextSelectionToolbarButton].
///
/// [child] cannot be null.
@ -114,25 +110,64 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
final Widget child = this.child ?? Text(
text ?? getButtonLabel(context, buttonItem!),
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: onPressed != null
? _kToolbarTextColor.resolveFrom(context)
: CupertinoColors.inactiveGray,
),
);
State<StatefulWidget> createState() => _CupertinoTextSelectionToolbarButtonState();
}
return CupertinoButton(
class _CupertinoTextSelectionToolbarButtonState extends State<CupertinoTextSelectionToolbarButton> {
bool isPressed = false;
void _onTapDown(TapDownDetails details) {
setState(() => isPressed = true);
}
void _onTapUp(TapUpDetails details) {
setState(() => isPressed = false);
widget.onPressed?.call();
}
void _onTapCancel() {
setState(() => isPressed = false);
}
@override
Widget build(BuildContext context) {
final Widget child = CupertinoButton(
color: isPressed
? _kToolbarPressedColor.resolveFrom(context)
: const Color(0x00000000),
borderRadius: null,
color: _kToolbarBackgroundColor,
disabledColor: _kToolbarBackgroundColor,
onPressed: onPressed,
disabledColor: const Color(0x00000000),
// This CupertinoButton does not actually handle the onPressed callback,
// this is only here to correctly enable/disable the button (see
// GestureDetector comment below).
onPressed: widget.onPressed,
padding: _kToolbarButtonPadding,
pressedOpacity: onPressed == null ? 1.0 : 0.7,
child: child,
// There's no foreground fade on iOS toolbar anymore, just the background
// is darkened.
pressedOpacity: 1.0,
child: widget.child ?? Text(
widget.text ?? CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!),
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: widget.onPressed != null
? _kToolbarTextColor.resolveFrom(context)
: CupertinoColors.inactiveGray,
),
),
);
if (widget.onPressed != null) {
// As it's needed to change the CupertinoButton's backgroundColor when
// pressed, not its opacity, this GestureDetector handles both the
// onPressed callback and the backgroundColor change.
return GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
child: child,
);
} else {
return child;
}
}
}

View file

@ -1545,7 +1545,7 @@ void main() {
Text text = tester.widget<Text>(find.text('Paste'));
expect(text.style!.color!.value, CupertinoColors.black.value);
expect(text.style!.fontSize, 14);
expect(text.style!.fontSize, 15);
expect(text.style!.letterSpacing, -0.15);
expect(text.style!.fontWeight, FontWeight.w400);
@ -1577,7 +1577,7 @@ void main() {
text = tester.widget<Text>(find.text('Paste'));
// The toolbar buttons' text are still the same style.
expect(text.style!.color!.value, CupertinoColors.white.value);
expect(text.style!.fontSize, 14);
expect(text.style!.fontSize, 15);
expect(text.style!.letterSpacing, -0.15);
expect(text.style!.fontWeight, FontWeight.w400);
}, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu.
@ -6537,7 +6537,7 @@ void main() {
topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8, epsilon: 0.01),
leftMatcher: moreOrLessEquals(8),
rightMatcher: lessThanOrEqualTo(400 - 8),
bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8 + 43, epsilon: 0.01),
bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8 + 45, epsilon: 0.01),
),
),
);
@ -6597,7 +6597,7 @@ void main() {
pathMatcher: PathBoundsMatcher(
topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8, epsilon: 0.01),
rightMatcher: moreOrLessEquals(400.0 - 8),
bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8 + 43, epsilon: 0.01),
bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8 + 45, epsilon: 0.01),
leftMatcher: greaterThanOrEqualTo(8),
),
),
@ -6650,7 +6650,7 @@ void main() {
paints..clipPath(
pathMatcher: PathBoundsMatcher(
bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy - 8 - lineHeight, epsilon: 0.01),
topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy - 8 - lineHeight - 43, epsilon: 0.01),
topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy - 8 - lineHeight - 45, epsilon: 0.01),
rightMatcher: lessThanOrEqualTo(400 - 8),
leftMatcher: greaterThanOrEqualTo(8),
),
@ -6719,7 +6719,7 @@ void main() {
paints..clipPath(
pathMatcher: PathBoundsMatcher(
bottomMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight, epsilon: 0.01),
topMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight - 43, epsilon: 0.01),
topMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight - 45, epsilon: 0.01),
rightMatcher: lessThanOrEqualTo(400 - 8),
leftMatcher: greaterThanOrEqualTo(8),
),
@ -6792,7 +6792,7 @@ void main() {
paints..clipPath(
pathMatcher: PathBoundsMatcher(
bottomMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight, epsilon: 0.01),
topMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight - 43, epsilon: 0.01),
topMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight - 45, epsilon: 0.01),
rightMatcher: lessThanOrEqualTo(400 - 8),
leftMatcher: greaterThanOrEqualTo(8),
),

View file

@ -60,18 +60,6 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard();
// Returns true iff the button is visually enabled.
bool appearsEnabled(WidgetTester tester, String text) {
final CupertinoButton button = tester.widget<CupertinoButton>(
find.ancestor(
of: find.text(text),
matching: find.byType(CupertinoButton),
),
);
// Disabled buttons have no opacity change when pressed.
return button.pressedOpacity! < 1.0;
}
List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
return points.map<TextSelectionPoint>((TextSelectionPoint point) {
return TextSelectionPoint(
@ -191,6 +179,15 @@ void main() {
});
group('Text selection menu overflow (iOS)', () {
Finder findOverflowNextButton() => find.byWidgetPredicate((Widget widget) =>
widget is CustomPaint &&
'${widget.painter?.runtimeType}' == '_RightCupertinoChevronPainter',
);
Finder findOverflowBackButton() => find.byWidgetPredicate((Widget widget) =>
widget is CustomPaint &&
'${widget.painter?.runtimeType}' == '_LeftCupertinoChevronPainter',
);
testWidgets('All menu items show when they fit.', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'abc def ghi');
await tester.pumpWidget(CupertinoApp(
@ -216,8 +213,8 @@ void main() {
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
// Long press on an empty space to show the selection menu.
await tester.longPressAt(textOffsetToPosition(tester, 4));
@ -226,8 +223,8 @@ void main() {
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Select All'), findsOneWidget);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
// Double tap to select a word and show the full selection menu.
final Offset textOffset = textOffsetToPosition(tester, 1);
@ -241,8 +238,8 @@ void main() {
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
},
skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
@ -273,8 +270,8 @@ void main() {
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
// Double tap to select a word and show the selection menu.
final Offset textOffset = textOffsetToPosition(tester, 1);
@ -288,32 +285,29 @@ void main() {
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
// Tapping the next button shows the overflowing button.
await tester.tap(find.text(''));
// Tapping the next button shows the overflowing button and the next
// button is hidden as the last page is shown.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), false);
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsNothing);
// Tapping the back button shows the first page again.
await tester.tap(find.text(''));
// Tapping the back button shows the first page again with the next button.
await tester.tapAt(tester.getCenter(findOverflowBackButton()));
await tester.pumpAndSettle();
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
},
skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
@ -341,13 +335,13 @@ void main() {
));
// Initially, the menu isn't shown at all.
expect(find.byType(CupertinoButton), findsNothing);
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
// Double tap to select a word and show the selection menu.
final Offset textOffset = textOffsetToPosition(tester, 1);
@ -357,65 +351,58 @@ void main() {
await tester.pumpAndSettle();
// Only the first button fits, and a next button is shown.
expect(find.byType(CupertinoButton), findsNWidgets(2));
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(2));
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
// Tapping the next button shows Copy.
await tester.tap(find.text(''));
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.byType(CupertinoButton), findsNWidgets(3));
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(3));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsOneWidget);
// Tapping the next button again shows Paste.
await tester.tap(find.text(''));
// Tapping the next button again shows Paste and hides the next button as
// the last page is shown.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.byType(CupertinoButton), findsNWidgets(3));
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(2));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), false);
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsNothing);
// Tapping the back button shows the second page again.
await tester.tap(find.text(''));
// Tapping the back button shows the second page again with the next button.
await tester.tapAt(tester.getCenter(findOverflowBackButton()));
await tester.pumpAndSettle();
expect(find.byType(CupertinoButton), findsNWidgets(3));
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(3));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsOneWidget);
// Tapping the back button again shows the first page again.
await tester.tap(find.text(''));
await tester.tapAt(tester.getCenter(findOverflowBackButton()));
await tester.pumpAndSettle();
expect(find.byType(CupertinoButton), findsNWidgets(2));
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(2));
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
},
skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
@ -452,8 +439,8 @@ void main() {
expect(find.text(_longLocalizations.copyButtonLabel), findsNothing);
expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing);
expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
// Long press on an empty space to show the selection menu, with only the
// paste button visible.
@ -463,21 +450,18 @@ void main() {
expect(find.text(_longLocalizations.copyButtonLabel), findsNothing);
expect(find.text(_longLocalizations.pasteButtonLabel), findsOneWidget);
expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
// Tap next to go to the second and final page.
await tester.tap(find.text(''));
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.text(_longLocalizations.cutButtonLabel), findsNothing);
expect(find.text(_longLocalizations.copyButtonLabel), findsNothing);
expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing);
expect(find.text(_longLocalizations.selectAllButtonLabel), findsOneWidget);
expect(find.text(''), findsOneWidget);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(appearsEnabled(tester, ''), false);
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsNothing);
// Tap select all to show the full selection menu.
await tester.tap(find.text(_longLocalizations.selectAllButtonLabel));
@ -488,56 +472,48 @@ void main() {
expect(find.text(_longLocalizations.copyButtonLabel), findsNothing);
expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing);
expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
// Tap next to go to the second page.
await tester.tap(find.text(''));
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.text(_longLocalizations.cutButtonLabel), findsNothing);
expect(find.text(_longLocalizations.copyButtonLabel), findsOneWidget);
expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing);
expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing);
expect(find.text(''), findsOneWidget);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsOneWidget);
// Tap next to go to the third and final page.
await tester.tap(find.text(''));
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.text(_longLocalizations.cutButtonLabel), findsNothing);
expect(find.text(_longLocalizations.copyButtonLabel), findsNothing);
expect(find.text(_longLocalizations.pasteButtonLabel), findsOneWidget);
expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing);
expect(find.text(''), findsOneWidget);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(appearsEnabled(tester, ''), false);
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsNothing);
// Tap back to go to the second page again.
await tester.tap(find.text(''));
await tester.tapAt(tester.getCenter(findOverflowBackButton()));
await tester.pumpAndSettle();
expect(find.text(_longLocalizations.cutButtonLabel), findsNothing);
expect(find.text(_longLocalizations.copyButtonLabel), findsOneWidget);
expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing);
expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing);
expect(find.text(''), findsOneWidget);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsOneWidget);
// Tap back to go to the first page again.
await tester.tap(find.text(''));
await tester.tapAt(tester.getCenter(findOverflowBackButton()));
await tester.pumpAndSettle();
expect(find.text(_longLocalizations.cutButtonLabel), findsOneWidget);
expect(find.text(_longLocalizations.copyButtonLabel), findsNothing);
expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing);
expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsOneWidget);
expect(appearsEnabled(tester, ''), true);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
},
skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
@ -572,8 +548,8 @@ void main() {
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
// Long press on an space to show the selection menu.
await tester.longPressAt(textOffsetToPosition(tester, 1));
@ -582,8 +558,8 @@ void main() {
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Select All'), findsOneWidget);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
// Tap to select all.
await tester.tap(find.text('Select All'));
@ -594,8 +570,8 @@ void main() {
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Select All'), findsNothing);
expect(find.text(''), findsNothing);
expect(find.text(''), findsNothing);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsNothing);
// The menu appears at the top of the visible selection.
final Offset selectionOffset = tester
@ -603,8 +579,8 @@ void main() {
final Offset textFieldOffset =
tester.getTopLeft(find.byType(CupertinoTextField));
// 7.0 + 43.0 + 8.0 - 8.0 = _kToolbarArrowSize + _kToolbarHeight + _kToolbarContentDistance - padding
expect(selectionOffset.dy + 7.0 + 43.0 + 8.0 - 8.0, equals(textFieldOffset.dy));
// 7.0 + 45.0 + 8.0 - 8.0 = _kToolbarArrowSize + _kToolbarHeight + _kToolbarContentDistance - padding
expect(selectionOffset.dy + 7.0 + 45.0 + 8.0 - 8.0, equals(textFieldOffset.dy));
},
skip: isBrowser, // [intended] the selection menu isn't required by web
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),

View file

@ -29,7 +29,7 @@ void main() {
expect(pressed, true);
});
testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async {
testWidgets('background darkens when pressed', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Center(
@ -41,35 +41,38 @@ void main() {
),
);
// Original at full opacity.
FadeTransition opacity = tester.widget(find.descendant(
of: find.byType(CupertinoTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
// Original with transparent background.
DecoratedBox decoratedBox = tester.widget(find.descendant(
of: find.byType(CupertinoButton),
matching: find.byType(DecoratedBox),
));
expect(opacity.opacity.value, 1.0);
BoxDecoration boxDecoration = decoratedBox.decoration as BoxDecoration;
expect(boxDecoration.color, const Color(0x00000000));
// Make a "down" gesture on the button.
final Offset center = tester.getCenter(find.byType(CupertinoTextSelectionToolbarButton));
final TestGesture gesture = await tester.startGesture(center);
await tester.pumpAndSettle();
// Opacity reduces during the down gesture.
opacity = tester.widget(find.descendant(
// When pressed, the background darkens.
decoratedBox = tester.widget(find.descendant(
of: find.byType(CupertinoTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
matching: find.byType(DecoratedBox),
));
expect(opacity.opacity.value, 0.7);
boxDecoration = decoratedBox.decoration as BoxDecoration;
expect(boxDecoration.color!.value, const Color(0x10000000).value);
// Release the down gesture.
await gesture.up();
await tester.pumpAndSettle();
// Opacity is back to normal.
opacity = tester.widget(find.descendant(
// Color is back to transparent.
decoratedBox = tester.widget(find.descendant(
of: find.byType(CupertinoTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
matching: find.byType(DecoratedBox),
));
expect(opacity.opacity.value, 1.0);
boxDecoration = decoratedBox.decoration as BoxDecoration;
expect(boxDecoration.color, const Color(0x00000000));
});
testWidgets('passing null to onPressed disables the button', (WidgetTester tester) async {

View file

@ -6,12 +6,13 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
// These constants are copied from cupertino/text_selection_toolbar.dart.
const double _kArrowScreenPadding = 26.0;
const double _kToolbarContentDistance = 8.0;
const double _kToolbarHeight = 43.0;
const double _kToolbarHeight = 45.0;
// A custom text selection menu that just displays a single custom button.
class _CustomCupertinoTextSelectionControls extends CupertinoTextSelectionControls {
@ -60,9 +61,9 @@ class TestBox extends SizedBox {
static const double itemWidth = 100.0;
}
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
color: Color(0xEBF7F7F7),
darkColor: Color(0xEB202020),
const CupertinoDynamicColor _kToolbarTextColor = CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
);
void main() {
@ -81,8 +82,65 @@ void main() {
// visible part of the toolbar for use in measurements.
Finder findToolbar() => findPrivate('_CupertinoTextSelectionToolbarContent');
Finder findOverflowNextButton() => find.text('');
Finder findOverflowBackButton() => find.text('');
// Check if the middle point of the chevron is pointing left or right.
//
// Offset.dx: a right or left margin (_kToolbarChevronSize / 4 => 2.5) to center the icon horizontally
// Offset.dy: always in the exact vertical center (_kToolbarChevronSize / 2 => 5)
PaintPattern overflowNextPaintPattern() => paints
..line(p1: const Offset(2.5, 0), p2: const Offset(7.5, 5))
..line(p1: const Offset(7.5, 5), p2: const Offset(2.5, 10));
PaintPattern overflowBackPaintPattern() => paints
..line(p1: const Offset(7.5, 0), p2: const Offset(2.5, 5))
..line(p1: const Offset(2.5, 5), p2: const Offset(7.5, 10));
Finder findOverflowNextButton() => find.byWidgetPredicate((Widget widget) =>
widget is CustomPaint &&
'${widget.painter?.runtimeType}' == '_RightCupertinoChevronPainter',
);
Finder findOverflowBackButton() => find.byWidgetPredicate((Widget widget) =>
widget is CustomPaint &&
'${widget.painter?.runtimeType}' == '_LeftCupertinoChevronPainter',
);
testWidgets('chevrons point to the correct side', (WidgetTester tester) async {
// Add enough TestBoxes to need 3 pages.
final List<Widget> children = List<Widget>.generate(15, (int i) => const TestBox());
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: children,
),
),
),
);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
expect(findOverflowNextButton(), overflowNextPaintPattern());
// Tap the overflow next button to show the next page of children.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsOneWidget);
expect(findOverflowBackButton(), overflowBackPaintPattern());
expect(findOverflowNextButton(), overflowNextPaintPattern());
// Tap the overflow next button to show the last page of children.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsNothing);
expect(findOverflowBackButton(), overflowBackPaintPattern());
}, skip: kIsWeb); // Path.combine is not implemented in the HTML backend https://github.com/flutter/flutter/issues/44572
testWidgets('paginates children if they overflow', (WidgetTester tester) async {
late StateSetter setState;
@ -121,22 +179,15 @@ void main() {
expect(findOverflowBackButton(), findsNothing);
// Tap the overflow next button to show the next page of children.
await tester.tap(findOverflowNextButton());
// The next button is hidden as there's no next page.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(1));
expect(findOverflowNextButton(), findsOneWidget);
expect(findOverflowBackButton(), findsOneWidget);
// Tapping the overflow next button again does nothing because it is
// disabled and there are no more children to display.
await tester.tap(findOverflowNextButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(1));
expect(findOverflowNextButton(), findsOneWidget);
expect(findOverflowNextButton(), findsNothing);
expect(findOverflowBackButton(), findsOneWidget);
// Tap the overflow back button to go back to the first page.
await tester.tap(findOverflowBackButton());
await tester.tapAt(tester.getCenter(findOverflowBackButton()));
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(7));
expect(findOverflowNextButton(), findsOneWidget);
@ -157,7 +208,7 @@ void main() {
expect(findOverflowBackButton(), findsNothing);
// Tap the overflow next button to show the second page of children.
await tester.tap(findOverflowNextButton());
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
// With the back button, only six children fit on this page.
expect(find.byType(TestBox), findsNWidgets(6));
@ -165,21 +216,21 @@ void main() {
expect(findOverflowBackButton(), findsOneWidget);
// Tap the overflow next button again to show the third page of children.
await tester.tap(findOverflowNextButton());
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(1));
expect(findOverflowNextButton(), findsOneWidget);
expect(findOverflowNextButton(), findsNothing);
expect(findOverflowBackButton(), findsOneWidget);
// Tap the overflow back button to go back to the second page.
await tester.tap(findOverflowBackButton());
await tester.tapAt(tester.getCenter(findOverflowBackButton()));
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(6));
expect(findOverflowNextButton(), findsOneWidget);
expect(findOverflowBackButton(), findsOneWidget);
// Tap the overflow back button to go back to the first page.
await tester.tap(findOverflowBackButton());
await tester.tapAt(tester.getCenter(findOverflowBackButton()));
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(7));
expect(findOverflowNextButton(), findsOneWidget);
@ -345,13 +396,12 @@ void main() {
final Finder buttonFinder = find.byType(CupertinoButton);
expect(buttonFinder, findsOneWidget);
final Finder decorationFinder = find.descendant(
final Finder textFinder = find.descendant(
of: find.byType(CupertinoButton),
matching: find.byType(DecoratedBox)
matching: find.byType(Text)
);
expect(decorationFinder, findsOneWidget);
final DecoratedBox decoratedBox = tester.widget(decorationFinder);
final BoxDecoration boxDecoration = decoratedBox.decoration as BoxDecoration;
expect(textFinder, findsOneWidget);
final Text text = tester.widget(textFinder);
// Theme brightness is preferred, otherwise MediaQuery brightness is
// used. If both are null, defaults to light.
@ -363,10 +413,10 @@ void main() {
}
expect(
boxDecoration.color!.value,
text.style!.color!.value,
effectiveBrightness == Brightness.dark
? _kToolbarBackgroundColor.darkColor.value
: _kToolbarBackgroundColor.color.value,
? _kToolbarTextColor.darkColor.value
: _kToolbarTextColor.color.value,
);
}, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web.
}
@ -419,7 +469,7 @@ void main() {
of: find.byType(CupertinoTextSelectionToolbar),
matching: find.byType(DecoratedBox),
);
expect(finder, findsNWidgets(2));
expect(finder, findsOneWidget);
DecoratedBox decoratedBox = tester.widget(finder.first);
BoxDecoration boxDecoration = decoratedBox.decoration as BoxDecoration;
List<BoxShadow>? shadows = boxDecoration.boxShadow;