diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index a6f75836e8..37b1aa5e0d 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -136,6 +136,8 @@ export 'src/material/text_field.dart'; export 'src/material/text_form_field.dart'; export 'src/material/text_selection.dart'; export 'src/material/text_selection_theme.dart'; +export 'src/material/text_selection_toolbar.dart'; +export 'src/material/text_selection_toolbar_text_button.dart'; export 'src/material/text_theme.dart'; export 'src/material/theme.dart'; export 'src/material/theme_data.dart'; diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index f9126090b0..644f52420e 100644 --- a/packages/flutter/lib/src/material/text_selection.dart +++ b/packages/flutter/lib/src/material/text_selection.dart @@ -9,730 +9,21 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'colors.dart'; -import 'constants.dart'; import 'debug.dart'; -import 'icon_button.dart'; -import 'icons.dart'; -import 'material.dart'; import 'material_localizations.dart'; -import 'text_button.dart'; import 'text_selection_theme.dart'; +import 'text_selection_toolbar.dart'; +import 'text_selection_toolbar_text_button.dart'; import 'theme.dart'; const double _kHandleSize = 22.0; -// Minimal padding from all edges of the selection toolbar to all edges of the -// viewport. -const double _kToolbarScreenPadding = 8.0; -const double _kToolbarHeight = 44.0; -// Padding when positioning toolbar below selection. +// Padding between the toolbar and the anchor. const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0; const double _kToolbarContentDistance = 8.0; -/// Manages a copy/paste text selection toolbar. -class _TextSelectionToolbar extends StatefulWidget { - const _TextSelectionToolbar({ - Key? key, - required this.clipboardStatus, - required this.handleCut, - required this.handleCopy, - required this.handlePaste, - required this.handleSelectAll, - required this.isAbove, - }) : super(key: key); - - final ClipboardStatusNotifier? clipboardStatus; - final VoidCallback? handleCut; - final VoidCallback? handleCopy; - final VoidCallback? handlePaste; - final VoidCallback? handleSelectAll; - - // When true, the toolbar fits above its anchor and will be positioned there. - final bool isAbove; - - @override - _TextSelectionToolbarState createState() => _TextSelectionToolbarState(); -} - -// Intermediate data used for building menu items with the _getItems method. -class _ItemData { - const _ItemData( - this.onPressed, - this.label, - ) : assert(onPressed != null), - assert(label != null); - - final VoidCallback onPressed; - final String label; -} - -class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with TickerProviderStateMixin { - late ClipboardStatusNotifier _clipboardStatus; - - // Whether or not the overflow menu is open. When it is closed, the menu - // items that don't overflow are shown. When it is open, only the overflowing - // menu items are shown. - bool _overflowOpen = false; - - // The key for _TextSelectionToolbarContainer. - UniqueKey _containerKey = UniqueKey(); - - Widget _getItem(_ItemData itemData, bool isFirst, bool isLast) { - assert(isFirst != null); - assert(isLast != null); - - // TODO(hansmuller): Should be colorScheme.onSurface - final ThemeData theme = Theme.of(context); - final bool isDark = theme.colorScheme.brightness == Brightness.dark; - final Color primary = isDark ? Colors.white : Colors.black87; - - return TextButton( - style: TextButton.styleFrom( - primary: primary, - shape: const RoundedRectangleBorder(), - minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension), - padding: EdgeInsets.only( - // These values were eyeballed to match the native text selection menu - // on a Pixel 2 running Android 10. - left: 9.5 + (isFirst ? 5.0 : 0.0), - right: 9.5 + (isLast ? 5.0 : 0.0), - ), - ), - onPressed: itemData.onPressed, - child: Text(itemData.label), - ); - } - - // Close the menu and reset layout calculations, as in when the menu has - // changed and saved values are no longer relevant. This should be called in - // setState or another context where a rebuild is happening. - void _reset() { - // Change _TextSelectionToolbarContainer's key when the menu changes in - // order to cause it to rebuild. This lets it recalculate its - // saved width for the new set of children, and it prevents AnimatedSize - // from animating the size change. - _containerKey = UniqueKey(); - // If the menu items change, make sure the overflow menu is closed. This - // prevents an empty overflow menu. - _overflowOpen = false; - } - - void _onChangedClipboardStatus() { - setState(() { - // Inform the widget that the value of clipboardStatus has changed. - }); - } - - @override - void initState() { - super.initState(); - _clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier(); - _clipboardStatus.addListener(_onChangedClipboardStatus); - _clipboardStatus.update(); - } - - @override - void didUpdateWidget(_TextSelectionToolbar oldWidget) { - super.didUpdateWidget(oldWidget); - // If the children are changing, the current page should be reset. - if (((widget.handleCut == null) != (oldWidget.handleCut == null)) - || ((widget.handleCopy == null) != (oldWidget.handleCopy == null)) - || ((widget.handlePaste == null) != (oldWidget.handlePaste == null)) - || ((widget.handleSelectAll == null) != (oldWidget.handleSelectAll == null))) { - _reset(); - } - if (oldWidget.clipboardStatus == null && widget.clipboardStatus != null) { - _clipboardStatus.removeListener(_onChangedClipboardStatus); - _clipboardStatus.dispose(); - _clipboardStatus = widget.clipboardStatus!; - } else if (oldWidget.clipboardStatus != null) { - if (widget.clipboardStatus == null) { - _clipboardStatus = ClipboardStatusNotifier(); - _clipboardStatus.addListener(_onChangedClipboardStatus); - oldWidget.clipboardStatus!.removeListener(_onChangedClipboardStatus); - } else if (widget.clipboardStatus != oldWidget.clipboardStatus) { - _clipboardStatus = widget.clipboardStatus!; - _clipboardStatus.addListener(_onChangedClipboardStatus); - oldWidget.clipboardStatus!.removeListener(_onChangedClipboardStatus); - } - } - if (widget.handlePaste != null) { - _clipboardStatus.update(); - } - } - - @override - void dispose() { - super.dispose(); - // When used in an Overlay, this can be disposed after its creator has - // already disposed _clipboardStatus. - if (!_clipboardStatus.disposed) { - _clipboardStatus.removeListener(_onChangedClipboardStatus); - if (widget.clipboardStatus == null) { - _clipboardStatus.dispose(); - } - } - } - - @override - Widget build(BuildContext context) { - // Don't render the menu until the state of the clipboard is known. - if (widget.handlePaste != null - && _clipboardStatus.value == ClipboardStatus.unknown) { - return const SizedBox(width: 0.0, height: 0.0); - } - - final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final List<_ItemData> itemDatas = <_ItemData>[ - if (widget.handleCut != null) - _ItemData(widget.handleCut!, localizations.cutButtonLabel), - if (widget.handleCopy != null) - _ItemData(widget.handleCopy!, localizations.copyButtonLabel), - if (widget.handlePaste != null - && _clipboardStatus.value == ClipboardStatus.pasteable) - _ItemData(widget.handlePaste!, localizations.pasteButtonLabel), - if (widget.handleSelectAll != null) - _ItemData(widget.handleSelectAll!, localizations.selectAllButtonLabel), - ]; - - // If there is no option available, build an empty widget. - if (itemDatas.isEmpty) { - return const SizedBox(width: 0.0, height: 0.0); - } - - return _TextSelectionToolbarContainer( - key: _containerKey, - overflowOpen: _overflowOpen, - child: AnimatedSize( - vsync: this, - // This duration was eyeballed on a Pixel 2 emulator running Android - // API 28. - duration: const Duration(milliseconds: 140), - child: Material( - // This value was eyeballed to match the native text selection menu on - // a Pixel 2 running Android 10. - borderRadius: const BorderRadius.all(Radius.circular(7.0)), - clipBehavior: Clip.antiAlias, - elevation: 1.0, - type: MaterialType.card, - child: _TextSelectionToolbarItems( - isAbove: widget.isAbove, - overflowOpen: _overflowOpen, - children: [ - // The navButton that shows and hides the overflow menu is the - // first child. - Material( - type: MaterialType.card, - child: IconButton( - // TODO(justinmc): This should be an AnimatedIcon, but - // AnimatedIcons doesn't yet support arrow_back to more_vert. - // https://github.com/flutter/flutter/issues/51209 - icon: Icon(_overflowOpen ? Icons.arrow_back : Icons.more_vert), - onPressed: () { - setState(() { - _overflowOpen = !_overflowOpen; - }); - }, - tooltip: _overflowOpen - ? localizations.backButtonTooltip - : localizations.moreButtonTooltip, - ), - ), - for (int i = 0; i < itemDatas.length; i++) - _getItem(itemDatas[i], i == 0, i == itemDatas.length - 1) - ], - ), - ), - ), - ); - } -} - -// When the overflow menu is open, it tries to align its right edge to the right -// edge of the closed menu. This widget handles this effect by measuring and -// maintaining the width of the closed menu and aligning the child to the right. -class _TextSelectionToolbarContainer extends SingleChildRenderObjectWidget { - const _TextSelectionToolbarContainer({ - Key? key, - required Widget child, - required this.overflowOpen, - }) : assert(child != null), - assert(overflowOpen != null), - super(key: key, child: child); - - final bool overflowOpen; - - @override - _TextSelectionToolbarContainerRenderBox createRenderObject(BuildContext context) { - return _TextSelectionToolbarContainerRenderBox(overflowOpen: overflowOpen); - } - - @override - void updateRenderObject(BuildContext context, _TextSelectionToolbarContainerRenderBox renderObject) { - renderObject.overflowOpen = overflowOpen; - } -} - -class _TextSelectionToolbarContainerRenderBox extends RenderProxyBox { - _TextSelectionToolbarContainerRenderBox({ - required bool overflowOpen, - }) : assert(overflowOpen != null), - _overflowOpen = overflowOpen, - super(); - - // The width of the menu when it was closed. This is used to achieve the - // behavior where the open menu aligns its right edge to the closed menu's - // right edge. - double? _closedWidth; - - bool _overflowOpen; - bool get overflowOpen => _overflowOpen; - set overflowOpen(bool value) { - if (value == overflowOpen) { - return; - } - _overflowOpen = value; - markNeedsLayout(); - } - - @override - void performLayout() { - child!.layout(constraints.loosen(), parentUsesSize: true); - - // Save the width when the menu is closed. If the menu changes, this width - // is invalid, so it's important that this RenderBox be recreated in that - // case. Currently, this is achieved by providing a new key to - // _TextSelectionToolbarContainer. - if (!overflowOpen && _closedWidth == null) { - _closedWidth = child!.size.width; - } - - size = constraints.constrain(Size( - // If the open menu is wider than the closed menu, just use its own width - // and don't worry about aligning the right edges. - // _closedWidth is used even when the menu is closed to allow it to - // animate its size while keeping the same right alignment. - _closedWidth == null || child!.size.width > _closedWidth! ? child!.size.width : _closedWidth!, - child!.size.height, - )); - - final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; - childParentData.offset = Offset( - size.width - child!.size.width, - 0.0, - ); - } - - // Paint at the offset set in the parent data. - @override - void paint(PaintingContext context, Offset offset) { - final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; - context.paintChild(child!, childParentData.offset + offset); - } - - // Include the parent data offset in the hit test. - @override - bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { - // The x, y parameters have the top left of the node's box as the origin. - final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; - return result.addWithPaintOffset( - offset: childParentData.offset, - position: position, - hitTest: (BoxHitTestResult result, Offset transformed) { - assert(transformed == position - childParentData.offset); - return child!.hitTest(result, position: transformed); - }, - ); - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is! ToolbarItemsParentData) { - child.parentData = ToolbarItemsParentData(); - } - } - - @override - void applyPaintTransform(RenderObject child, Matrix4 transform) { - final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; - transform.translate(childParentData.offset.dx, childParentData.offset.dy); - super.applyPaintTransform(child, transform); - } -} - -// Renders the menu items in the correct positions in the menu and its overflow -// submenu based on calculating which item would first overflow. -class _TextSelectionToolbarItems extends MultiChildRenderObjectWidget { - _TextSelectionToolbarItems({ - Key? key, - required this.isAbove, - required this.overflowOpen, - required List children, - }) : assert(children != null), - assert(isAbove != null), - assert(overflowOpen != null), - super(key: key, children: children); - - final bool isAbove; - final bool overflowOpen; - - @override - _TextSelectionToolbarItemsRenderBox createRenderObject(BuildContext context) { - return _TextSelectionToolbarItemsRenderBox( - isAbove: isAbove, - overflowOpen: overflowOpen, - ); - } - - @override - void updateRenderObject(BuildContext context, _TextSelectionToolbarItemsRenderBox renderObject) { - renderObject - ..isAbove = isAbove - ..overflowOpen = overflowOpen; - } - - @override - _TextSelectionToolbarItemsElement createElement() => _TextSelectionToolbarItemsElement(this); -} - -class _TextSelectionToolbarItemsElement extends MultiChildRenderObjectElement { - _TextSelectionToolbarItemsElement( - MultiChildRenderObjectWidget widget, - ) : super(widget); - - static bool _shouldPaint(Element child) { - return (child.renderObject!.parentData! as ToolbarItemsParentData).shouldPaint; - } - - @override - void debugVisitOnstageChildren(ElementVisitor visitor) { - children.where(_shouldPaint).forEach(visitor); - } -} - -class _TextSelectionToolbarItemsRenderBox extends RenderBox with ContainerRenderObjectMixin { - _TextSelectionToolbarItemsRenderBox({ - required bool isAbove, - required bool overflowOpen, - }) : assert(overflowOpen != null), - assert(isAbove != null), - _isAbove = isAbove, - _overflowOpen = overflowOpen, - super(); - - // The index of the last item that doesn't overflow. - int _lastIndexThatFits = -1; - - bool _isAbove; - bool get isAbove => _isAbove; - set isAbove(bool value) { - if (value == isAbove) { - return; - } - _isAbove = value; - markNeedsLayout(); - } - - bool _overflowOpen; - bool get overflowOpen => _overflowOpen; - set overflowOpen(bool value) { - if (value == overflowOpen) { - return; - } - _overflowOpen = value; - markNeedsLayout(); - } - - // Layout the necessary children, and figure out where the children first - // overflow, if at all. - void _layoutChildren() { - // When overflow is not open, the toolbar is always a specific height. - final BoxConstraints sizedConstraints = _overflowOpen - ? constraints - : BoxConstraints.loose(Size( - constraints.maxWidth, - _kToolbarHeight, - )); - - int i = -1; - double width = 0.0; - visitChildren((RenderObject renderObjectChild) { - i++; - - // No need to layout children inside the overflow menu when it's closed. - // The opposite is not true. It is necessary to layout the children that - // don't overflow when the overflow menu is open in order to calculate - // _lastIndexThatFits. - if (_lastIndexThatFits != -1 && !overflowOpen) { - return; - } - - final RenderBox child = renderObjectChild as RenderBox; - child.layout(sizedConstraints.loosen(), parentUsesSize: true); - width += child.size.width; - - if (width > sizedConstraints.maxWidth && _lastIndexThatFits == -1) { - _lastIndexThatFits = i - 1; - } - }); - - // If the last child overflows, but only because of the width of the - // overflow button, then just show it and hide the overflow button. - final RenderBox navButton = firstChild!; - if (_lastIndexThatFits != -1 - && _lastIndexThatFits == childCount - 2 - && width - navButton.size.width <= sizedConstraints.maxWidth) { - _lastIndexThatFits = -1; - } - } - - // Returns true when the child should be painted, false otherwise. - bool _shouldPaintChild(RenderObject renderObjectChild, int index) { - // Paint the navButton when there is overflow. - if (renderObjectChild == firstChild) { - return _lastIndexThatFits != -1; - } - - // If there is no overflow, all children besides the navButton are painted. - if (_lastIndexThatFits == -1) { - return true; - } - - // When there is overflow, paint if the child is in the part of the menu - // that is currently open. Overflowing children are painted when the - // overflow menu is open, and the children that fit are painted when the - // overflow menu is closed. - return (index > _lastIndexThatFits) == overflowOpen; - } - - // Decide which children will be pained and set their shouldPaint, and set the - // offset that painted children will be placed at. - void _placeChildren() { - int i = -1; - Size nextSize = const Size(0.0, 0.0); - double fitWidth = 0.0; - final RenderBox navButton = firstChild!; - double overflowHeight = overflowOpen && !isAbove ? navButton.size.height : 0.0; - visitChildren((RenderObject renderObjectChild) { - i++; - - final RenderBox child = renderObjectChild as RenderBox; - final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; - - // Handle placing the navigation button after iterating all children. - if (renderObjectChild == navButton) { - return; - } - - // There is no need to place children that won't be painted. - if (!_shouldPaintChild(renderObjectChild, i)) { - childParentData.shouldPaint = false; - return; - } - childParentData.shouldPaint = true; - - if (!overflowOpen) { - childParentData.offset = Offset(fitWidth, 0.0); - fitWidth += child.size.width; - nextSize = Size( - fitWidth, - math.max(child.size.height, nextSize.height), - ); - } else { - childParentData.offset = Offset(0.0, overflowHeight); - overflowHeight += child.size.height; - nextSize = Size( - math.max(child.size.width, nextSize.width), - overflowHeight, - ); - } - }); - - // Place the navigation button if needed. - final ToolbarItemsParentData navButtonParentData = navButton.parentData! as ToolbarItemsParentData; - if (_shouldPaintChild(firstChild!, 0)) { - navButtonParentData.shouldPaint = true; - if (overflowOpen) { - navButtonParentData.offset = isAbove - ? Offset(0.0, overflowHeight) - : Offset.zero; - nextSize = Size( - nextSize.width, - isAbove ? nextSize.height + navButton.size.height : nextSize.height, - ); - } else { - navButtonParentData.offset = Offset(fitWidth, 0.0); - nextSize = Size(nextSize.width + navButton.size.width, nextSize.height); - } - } else { - navButtonParentData.shouldPaint = false; - } - - size = nextSize; - } - - @override - void performLayout() { - _lastIndexThatFits = -1; - if (firstChild == null) { - size = constraints.smallest; - return; - } - - _layoutChildren(); - _placeChildren(); - } - - @override - void paint(PaintingContext context, Offset offset) { - visitChildren((RenderObject renderObjectChild) { - final RenderBox child = renderObjectChild as RenderBox; - final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; - if (!childParentData.shouldPaint) { - return; - } - - context.paintChild(child, childParentData.offset + offset); - }); - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is! ToolbarItemsParentData) { - child.parentData = ToolbarItemsParentData(); - } - } - - @override - bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { - // The x, y parameters have the top left of the node's box as the origin. - RenderBox? child = lastChild; - while (child != null) { - final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; - - // Don't hit test children aren't shown. - if (!childParentData.shouldPaint) { - child = childParentData.previousSibling; - continue; - } - - final bool isHit = result.addWithPaintOffset( - offset: childParentData.offset, - position: position, - hitTest: (BoxHitTestResult result, Offset transformed) { - assert(transformed == position - childParentData.offset); - return child!.hitTest(result, position: transformed); - }, - ); - if (isHit) - return true; - child = childParentData.previousSibling; - } - return false; - } - - // Visit only the children that should be painted. - @override - void visitChildrenForSemantics(RenderObjectVisitor visitor) { - visitChildren((RenderObject renderObjectChild) { - final RenderBox child = renderObjectChild as RenderBox; - final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; - if (childParentData.shouldPaint) { - visitor(renderObjectChild); - } - }); - } -} - -/// Centers the toolbar around the given anchor, ensuring that it remains on -/// screen. -class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate { - _TextSelectionToolbarLayout(this.anchor, this.upperBounds, this.fitsAbove); - - /// Anchor position of the toolbar in global coordinates. - final Offset anchor; - - /// The upper-most valid y value for the anchor. - final double upperBounds; - - /// Whether the closed toolbar fits above the anchor position. - /// - /// If the closed toolbar doesn't fit, then the menu is rendered below the - /// anchor position. It should never happen that the toolbar extends below the - /// padded bottom of the screen. - /// - /// If the closed toolbar does fit but it doesn't fit when the overflow menu - /// is open, then the toolbar is still rendered above the anchor position. It - /// then grows downward, overlapping the selection. - final bool fitsAbove; - - // Return the value that centers width as closely as possible to position - // while fitting inside of min and max. - static double _centerOn(double position, double width, double min, double max) { - // If it overflows on the left, put it as far left as possible. - if (position - width / 2.0 < min) { - return min; - } - - // If it overflows on the right, put it as far right as possible. - if (position + width / 2.0 > max) { - return max - width; - } - - // Otherwise it fits while perfectly centered. - return position - width / 2.0; - } - - @override - BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - return constraints.loosen(); - } - - @override - Offset getPositionForChild(Size size, Size childSize) { - return Offset( - _centerOn( - anchor.dx, - childSize.width, - _kToolbarScreenPadding, - size.width - _kToolbarScreenPadding, - ), - fitsAbove - ? math.max(upperBounds, anchor.dy - childSize.height) - : anchor.dy, - ); - } - - @override - bool shouldRelayout(_TextSelectionToolbarLayout oldDelegate) { - return anchor != oldDelegate.anchor; - } -} - -/// Draws a single text selection handle which points up and to the left. -class _TextSelectionHandlePainter extends CustomPainter { - _TextSelectionHandlePainter({ required this.color }); - - final Color color; - - @override - void paint(Canvas canvas, Size size) { - final Paint paint = Paint()..color = color; - final double radius = size.width/2.0; - final Rect circle = Rect.fromCircle(center: Offset(radius, radius), radius: radius); - final Rect point = Rect.fromLTWH(0.0, 0.0, radius, radius); - final Path path = Path()..addOval(circle)..addRect(point); - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(_TextSelectionHandlePainter oldPainter) { - return color != oldPainter.color; - } -} - -class _MaterialTextSelectionControls extends TextSelectionControls { +/// Android Material styled text selection controls. +class MaterialTextSelectionControls extends TextSelectionControls { /// Returns the size of the Material handle. @override Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize); @@ -748,49 +39,17 @@ class _MaterialTextSelectionControls extends TextSelectionControls { TextSelectionDelegate delegate, ClipboardStatusNotifier clipboardStatus, ) { - assert(debugCheckHasMediaQuery(context)); - assert(debugCheckHasMaterialLocalizations(context)); - - // The toolbar should appear below the TextField when there is not enough - // space above the TextField to show it. - final TextSelectionPoint startTextSelectionPoint = endpoints[0]; - final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1 - ? endpoints[1] - : endpoints[0]; - const double closedToolbarHeightNeeded = _kToolbarScreenPadding - + _kToolbarHeight - + _kToolbarContentDistance; - final double paddingTop = MediaQuery.of(context).padding.top; - final double availableHeight = globalEditableRegion.top - + startTextSelectionPoint.point.dy - - textLineHeight - - paddingTop; - final bool fitsAbove = closedToolbarHeightNeeded <= availableHeight; - final Offset anchor = Offset( - globalEditableRegion.left + selectionMidpoint.dx, - fitsAbove - ? globalEditableRegion.top + startTextSelectionPoint.point.dy - textLineHeight - _kToolbarContentDistance - : globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, - ); - - return Stack( - children: [ - CustomSingleChildLayout( - delegate: _TextSelectionToolbarLayout( - anchor, - _kToolbarScreenPadding + paddingTop, - fitsAbove, - ), - child: _TextSelectionToolbar( - clipboardStatus: clipboardStatus, - handleCut: canCut(delegate) ? () => handleCut(delegate) : null, - handleCopy: canCopy(delegate) ? () => handleCopy(delegate, clipboardStatus) : null, - handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, - handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, - isAbove: fitsAbove, - ), - ), - ], + return _TextSelectionControlsToolbar( + globalEditableRegion: globalEditableRegion, + textLineHeight: textLineHeight, + selectionMidpoint: selectionMidpoint, + endpoints: endpoints, + delegate: delegate, + clipboardStatus: clipboardStatus, + handleCut: canCut(delegate) ? () => handleCut(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate, clipboardStatus) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, ); } @@ -854,5 +113,180 @@ class _MaterialTextSelectionControls extends TextSelectionControls { } } +// The label and callback for the available default text selection menu buttons. +class _TextSelectionToolbarItemData { + const _TextSelectionToolbarItemData({ + required this.label, + required this.onPressed, + }); + + final String label; + final VoidCallback onPressed; +} + +// The highest level toolbar widget, built directly by buildToolbar. +class _TextSelectionControlsToolbar extends StatefulWidget { + const _TextSelectionControlsToolbar({ + Key? key, + required this.clipboardStatus, + required this.delegate, + required this.endpoints, + required this.globalEditableRegion, + required this.handleCut, + required this.handleCopy, + required this.handlePaste, + required this.handleSelectAll, + required this.selectionMidpoint, + required this.textLineHeight, + }) : super(key: key); + + final ClipboardStatusNotifier clipboardStatus; + final TextSelectionDelegate delegate; + final List endpoints; + final Rect globalEditableRegion; + final VoidCallback? handleCut; + final VoidCallback? handleCopy; + final VoidCallback? handlePaste; + final VoidCallback? handleSelectAll; + final Offset selectionMidpoint; + final double textLineHeight; + + @override + _TextSelectionControlsToolbarState createState() => _TextSelectionControlsToolbarState(); +} + +class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToolbar> with TickerProviderStateMixin { + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + @override + void initState() { + super.initState(); + widget.clipboardStatus.addListener(_onChangedClipboardStatus); + widget.clipboardStatus.update(); + } + + @override + void didUpdateWidget(_TextSelectionControlsToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.clipboardStatus != oldWidget.clipboardStatus) { + widget.clipboardStatus.addListener(_onChangedClipboardStatus); + oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus); + } + widget.clipboardStatus.update(); + } + + @override + void dispose() { + super.dispose(); + // When used in an Overlay, it can happen that this is disposed after its + // creator has already disposed _clipboardStatus. + if (!widget.clipboardStatus.disposed) { + widget.clipboardStatus.removeListener(_onChangedClipboardStatus); + } + } + + @override + Widget build(BuildContext context) { + // If there are no buttons to be shown, don't render anything. + if (widget.handleCut == null && widget.handleCopy == null + && widget.handlePaste == null && widget.handleSelectAll == null) { + return const SizedBox.shrink(); + } + // If the paste button is desired, don't render anything until the state of + // the clipboard is known, since it's used to determine if paste is shown. + if (widget.handlePaste != null + && widget.clipboardStatus.value == ClipboardStatus.unknown) { + return const SizedBox.shrink(); + } + + // Calculate the positioning of the menu. It is placed above the selection + // if there is enough room, or otherwise below. + final TextSelectionPoint startTextSelectionPoint = widget.endpoints[0]; + final TextSelectionPoint endTextSelectionPoint = widget.endpoints.length > 1 + ? widget.endpoints[1] + : widget.endpoints[0]; + final Offset anchorAbove = Offset( + widget.globalEditableRegion.left + widget.selectionMidpoint.dx, + widget.globalEditableRegion.top + startTextSelectionPoint.point.dy - widget.textLineHeight - _kToolbarContentDistance + ); + final Offset anchorBelow = Offset( + widget.globalEditableRegion.left + widget.selectionMidpoint.dx, + widget.globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, + ); + + // Determine which buttons will appear so that the order and total number is + // known. A button's position in the menu can slightly affect its + // appearance. + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final List<_TextSelectionToolbarItemData> itemDatas = <_TextSelectionToolbarItemData>[ + if (widget.handleCut != null) + _TextSelectionToolbarItemData( + label: localizations.cutButtonLabel, + onPressed: widget.handleCut!, + ), + if (widget.handleCopy != null) + _TextSelectionToolbarItemData( + label: localizations.copyButtonLabel, + onPressed: widget.handleCopy!, + ), + if (widget.handlePaste != null + && widget.clipboardStatus.value == ClipboardStatus.pasteable) + _TextSelectionToolbarItemData( + label: localizations.pasteButtonLabel, + onPressed: widget.handlePaste!, + ), + if (widget.handleSelectAll != null) + _TextSelectionToolbarItemData( + label: localizations.selectAllButtonLabel, + onPressed: widget.handleSelectAll!, + ), + ]; + + // If there is no option available, build an empty widget. + if (itemDatas.isEmpty) { + return const SizedBox(width: 0.0, height: 0.0); + } + + return TextSelectionToolbar( + anchorAbove: anchorAbove, + anchorBelow: anchorBelow, + children: itemDatas.asMap().entries.map((MapEntry entry) { + return TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(entry.key, itemDatas.length), + onPressed: entry.value.onPressed, + child: Text(entry.value.label), + ); + }).toList(), + ); + } +} + +/// Draws a single text selection handle which points up and to the left. +class _TextSelectionHandlePainter extends CustomPainter { + _TextSelectionHandlePainter({ required this.color }); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final Paint paint = Paint()..color = color; + final double radius = size.width/2.0; + final Rect circle = Rect.fromCircle(center: Offset(radius, radius), radius: radius); + final Rect point = Rect.fromLTWH(0.0, 0.0, radius, radius); + final Path path = Path()..addOval(circle)..addRect(point); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(_TextSelectionHandlePainter oldPainter) { + return color != oldPainter.color; + } +} + /// Text selection controls that follow the Material Design specification. -final TextSelectionControls materialTextSelectionControls = _MaterialTextSelectionControls(); +final TextSelectionControls materialTextSelectionControls = MaterialTextSelectionControls(); diff --git a/packages/flutter/lib/src/material/text_selection_toolbar.dart b/packages/flutter/lib/src/material/text_selection_toolbar.dart new file mode 100644 index 0000000000..382b1b9338 --- /dev/null +++ b/packages/flutter/lib/src/material/text_selection_toolbar.dart @@ -0,0 +1,751 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'material.dart'; +import 'material_localizations.dart'; + +// Minimal padding from all edges of the selection toolbar to all edges of the +// viewport. +const double _kToolbarScreenPadding = 8.0; +const double _kToolbarHeight = 44.0; + +/// The type for a Function that builds a toolbar's container with the given +/// child. +/// +/// See also: +/// +/// * [TextSelectionToolbar.toolbarBuilder], which is of this type. +typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child); + +/// A fully-functional Material-style text selection toolbar. +/// +/// Tries to position itself above [anchorAbove], but if it doesn't fit, then +/// it positions itself below [anchorBelow]. +/// +/// If any children don't fit in the menu, an overflow menu will automatically +/// be created. +class TextSelectionToolbar extends StatelessWidget { + /// Creates an instance of TextSelectionToolbar. + const TextSelectionToolbar({ + Key? key, + required this.anchorAbove, + required this.anchorBelow, + this.toolbarBuilder = _defaultToolbarBuilder, + required this.children, + }) : assert(children.length > 0), + super(key: key); + + /// The focal point above which the toolbar attempts to position itself. + /// + /// If there is not enough room above before reaching the top of the screen, + /// then the toolbar will position itself below [anchorBelow]. + final Offset anchorAbove; + + /// The focal point below which the toolbar attempts to position itself, if it + /// doesn't fit above [anchorAbove]. + final Offset anchorBelow; + + /// The children that will be displayed in the text selection toolbar. + /// + /// Typically these are buttons. + /// + /// Must not be empty. + /// + /// See also: + /// * [TextSelectionToolbarTextButton], which builds a default Material- + /// style text selection toolbar text button. + final List children; + + /// Builds the toolbar container. + /// + /// Useful for customizing the high-level background of the toolbar. The given + /// child Widget will contain all of the [children]. + final ToolbarBuilder toolbarBuilder; + + // Build the default Android Material text selection menu toolbar. + static Widget _defaultToolbarBuilder(BuildContext context, Widget child) { + return _TextSelectionToolbarContainer( + child: child, + ); + } + + @override + Widget build(BuildContext context) { + final double paddingTop = MediaQuery.of(context).padding.top + + _kToolbarScreenPadding; + final double availableHeight = anchorAbove.dy - paddingTop; + final bool fitsAbove = _kToolbarHeight <= availableHeight; + final Offset anchor = fitsAbove ? anchorAbove : anchorBelow; + final Offset localAnchor = Offset( + anchor.dx - _kToolbarScreenPadding, + anchor.dy - paddingTop, + ); + + return Padding( + padding: EdgeInsets.fromLTRB( + _kToolbarScreenPadding, + paddingTop, + _kToolbarScreenPadding, + _kToolbarScreenPadding, + ), + child: Stack( + children: [ + CustomSingleChildLayout( + delegate: _TextSelectionToolbarLayoutDelegate( + localAnchor, + fitsAbove, + ), + child: _TextSelectionToolbarOverflowable( + isAbove: fitsAbove, + toolbarBuilder: toolbarBuilder, + children: children, + ), + ), + ], + ), + ); + } +} + +// Positions the toolbar at the given anchor, ensuring that it remains on +// screen. +class _TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate { + _TextSelectionToolbarLayoutDelegate( + this.anchor, + this.fitsAbove, + ); + + // Anchor position of the toolbar in global coordinates. + final Offset anchor; + + // Whether the closed toolbar fits above the anchor position. + // + // If the closed toolbar doesn't fit, then the menu is rendered below the + // anchor position. It should never happen that the toolbar extends below the + // padded bottom of the screen. + final bool fitsAbove; + + // Return the value that centers width as closely as possible to position + // while fitting inside of min and max. + static double _centerOn(double position, double width, double max) { + // If it overflows on the left, put it as far left as possible. + if (position - width / 2.0 < 0.0) { + return 0.0; + } + + // If it overflows on the right, put it as far right as possible. + if (position + width / 2.0 > max) { + return max - width; + } + + // Otherwise it fits while perfectly centered. + return position - width / 2.0; + } + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return constraints.loosen(); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + return Offset( + _centerOn( + anchor.dx, + childSize.width, + size.width, + ), + fitsAbove + ? math.max(0.0, anchor.dy - childSize.height) + : anchor.dy, + ); + } + + @override + bool shouldRelayout(_TextSelectionToolbarLayoutDelegate oldDelegate) { + return anchor != oldDelegate.anchor || fitsAbove != oldDelegate.fitsAbove; + } +} + +// A toolbar containing the given children. If they overflow the width +// available, then the overflowing children will be displayed in an overflow +// menu. +class _TextSelectionToolbarOverflowable extends StatefulWidget { + const _TextSelectionToolbarOverflowable({ + Key? key, + required this.isAbove, + required this.toolbarBuilder, + required this.children, + }) : assert(children.length > 0), + super(key: key); + + final List children; + + // When true, the toolbar fits above its anchor and will be positioned there. + final bool isAbove; + + // Builds the toolbar that will be populated with the children and fit inside + // of the layout that adjusts to overflow. + final ToolbarBuilder toolbarBuilder; + + @override + _TextSelectionToolbarOverflowableState createState() => _TextSelectionToolbarOverflowableState(); +} + +class _TextSelectionToolbarOverflowableState extends State<_TextSelectionToolbarOverflowable> with TickerProviderStateMixin { + // Whether or not the overflow menu is open. When it is closed, the menu + // items that don't overflow are shown. When it is open, only the overflowing + // menu items are shown. + bool _overflowOpen = false; + + // The key for _TextSelectionToolbarTrailingEdgeAlign. + UniqueKey _containerKey = UniqueKey(); + + // Close the menu and reset layout calculations, as in when the menu has + // changed and saved values are no longer relevant. This should be called in + // setState or another context where a rebuild is happening. + void _reset() { + // Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes in + // order to cause it to rebuild. This lets it recalculate its + // saved width for the new set of children, and it prevents AnimatedSize + // from animating the size change. + _containerKey = UniqueKey(); + // If the menu items change, make sure the overflow menu is closed. This + // prevents getting into a broken state where _overflowOpen is true when + // there are not enough children to cause overflow. + _overflowOpen = false; + } + + @override + void didUpdateWidget(_TextSelectionToolbarOverflowable oldWidget) { + super.didUpdateWidget(oldWidget); + // If the children are changing at all, the current page should be reset. + if (!listEquals(widget.children, oldWidget.children)) { + _reset(); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + return _TextSelectionToolbarTrailingEdgeAlign( + key: _containerKey, + overflowOpen: _overflowOpen, + textDirection: Directionality.of(context), + child: AnimatedSize( + vsync: this, + // This duration was eyeballed on a Pixel 2 emulator running Android + // API 28. + duration: const Duration(milliseconds: 140), + child: widget.toolbarBuilder(context, _TextSelectionToolbarItemsLayout( + isAbove: widget.isAbove, + overflowOpen: _overflowOpen, + children: [ + // TODO(justinmc): This overflow button should have its own slot in + // _TextSelectionToolbarItemsLayout separate from children, similar + // to how it's done in Cupertino's text selection menu. + // https://github.com/flutter/flutter/issues/69908 + // The navButton that shows and hides the overflow menu is the + // first child. + _TextSelectionToolbarOverflowButton( + icon: Icon(_overflowOpen ? Icons.arrow_back : Icons.more_vert), + onPressed: () { + setState(() { + _overflowOpen = !_overflowOpen; + }); + }, + tooltip: _overflowOpen + ? localizations.backButtonTooltip + : localizations.moreButtonTooltip, + ), + ...widget.children, + ], + )), + ), + ); + } +} + +// When the overflow menu is open, it tries to align its trailing edge to the +// trailing edge of the closed menu. This widget handles this effect by +// measuring and maintaining the width of the closed menu and aligning the child +// to that side. +class _TextSelectionToolbarTrailingEdgeAlign extends SingleChildRenderObjectWidget { + const _TextSelectionToolbarTrailingEdgeAlign({ + Key? key, + required Widget child, + required this.overflowOpen, + required this.textDirection, + }) : assert(child != null), + assert(overflowOpen != null), + super(key: key, child: child); + + final bool overflowOpen; + final TextDirection textDirection; + + @override + _TextSelectionToolbarTrailingEdgeAlignRenderBox createRenderObject(BuildContext context) { + return _TextSelectionToolbarTrailingEdgeAlignRenderBox( + overflowOpen: overflowOpen, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject(BuildContext context, _TextSelectionToolbarTrailingEdgeAlignRenderBox renderObject) { + renderObject + ..overflowOpen = overflowOpen + ..textDirection = textDirection; + } +} + +class _TextSelectionToolbarTrailingEdgeAlignRenderBox extends RenderProxyBox { + _TextSelectionToolbarTrailingEdgeAlignRenderBox({ + required bool overflowOpen, + required TextDirection textDirection, + }) : _textDirection = textDirection, + _overflowOpen = overflowOpen, + super(); + + // The width of the menu when it was closed. This is used to achieve the + // behavior where the open menu aligns its trailing edge to the closed menu's + // trailing edge. + double? _closedWidth; + + bool _overflowOpen; + bool get overflowOpen => _overflowOpen; + set overflowOpen(bool value) { + if (value == overflowOpen) { + return; + } + _overflowOpen = value; + markNeedsLayout(); + } + + TextDirection _textDirection; + TextDirection get textDirection => _textDirection; + set textDirection(TextDirection value) { + if (value == textDirection) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + @override + void performLayout() { + child!.layout(constraints.loosen(), parentUsesSize: true); + + // Save the width when the menu is closed. If the menu changes, this width + // is invalid, so it's important that this RenderBox be recreated in that + // case. Currently, this is achieved by providing a new key to + // _TextSelectionToolbarTrailingEdgeAlign. + if (!overflowOpen && _closedWidth == null) { + _closedWidth = child!.size.width; + } + + size = constraints.constrain(Size( + // If the open menu is wider than the closed menu, just use its own width + // and don't worry about aligning the trailing edges. + // _closedWidth is used even when the menu is closed to allow it to + // animate its size while keeping the same edge alignment. + _closedWidth == null || child!.size.width > _closedWidth! ? child!.size.width : _closedWidth!, + child!.size.height, + )); + + // Set the offset in the parent data such that the child will be aligned to + // the trailing edge, depending on the text direction. + final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; + childParentData.offset = Offset( + textDirection == TextDirection.rtl ? 0.0 : size.width - child!.size.width, + 0.0, + ); + } + + // Paint at the offset set in the parent data. + @override + void paint(PaintingContext context, Offset offset) { + final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; + context.paintChild(child!, childParentData.offset + offset); + } + + // Include the parent data offset in the hit test. + @override + bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { + // The x, y parameters have the top left of the node's box as the origin. + final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed); + }, + ); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ToolbarItemsParentData) { + child.parentData = ToolbarItemsParentData(); + } + } + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + transform.translate(childParentData.offset.dx, childParentData.offset.dy); + super.applyPaintTransform(child, transform); + } +} + +// Renders the menu items in the correct positions in the menu and its overflow +// submenu based on calculating which item would first overflow. +class _TextSelectionToolbarItemsLayout extends MultiChildRenderObjectWidget { + _TextSelectionToolbarItemsLayout({ + Key? key, + required this.isAbove, + required this.overflowOpen, + required List children, + }) : assert(children != null), + assert(isAbove != null), + assert(overflowOpen != null), + super(key: key, children: children); + + final bool isAbove; + final bool overflowOpen; + + @override + _RenderTextSelectionToolbarItemsLayout createRenderObject(BuildContext context) { + return _RenderTextSelectionToolbarItemsLayout( + isAbove: isAbove, + overflowOpen: overflowOpen, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderTextSelectionToolbarItemsLayout renderObject) { + renderObject + ..isAbove = isAbove + ..overflowOpen = overflowOpen; + } + + @override + _TextSelectionToolbarItemsLayoutElement createElement() => _TextSelectionToolbarItemsLayoutElement(this); +} + +class _TextSelectionToolbarItemsLayoutElement extends MultiChildRenderObjectElement { + _TextSelectionToolbarItemsLayoutElement( + MultiChildRenderObjectWidget widget, + ) : super(widget); + + static bool _shouldPaint(Element child) { + return (child.renderObject!.parentData! as ToolbarItemsParentData).shouldPaint; + } + + @override + void debugVisitOnstageChildren(ElementVisitor visitor) { + children.where(_shouldPaint).forEach(visitor); + } +} + +class _RenderTextSelectionToolbarItemsLayout extends RenderBox with ContainerRenderObjectMixin { + _RenderTextSelectionToolbarItemsLayout({ + required bool isAbove, + required bool overflowOpen, + }) : assert(overflowOpen != null), + assert(isAbove != null), + _isAbove = isAbove, + _overflowOpen = overflowOpen, + super(); + + // The index of the last item that doesn't overflow. + int _lastIndexThatFits = -1; + + bool _isAbove; + bool get isAbove => _isAbove; + set isAbove(bool value) { + if (value == isAbove) { + return; + } + _isAbove = value; + markNeedsLayout(); + } + + bool _overflowOpen; + bool get overflowOpen => _overflowOpen; + set overflowOpen(bool value) { + if (value == overflowOpen) { + return; + } + _overflowOpen = value; + markNeedsLayout(); + } + + // Layout the necessary children, and figure out where the children first + // overflow, if at all. + void _layoutChildren() { + // When overflow is not open, the toolbar is always a specific height. + final BoxConstraints sizedConstraints = _overflowOpen + ? constraints + : BoxConstraints.loose(Size( + constraints.maxWidth, + _kToolbarHeight, + )); + + int i = -1; + double width = 0.0; + visitChildren((RenderObject renderObjectChild) { + i++; + + // No need to layout children inside the overflow menu when it's closed. + // The opposite is not true. It is necessary to layout the children that + // don't overflow when the overflow menu is open in order to calculate + // _lastIndexThatFits. + if (_lastIndexThatFits != -1 && !overflowOpen) { + return; + } + + final RenderBox child = renderObjectChild as RenderBox; + child.layout(sizedConstraints.loosen(), parentUsesSize: true); + width += child.size.width; + + if (width > sizedConstraints.maxWidth && _lastIndexThatFits == -1) { + _lastIndexThatFits = i - 1; + } + }); + + // If the last child overflows, but only because of the width of the + // overflow button, then just show it and hide the overflow button. + final RenderBox navButton = firstChild!; + if (_lastIndexThatFits != -1 + && _lastIndexThatFits == childCount - 2 + && width - navButton.size.width <= sizedConstraints.maxWidth) { + _lastIndexThatFits = -1; + } + } + + // Returns true when the child should be painted, false otherwise. + bool _shouldPaintChild(RenderObject renderObjectChild, int index) { + // Paint the navButton when there is overflow. + if (renderObjectChild == firstChild) { + return _lastIndexThatFits != -1; + } + + // If there is no overflow, all children besides the navButton are painted. + if (_lastIndexThatFits == -1) { + return true; + } + + // When there is overflow, paint if the child is in the part of the menu + // that is currently open. Overflowing children are painted when the + // overflow menu is open, and the children that fit are painted when the + // overflow menu is closed. + return (index > _lastIndexThatFits) == overflowOpen; + } + + // Decide which children will be painted, set their shouldPaint, and set the + // offset that painted children will be placed at. + void _placeChildren() { + int i = -1; + Size nextSize = const Size(0.0, 0.0); + double fitWidth = 0.0; + final RenderBox navButton = firstChild!; + double overflowHeight = overflowOpen && !isAbove ? navButton.size.height : 0.0; + visitChildren((RenderObject renderObjectChild) { + i++; + + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + + // Handle placing the navigation button after iterating all children. + if (renderObjectChild == navButton) { + return; + } + + // There is no need to place children that won't be painted. + if (!_shouldPaintChild(renderObjectChild, i)) { + childParentData.shouldPaint = false; + return; + } + childParentData.shouldPaint = true; + + if (!overflowOpen) { + childParentData.offset = Offset(fitWidth, 0.0); + fitWidth += child.size.width; + nextSize = Size( + fitWidth, + math.max(child.size.height, nextSize.height), + ); + } else { + childParentData.offset = Offset(0.0, overflowHeight); + overflowHeight += child.size.height; + nextSize = Size( + math.max(child.size.width, nextSize.width), + overflowHeight, + ); + } + }); + + // Place the navigation button if needed. + final ToolbarItemsParentData navButtonParentData = navButton.parentData! as ToolbarItemsParentData; + if (_shouldPaintChild(firstChild!, 0)) { + navButtonParentData.shouldPaint = true; + if (overflowOpen) { + navButtonParentData.offset = isAbove + ? Offset(0.0, overflowHeight) + : Offset.zero; + nextSize = Size( + nextSize.width, + isAbove ? nextSize.height + navButton.size.height : nextSize.height, + ); + } else { + navButtonParentData.offset = Offset(fitWidth, 0.0); + nextSize = Size(nextSize.width + navButton.size.width, nextSize.height); + } + } else { + navButtonParentData.shouldPaint = false; + } + + size = nextSize; + } + + @override + void performLayout() { + _lastIndexThatFits = -1; + if (firstChild == null) { + size = constraints.smallest; + return; + } + + _layoutChildren(); + _placeChildren(); + } + + @override + void paint(PaintingContext context, Offset offset) { + visitChildren((RenderObject renderObjectChild) { + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + if (!childParentData.shouldPaint) { + return; + } + + context.paintChild(child, childParentData.offset + offset); + }); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ToolbarItemsParentData) { + child.parentData = ToolbarItemsParentData(); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { + // The x, y parameters have the top left of the node's box as the origin. + RenderBox? child = lastChild; + while (child != null) { + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + + // Don't hit test children aren't shown. + if (!childParentData.shouldPaint) { + child = childParentData.previousSibling; + continue; + } + + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed); + }, + ); + if (isHit) + return true; + child = childParentData.previousSibling; + } + return false; + } + + // Visit only the children that should be painted. + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + visitChildren((RenderObject renderObjectChild) { + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + if (childParentData.shouldPaint) { + visitor(renderObjectChild); + } + }); + } +} + +// The Material-styled toolbar outline. Fill it with any widgets you want. No +// overflow ability. +class _TextSelectionToolbarContainer extends StatelessWidget { + const _TextSelectionToolbarContainer({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Material( + // This value was eyeballed to match the native text selection menu on + // a Pixel 2 running Android 10. + borderRadius: const BorderRadius.all(Radius.circular(7.0)), + clipBehavior: Clip.antiAlias, + elevation: 1.0, + type: MaterialType.card, + child: child, + ); + } +} + +// A button styled like a Material native Android text selection overflow menu +// forward and back controls. +class _TextSelectionToolbarOverflowButton extends StatelessWidget { + const _TextSelectionToolbarOverflowButton({ + Key? key, + required this.icon, + this.onPressed, + this.tooltip, + }) : super(key: key); + + final Icon icon; + final VoidCallback? onPressed; + final String? tooltip; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.card, + color: const Color(0x00000000), + child: IconButton( + // TODO(justinmc): This should be an AnimatedIcon, but + // AnimatedIcons doesn't yet support arrow_back to more_vert. + // https://github.com/flutter/flutter/issues/51209 + icon: icon, + onPressed: onPressed, + tooltip: tooltip, + ), + ); + } +} diff --git a/packages/flutter/lib/src/material/text_selection_toolbar_text_button.dart b/packages/flutter/lib/src/material/text_selection_toolbar_text_button.dart new file mode 100644 index 0000000000..dae5787439 --- /dev/null +++ b/packages/flutter/lib/src/material/text_selection_toolbar_text_button.dart @@ -0,0 +1,121 @@ +// 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/widgets.dart'; + +import 'colors.dart'; +import 'constants.dart'; +import 'text_button.dart'; +import 'theme.dart'; + +enum _TextSelectionToolbarItemPosition { + /// The first item among multiple in the menu. + first, + + /// One of several items, not the first or last. + middle, + + /// The last item among multiple in the menu. + last, + + /// The only item in the menu. + only, +} + +/// A button styled like a Material native Android text selection menu button. +class TextSelectionToolbarTextButton extends StatelessWidget { + /// Creates an instance of TextSelectionToolbarTextButton. + const TextSelectionToolbarTextButton({ + Key? key, + required this.child, + required this.padding, + this.onPressed, + }) : super(key: key); + + // These values were eyeballed to match the native text selection menu on a + // Pixel 2 running Android 10. + static const double _kMiddlePadding = 9.5; + static const double _kEndPadding = 14.5; + + /// The child of this button. + /// + /// Usually a [Text]. + final Widget child; + + /// Called when this button is pressed. + final VoidCallback? onPressed; + + /// The padding between the button's edge and its child. + /// + /// In a standard Material [TextSelectionToolbar], the padding depends on the + /// button's position within the toolbar. + /// + /// See also: + /// + /// * [getPadding], which calculates the standard padding based on the + /// button's position. + /// * [ButtonStyle.padding], which is where this padding is applied. + final EdgeInsets padding; + + /// Returns the standard padding for a button at index out of a total number + /// of buttons. + /// + /// Standard Material [TextSelectionToolbar]s have buttons with different + /// padding depending on their position in the toolbar. + static EdgeInsets getPadding(int index, int total) { + assert(total > 0 && index >= 0 && index < total); + final _TextSelectionToolbarItemPosition position = _getPosition(index, total); + return EdgeInsets.only( + left: _getLeftPadding(position), + right: _getRightPadding(position), + ); + } + + static double _getLeftPadding(_TextSelectionToolbarItemPosition position) { + if (position == _TextSelectionToolbarItemPosition.first + || position == _TextSelectionToolbarItemPosition.only) { + return _kEndPadding; + } + return _kMiddlePadding; + } + + static double _getRightPadding(_TextSelectionToolbarItemPosition position) { + if (position == _TextSelectionToolbarItemPosition.last + || position == _TextSelectionToolbarItemPosition.only) { + return _kEndPadding; + } + return _kMiddlePadding; + } + + static _TextSelectionToolbarItemPosition _getPosition(int index, int total) { + if (index == 0) { + return total == 1 + ? _TextSelectionToolbarItemPosition.only + : _TextSelectionToolbarItemPosition.first; + } + if (index == total - 1) { + return _TextSelectionToolbarItemPosition.last; + } + return _TextSelectionToolbarItemPosition.middle; + } + + @override + Widget build(BuildContext context) { + // TODO(hansmuller): Should be colorScheme.onSurface + final ThemeData theme = Theme.of(context); + final bool isDark = theme.colorScheme.brightness == Brightness.dark; + final Color primary = isDark ? Colors.white : Colors.black87; + + return TextButton( + style: TextButton.styleFrom( + primary: primary, + shape: const RoundedRectangleBorder(), + minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension), + padding: padding, + ), + onPressed: onPressed, + child: child, + ); + } +} diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 44e867e81e..cce6c5d40f 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -567,20 +567,27 @@ class TextSelectionOverlay { endpoints[0].point.dy - renderObject.preferredLineHeight, ); - return FadeTransition( - opacity: _toolbarOpacity, - child: CompositedTransformFollower( - link: toolbarLayerLink, - showWhenUnlinked: false, - offset: -editingRegion.topLeft, - child: selectionControls!.buildToolbar( - context, - editingRegion, - renderObject.preferredLineHeight, - midpoint, - endpoints, - selectionDelegate!, - clipboardStatus!, + return Directionality( + textDirection: Directionality.of(this.context), + child: FadeTransition( + opacity: _toolbarOpacity, + child: CompositedTransformFollower( + link: toolbarLayerLink, + showWhenUnlinked: false, + offset: -editingRegion.topLeft, + child: Builder( + builder: (BuildContext context) { + return selectionControls!.buildToolbar( + context, + editingRegion, + renderObject.preferredLineHeight, + midpoint, + endpoints, + selectionDelegate!, + clipboardStatus!, + ); + }, + ), ), ), ); @@ -1516,7 +1523,7 @@ class ClipboardStatusNotifier extends ValueNotifier with Widget Future update() async { // iOS 14 added a notification that appears when an app accesses the // clipboard. To avoid the notification, don't access the clipboard on iOS, - // and instead always shown the paste button, even when the clipboard is + // and instead always show the paste button, even when the clipboard is // empty. // TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that // won't trigger the notification. diff --git a/packages/flutter/test/material/text_selection_toolbar_test.dart b/packages/flutter/test/material/text_selection_toolbar_test.dart new file mode 100644 index 0000000000..6048152ed3 --- /dev/null +++ b/packages/flutter/test/material/text_selection_toolbar_test.dart @@ -0,0 +1,211 @@ +// 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_test/flutter_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import '../widgets/text.dart' show textOffsetToPosition; + +// A custom text selection menu that just displays a single custom button. +class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls { + static const double _kToolbarContentDistanceBelow = 20.0; + static const double _kToolbarContentDistance = 8.0; + + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List endpoints, + TextSelectionDelegate delegate, + ClipboardStatusNotifier clipboardStatus, + ) { + final TextSelectionPoint startTextSelectionPoint = endpoints[0]; + final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1 + ? endpoints[1] + : endpoints[0]; + final Offset anchorAbove = Offset( + globalEditableRegion.left + selectionMidpoint.dx, + globalEditableRegion.top + startTextSelectionPoint.point.dy - textLineHeight - _kToolbarContentDistance + ); + final Offset anchorBelow = Offset( + globalEditableRegion.left + selectionMidpoint.dx, + globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, + ); + + return TextSelectionToolbar( + anchorAbove: anchorAbove, + anchorBelow: anchorBelow, + children: [ + TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(0, 1), + onPressed: () {}, + child: const Text('Custom button'), + ), + ], + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Find by a runtimeType String, including private types. + Finder _findPrivate(String type) { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type), + ); + } + + // Finding TextSelectionToolbar won't give you the position as the user sees + // it because it's a full-sized Stack at the top level. This method finds the + // visible part of the toolbar for use in measurements. + Finder _findToolbar() => _findPrivate('_TextSelectionToolbarOverflowable'); + + Finder _findOverflowButton() => _findPrivate('_TextSelectionToolbarOverflowButton'); + + testWidgets('puts children in an overflow menu if they overflow', (WidgetTester tester) async { + late StateSetter setState; + const double height = 44.0; + const double itemWidth = 100.0; + final List children = [ + Container(width: itemWidth, height: height), + Container(width: itemWidth, height: height), + Container(width: itemWidth, height: height), + Container(width: itemWidth, height: height), + Container(width: itemWidth, height: height), + Container(width: itemWidth, height: height), + Container(width: itemWidth, height: height), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: children, + ); + }, + ), + ), + ), + ); + + // All children fit on the screen, so they are all rendered. + expect(find.byType(Container), findsNWidgets(children.length)); + expect(_findOverflowButton(), findsNothing); + + // Adding one more child makes the children overflow. + setState(() { + children.add( + Container(width: itemWidth, height: height), + ); + }); + await tester.pumpAndSettle(); + expect(find.byType(Container), findsNWidgets(children.length - 1)); + expect(_findOverflowButton(), findsOneWidget); + + // Tap the overflow button to show the overflow menu. + await tester.tap(_findOverflowButton()); + await tester.pumpAndSettle(); + expect(find.byType(Container), findsNWidgets(1)); + expect(_findOverflowButton(), findsOneWidget); + + // Tap the overflow button again to hide the overflow menu. + await tester.tap(_findOverflowButton()); + await tester.pumpAndSettle(); + expect(find.byType(Container), findsNWidgets(children.length - 1)); + expect(_findOverflowButton(), findsOneWidget); + }); + + testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async { + late StateSetter setState; + const double height = 44.0; + const double anchorBelowY = 500.0; + double anchorAboveY = 0.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextSelectionToolbar( + anchorAbove: Offset(50.0, anchorAboveY), + anchorBelow: const Offset(50.0, anchorBelowY), + children: [ + Container(color: Colors.red, width: 50.0, height: height), + Container(color: Colors.green, width: 50.0, height: height), + Container(color: Colors.blue, width: 50.0, height: height), + ], + ); + }, + ), + ), + ), + ); + + // When the toolbar doesn't fit above aboveAnchor, it positions itself below + // belowAnchor. + double toolbarY = tester.getTopLeft(_findToolbar()).dy; + expect(toolbarY, equals(anchorBelowY)); + + // Even when it barely doesn't fit. + setState(() { + anchorAboveY = 50.0; + }); + await tester.pump(); + toolbarY = tester.getTopLeft(_findToolbar()).dy; + expect(toolbarY, equals(anchorBelowY)); + + // When it does fit above aboveAnchor, it positions itself there. + setState(() { + anchorAboveY = 60.0; + }); + await tester.pump(); + toolbarY = tester.getTopLeft(_findToolbar()).dy; + expect(toolbarY, equals(anchorAboveY - height)); + }); + + testWidgets('can create and use a custom toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SelectableText( + 'Select me custom menu', + selectionControls: _CustomMaterialTextSelectionControls(), + ), + ), + ), + ), + ); + + // The selection menu is not initially shown. + expect(find.text('Custom button'), findsNothing); + + // Long press on "custom" to select it. + final Offset customPos = textOffsetToPosition(tester, 11); + final TestGesture gesture = await tester.startGesture(customPos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + // The custom selection menu is shown. + expect(find.text('Custom button'), findsOneWidget); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + }, skip: kIsWeb); +} diff --git a/packages/flutter/test/material/text_selection_toolbar_text_button_test.dart b/packages/flutter/test/material/text_selection_toolbar_text_button_test.dart new file mode 100644 index 0000000000..47a582faa1 --- /dev/null +++ b/packages/flutter/test/material/text_selection_toolbar_text_button_test.dart @@ -0,0 +1,65 @@ +// 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_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('position in the toolbar changes width', (WidgetTester tester) async { + late StateSetter setState; + int index = 1; + int total = 3; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextSelectionToolbarTextButton( + child: const Text('button'), + padding: TextSelectionToolbarTextButton.getPadding(index, total), + ); + }, + ), + ), + ), + ), + ); + + final Size middleSize = tester.getSize(find.byType(TextSelectionToolbarTextButton)); + + setState(() { + index = 0; + total = 3; + }); + await tester.pump(); + final Size firstSize = tester.getSize(find.byType(TextSelectionToolbarTextButton)); + expect(firstSize.width, greaterThan(middleSize.width)); + + setState(() { + index = 2; + total = 3; + }); + await tester.pump(); + final Size lastSize = tester.getSize(find.byType(TextSelectionToolbarTextButton)); + expect(lastSize.width, greaterThan(middleSize.width)); + expect(lastSize.width, equals(firstSize.width)); + + setState(() { + index = 0; + total = 1; + }); + await tester.pump(); + final Size onlySize = tester.getSize(find.byType(TextSelectionToolbarTextButton)); + expect(onlySize.width, greaterThan(middleSize.width)); + expect(onlySize.width, greaterThan(firstSize.width)); + expect(onlySize.width, greaterThan(lastSize.width)); + }); +} diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index 7d2cd13fe6..77831b74b9 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -12,6 +12,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import '../widgets/semantics_tester.dart'; +import '../widgets/text.dart' show textOffsetToPosition; class MockClipboard { dynamic _clipboardData = { @@ -160,18 +161,6 @@ void main() { }).toList(); } - Offset textOffsetToPosition(WidgetTester tester, int offset) { - final RenderEditable renderEditable = findRenderEditable(tester); - final List endpoints = globalize( - renderEditable.getEndpointsForSelection( - TextSelection.collapsed(offset: offset), - ), - renderEditable, - ); - expect(endpoints.length, 1); - return endpoints[0].point + const Offset(0.0, -2.0); - } - setUp(() { debugResetSemanticsIdCounter(); });