Unverified Commit 7d2e3a18 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Material Text Selection Toolbar improvements (#69428)

Exposes the ability to create custom Material text selection toolbar menus, and cleans up the code around these menus.
parent 12232294
......@@ -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';
......
......@@ -9,115 +9,153 @@ 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({
/// 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);
/// 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,
ClipboardStatusNotifier clipboardStatus,
) {
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,
);
}
/// Builder for material-style text selection handles.
@override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight) {
final ThemeData theme = Theme.of(context);
final Color handleColor = TextSelectionTheme.of(context).selectionHandleColor ?? theme.colorScheme.primary;
final Widget handle = SizedBox(
width: _kHandleSize,
height: _kHandleSize,
child: CustomPaint(
painter: _TextSelectionHandlePainter(
color: handleColor,
),
),
);
// [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,
);
}
}
/// 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);
}
}
// 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.isAbove,
required this.selectionMidpoint,
required this.textLineHeight,
}) : super(key: key);
final ClipboardStatusNotifier? clipboardStatus;
final ClipboardStatusNotifier clipboardStatus;
final TextSelectionDelegate delegate;
final List<TextSelectionPoint> endpoints;
final Rect globalEditableRegion;
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;
final Offset selectionMidpoint;
final double textLineHeight;
@override
_TextSelectionToolbarState createState() => _TextSelectionToolbarState();
_TextSelectionControlsToolbarState createState() => _TextSelectionControlsToolbarState();
}
// 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;
}
class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToolbar> with TickerProviderStateMixin {
void _onChangedClipboardStatus() {
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
......@@ -127,73 +165,86 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke
@override
void initState() {
super.initState();
_clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier();
_clipboardStatus.addListener(_onChangedClipboardStatus);
_clipboardStatus.update();
widget.clipboardStatus.addListener(_onChangedClipboardStatus);
widget.clipboardStatus.update();
}
@override
void didUpdateWidget(_TextSelectionToolbar oldWidget) {
void didUpdateWidget(_TextSelectionControlsToolbar 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();
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, this can be disposed after its creator has
// already disposed _clipboardStatus.
if (!_clipboardStatus.disposed) {
_clipboardStatus.removeListener(_onChangedClipboardStatus);
if (widget.clipboardStatus == null) {
_clipboardStatus.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) {
// Don't render the menu until the state of the clipboard is known.
// 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
&& _clipboardStatus.value == ClipboardStatus.unknown) {
return const SizedBox(width: 0.0, height: 0.0);
&& 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<_ItemData> itemDatas = <_ItemData>[
final List<_TextSelectionToolbarItemData> itemDatas = <_TextSelectionToolbarItemData>[
if (widget.handleCut != null)
_ItemData(widget.handleCut!, localizations.cutButtonLabel),
_TextSelectionToolbarItemData(
label: localizations.cutButtonLabel,
onPressed: widget.handleCut!,
),
if (widget.handleCopy != null)
_ItemData(widget.handleCopy!, localizations.copyButtonLabel),
_TextSelectionToolbarItemData(
label: localizations.copyButtonLabel,
onPressed: widget.handleCopy!,
),
if (widget.handlePaste != null
&& _clipboardStatus.value == ClipboardStatus.pasteable)
_ItemData(widget.handlePaste!, localizations.pasteButtonLabel),
&& widget.clipboardStatus.value == ClipboardStatus.pasteable)
_TextSelectionToolbarItemData(
label: localizations.pasteButtonLabel,
onPressed: widget.handlePaste!,
),
if (widget.handleSelectAll != null)
_ItemData(widget.handleSelectAll!, localizations.selectAllButtonLabel),
_TextSelectionToolbarItemData(
label: localizations.selectAllButtonLabel,
onPressed: widget.handleSelectAll!,
),
];
// If there is no option available, build an empty widget.
......@@ -201,513 +252,18 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke
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: <Widget>[
// 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<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,
return TextSelectionToolbar(
anchorAbove: anchorAbove,
anchorBelow: anchorBelow,
children: itemDatas.asMap().entries.map((MapEntry<int, _TextSelectionToolbarItemData> entry) {
return TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(entry.key, itemDatas.length),
onPressed: entry.value.onPressed,
child: Text(entry.value.label),
);
} 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,
}).toList(),
);
}
@override
bool shouldRelayout(_TextSelectionToolbarLayout oldDelegate) {
return anchor != oldDelegate.anchor;
}
}
/// Draws a single text selection handle which points up and to the left.
......@@ -732,127 +288,5 @@ class _TextSelectionHandlePainter extends CustomPainter {
}
}
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,
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: <Widget>[
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,
),
),
],
);
}
/// Builder for material-style text selection handles.
@override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight) {
final ThemeData theme = Theme.of(context);
final Color handleColor = TextSelectionTheme.of(context).selectionHandleColor ?? theme.colorScheme.primary;
final Widget handle = SizedBox(
width: _kHandleSize,
height: _kHandleSize,
child: CustomPaint(
painter: _TextSelectionHandlePainter(
color: handleColor,
),
),
);
// [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,
);
}
}
/// 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();
final TextSelectionControls materialTextSelectionControls = MaterialTextSelectionControls();
// 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<Widget> 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: <Widget>[
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<Widget> 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: <Widget>[
// 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<Widget> 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<RenderBox, ToolbarItemsParentData> {
_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,
),
);
}
}
// 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,
);
}
}
......@@ -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<ClipboardStatus> with Widget
Future<void> 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.
......
// 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<TextSelectionPoint> 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: <Widget>[
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<Widget> children = <Widget>[
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: <Widget>[
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);
}
// 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));
});
}
......@@ -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 = <String, dynamic>{
......@@ -160,18 +161,6 @@ void main() {
}).toList();
}
Offset textOffsetToPosition(WidgetTester tester, int offset) {
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> 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();
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment