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'; ...@@ -136,6 +136,8 @@ export 'src/material/text_field.dart';
export 'src/material/text_form_field.dart'; export 'src/material/text_form_field.dart';
export 'src/material/text_selection.dart'; export 'src/material/text_selection.dart';
export 'src/material/text_selection_theme.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/text_theme.dart';
export 'src/material/theme.dart'; export 'src/material/theme.dart';
export 'src/material/theme_data.dart'; export 'src/material/theme_data.dart';
......
...@@ -9,115 +9,153 @@ import 'package:flutter/scheduler.dart'; ...@@ -9,115 +9,153 @@ import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'constants.dart';
import 'debug.dart'; import 'debug.dart';
import 'icon_button.dart';
import 'icons.dart';
import 'material.dart';
import 'material_localizations.dart'; import 'material_localizations.dart';
import 'text_button.dart';
import 'text_selection_theme.dart'; import 'text_selection_theme.dart';
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_text_button.dart';
import 'theme.dart'; import 'theme.dart';
const double _kHandleSize = 22.0; const double _kHandleSize = 22.0;
// Minimal padding from all edges of the selection toolbar to all edges of the // Padding between the toolbar and the anchor.
// 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 _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0; const double _kToolbarContentDistance = 8.0;
/// Manages a copy/paste text selection toolbar. /// Android Material styled text selection controls.
class _TextSelectionToolbar extends StatefulWidget { class MaterialTextSelectionControls extends TextSelectionControls {
const _TextSelectionToolbar({ /// 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, Key? key,
required this.clipboardStatus, required this.clipboardStatus,
required this.delegate,
required this.endpoints,
required this.globalEditableRegion,
required this.handleCut, required this.handleCut,
required this.handleCopy, required this.handleCopy,
required this.handlePaste, required this.handlePaste,
required this.handleSelectAll, required this.handleSelectAll,
required this.isAbove, required this.selectionMidpoint,
required this.textLineHeight,
}) : super(key: key); }) : super(key: key);
final ClipboardStatusNotifier? clipboardStatus; final ClipboardStatusNotifier clipboardStatus;
final TextSelectionDelegate delegate;
final List<TextSelectionPoint> endpoints;
final Rect globalEditableRegion;
final VoidCallback? handleCut; final VoidCallback? handleCut;
final VoidCallback? handleCopy; final VoidCallback? handleCopy;
final VoidCallback? handlePaste; final VoidCallback? handlePaste;
final VoidCallback? handleSelectAll; final VoidCallback? handleSelectAll;
final Offset selectionMidpoint;
// When true, the toolbar fits above its anchor and will be positioned there. final double textLineHeight;
final bool isAbove;
@override @override
_TextSelectionToolbarState createState() => _TextSelectionToolbarState(); _TextSelectionControlsToolbarState createState() => _TextSelectionControlsToolbarState();
} }
// Intermediate data used for building menu items with the _getItems method. class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToolbar> with TickerProviderStateMixin {
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() { void _onChangedClipboardStatus() {
setState(() { setState(() {
// Inform the widget that the value of clipboardStatus has changed. // Inform the widget that the value of clipboardStatus has changed.
...@@ -127,73 +165,86 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke ...@@ -127,73 +165,86 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier(); widget.clipboardStatus.addListener(_onChangedClipboardStatus);
_clipboardStatus.addListener(_onChangedClipboardStatus); widget.clipboardStatus.update();
_clipboardStatus.update();
} }
@override @override
void didUpdateWidget(_TextSelectionToolbar oldWidget) { void didUpdateWidget(_TextSelectionControlsToolbar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// If the children are changing, the current page should be reset. if (widget.clipboardStatus != oldWidget.clipboardStatus) {
if (((widget.handleCut == null) != (oldWidget.handleCut == null)) widget.clipboardStatus.addListener(_onChangedClipboardStatus);
|| ((widget.handleCopy == null) != (oldWidget.handleCopy == null)) oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus);
|| ((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();
} }
widget.clipboardStatus.update();
} }
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
// When used in an Overlay, this can be disposed after its creator has // When used in an Overlay, it can happen that this is disposed after its
// already disposed _clipboardStatus. // creator has already disposed _clipboardStatus.
if (!_clipboardStatus.disposed) { if (!widget.clipboardStatus.disposed) {
_clipboardStatus.removeListener(_onChangedClipboardStatus); widget.clipboardStatus.removeListener(_onChangedClipboardStatus);
if (widget.clipboardStatus == null) {
_clipboardStatus.dispose();
}
} }
} }
@override @override
Widget build(BuildContext context) { 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 if (widget.handlePaste != null
&& _clipboardStatus.value == ClipboardStatus.unknown) { && widget.clipboardStatus.value == ClipboardStatus.unknown) {
return const SizedBox(width: 0.0, height: 0.0); 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 MaterialLocalizations localizations = MaterialLocalizations.of(context);
final List<_ItemData> itemDatas = <_ItemData>[ final List<_TextSelectionToolbarItemData> itemDatas = <_TextSelectionToolbarItemData>[
if (widget.handleCut != null) if (widget.handleCut != null)
_ItemData(widget.handleCut!, localizations.cutButtonLabel), _TextSelectionToolbarItemData(
label: localizations.cutButtonLabel,
onPressed: widget.handleCut!,
),
if (widget.handleCopy != null) if (widget.handleCopy != null)
_ItemData(widget.handleCopy!, localizations.copyButtonLabel), _TextSelectionToolbarItemData(
label: localizations.copyButtonLabel,
onPressed: widget.handleCopy!,
),
if (widget.handlePaste != null if (widget.handlePaste != null
&& _clipboardStatus.value == ClipboardStatus.pasteable) && widget.clipboardStatus.value == ClipboardStatus.pasteable)
_ItemData(widget.handlePaste!, localizations.pasteButtonLabel), _TextSelectionToolbarItemData(
label: localizations.pasteButtonLabel,
onPressed: widget.handlePaste!,
),
if (widget.handleSelectAll != null) 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. // If there is no option available, build an empty widget.
...@@ -201,513 +252,18 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke ...@@ -201,513 +252,18 @@ class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with Ticke
return const SizedBox(width: 0.0, height: 0.0); return const SizedBox(width: 0.0, height: 0.0);
} }
return _TextSelectionToolbarContainer( return TextSelectionToolbar(
key: _containerKey, anchorAbove: anchorAbove,
overflowOpen: _overflowOpen, anchorBelow: anchorBelow,
child: AnimatedSize( children: itemDatas.asMap().entries.map((MapEntry<int, _TextSelectionToolbarItemData> entry) {
vsync: this, return TextSelectionToolbarTextButton(
// This duration was eyeballed on a Pixel 2 emulator running Android padding: TextSelectionToolbarTextButton.getPadding(entry.key, itemDatas.length),
// API 28. onPressed: entry.value.onPressed,
duration: const Duration(milliseconds: 140), child: Text(entry.value.label),
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,
); );
} else { }).toList(),
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. /// Draws a single text selection handle which points up and to the left.
...@@ -732,127 +288,5 @@ class _TextSelectionHandlePainter extends CustomPainter { ...@@ -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. /// 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 { ...@@ -567,20 +567,27 @@ class TextSelectionOverlay {
endpoints[0].point.dy - renderObject.preferredLineHeight, endpoints[0].point.dy - renderObject.preferredLineHeight,
); );
return FadeTransition( return Directionality(
opacity: _toolbarOpacity, textDirection: Directionality.of(this.context),
child: CompositedTransformFollower( child: FadeTransition(
link: toolbarLayerLink, opacity: _toolbarOpacity,
showWhenUnlinked: false, child: CompositedTransformFollower(
offset: -editingRegion.topLeft, link: toolbarLayerLink,
child: selectionControls!.buildToolbar( showWhenUnlinked: false,
context, offset: -editingRegion.topLeft,
editingRegion, child: Builder(
renderObject.preferredLineHeight, builder: (BuildContext context) {
midpoint, return selectionControls!.buildToolbar(
endpoints, context,
selectionDelegate!, editingRegion,
clipboardStatus!, renderObject.preferredLineHeight,
midpoint,
endpoints,
selectionDelegate!,
clipboardStatus!,
);
},
),
), ),
), ),
); );
...@@ -1516,7 +1523,7 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget ...@@ -1516,7 +1523,7 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget
Future<void> update() async { Future<void> update() async {
// iOS 14 added a notification that appears when an app accesses the // iOS 14 added a notification that appears when an app accesses the
// clipboard. To avoid the notification, don't access the clipboard on iOS, // 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. // empty.
// TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that // TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that
// won't trigger the notification. // 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'; ...@@ -12,6 +12,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
import '../widgets/text.dart' show textOffsetToPosition;
class MockClipboard { class MockClipboard {
dynamic _clipboardData = <String, dynamic>{ dynamic _clipboardData = <String, dynamic>{
...@@ -160,18 +161,6 @@ void main() { ...@@ -160,18 +161,6 @@ void main() {
}).toList(); }).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(() { setUp(() {
debugResetSemanticsIdCounter(); 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