// 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/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'debug.dart'; import 'flat_button.dart'; import 'icon_button.dart'; import 'icons.dart'; import 'material.dart'; import 'material_localizations.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. 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, this.handleCut, this.handleCopy, this.handlePaste, this.handleSelectAll, this.isAbove, }) : super(key: key); 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(); } class _TextSelectionToolbarState extends State<_TextSelectionToolbar> 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 _TextSelectionToolbarContainer. UniqueKey _containerKey = UniqueKey(); FlatButton _getItem(VoidCallback onPressed, String label) { assert(onPressed != null); return FlatButton( child: Text(label), onPressed: onPressed, ); } @override void didUpdateWidget(_TextSelectionToolbar oldWidget) { 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))) { // 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; } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); final List<Widget> items = <Widget>[ if (widget.handleCut != null) _getItem(widget.handleCut, localizations.cutButtonLabel), if (widget.handleCopy != null) _getItem(widget.handleCopy, localizations.copyButtonLabel), if (widget.handlePaste != null) _getItem(widget.handlePaste, localizations.pasteButtonLabel), if (widget.handleSelectAll != null) _getItem(widget.handleSelectAll, localizations.selectAllButtonLabel), ]; // If there is no option available, build an empty widget. if (items.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( elevation: 1.0, child: _TextSelectionToolbarItems( isAbove: widget.isAbove, overflowOpen: _overflowOpen, children: <Widget>[ // The navButton that shows and hides the overflow menu is the // first child. Material( 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, ), ), ...items, ], ), ), ), ); } } // 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, { 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<Widget> 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<RenderBox, ToolbarItemsParentData> { _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) { performResize(); 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, { 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; } } /// 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({ 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 { /// Returns the size of the Material handle. @override Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize); /// Builder for material-style copy/paste text selection toolbar. @override Widget buildToolbar( BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset selectionMidpoint, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ) { 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: <Widget>[ CustomSingleChildLayout( delegate: _TextSelectionToolbarLayout( anchor, _kToolbarScreenPadding + paddingTop, fitsAbove, ), child: _TextSelectionToolbar( handleCut: canCut(delegate) ? () => handleCut(delegate) : null, handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, isAbove: fitsAbove, ), ), ], ); } /// Builder for material-style text selection handles. @override Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight) { final Widget handle = SizedBox( width: _kHandleSize, height: _kHandleSize, child: CustomPaint( painter: _TextSelectionHandlePainter( color: Theme.of(context).textSelectionHandleColor, ), ), ); // [handle] is a circle, with a rectangle in the top left quadrant of that // circle (an onion pointing to 10:30). We rotate [handle] to point // straight up or up-right depending on the handle type. switch (type) { case TextSelectionHandleType.left: // points up-right return Transform.rotate( angle: math.pi / 2.0, child: handle, ); case TextSelectionHandleType.right: // points up-left return handle; case TextSelectionHandleType.collapsed: // points up return Transform.rotate( angle: math.pi / 4.0, child: handle, ); } assert(type != null); return null; } /// Gets anchor for material-style text selection handles. /// /// See [TextSelectionControls.getHandleAnchor]. @override Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { switch (type) { case TextSelectionHandleType.left: return const Offset(_kHandleSize, 0); case TextSelectionHandleType.right: return Offset.zero; default: return const Offset(_kHandleSize / 2, -4); } } @override bool canSelectAll(TextSelectionDelegate delegate) { // Android allows SelectAll when selection is not collapsed, unless // everything has already been selected. final TextEditingValue value = delegate.textEditingValue; return delegate.selectAllEnabled && value.text.isNotEmpty && !(value.selection.start == 0 && value.selection.end == value.text.length); } } /// Text selection controls that follow the Material Design specification. final TextSelectionControls materialTextSelectionControls = _MaterialTextSelectionControls();