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.
This commit is contained in:
parent
12232294c1
commit
7d2e3a1859
@ -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,730 +9,21 @@ import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'colors.dart';
|
||||
import 'constants.dart';
|
||||
import 'debug.dart';
|
||||
import 'icon_button.dart';
|
||||
import 'icons.dart';
|
||||
import 'material.dart';
|
||||
import 'material_localizations.dart';
|
||||
import 'text_button.dart';
|
||||
import 'text_selection_theme.dart';
|
||||
import 'text_selection_toolbar.dart';
|
||||
import 'text_selection_toolbar_text_button.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
const double _kHandleSize = 22.0;
|
||||
|
||||
// Minimal padding from all edges of the selection toolbar to all edges of the
|
||||
// viewport.
|
||||
const double _kToolbarScreenPadding = 8.0;
|
||||
const double _kToolbarHeight = 44.0;
|
||||
// Padding when positioning toolbar below selection.
|
||||
// Padding between the toolbar and the anchor.
|
||||
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
|
||||
const double _kToolbarContentDistance = 8.0;
|
||||
|
||||
/// Manages a copy/paste text selection toolbar.
|
||||
class _TextSelectionToolbar extends StatefulWidget {
|
||||
const _TextSelectionToolbar({
|
||||
Key? key,
|
||||
required this.clipboardStatus,
|
||||
required this.handleCut,
|
||||
required this.handleCopy,
|
||||
required this.handlePaste,
|
||||
required this.handleSelectAll,
|
||||
required this.isAbove,
|
||||
}) : super(key: key);
|
||||
|
||||
final ClipboardStatusNotifier? clipboardStatus;
|
||||
final VoidCallback? handleCut;
|
||||
final VoidCallback? handleCopy;
|
||||
final VoidCallback? handlePaste;
|
||||
final VoidCallback? handleSelectAll;
|
||||
|
||||
// When true, the toolbar fits above its anchor and will be positioned there.
|
||||
final bool isAbove;
|
||||
|
||||
@override
|
||||
_TextSelectionToolbarState createState() => _TextSelectionToolbarState();
|
||||
}
|
||||
|
||||
// Intermediate data used for building menu items with the _getItems method.
|
||||
class _ItemData {
|
||||
const _ItemData(
|
||||
this.onPressed,
|
||||
this.label,
|
||||
) : assert(onPressed != null),
|
||||
assert(label != null);
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class _TextSelectionToolbarState extends State<_TextSelectionToolbar> with TickerProviderStateMixin {
|
||||
late ClipboardStatusNotifier _clipboardStatus;
|
||||
|
||||
// Whether or not the overflow menu is open. When it is closed, the menu
|
||||
// items that don't overflow are shown. When it is open, only the overflowing
|
||||
// menu items are shown.
|
||||
bool _overflowOpen = false;
|
||||
|
||||
// The key for _TextSelectionToolbarContainer.
|
||||
UniqueKey _containerKey = UniqueKey();
|
||||
|
||||
Widget _getItem(_ItemData itemData, bool isFirst, bool isLast) {
|
||||
assert(isFirst != null);
|
||||
assert(isLast != null);
|
||||
|
||||
// TODO(hansmuller): Should be colorScheme.onSurface
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final bool isDark = theme.colorScheme.brightness == Brightness.dark;
|
||||
final Color primary = isDark ? Colors.white : Colors.black87;
|
||||
|
||||
return TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
primary: primary,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension),
|
||||
padding: EdgeInsets.only(
|
||||
// These values were eyeballed to match the native text selection menu
|
||||
// on a Pixel 2 running Android 10.
|
||||
left: 9.5 + (isFirst ? 5.0 : 0.0),
|
||||
right: 9.5 + (isLast ? 5.0 : 0.0),
|
||||
),
|
||||
),
|
||||
onPressed: itemData.onPressed,
|
||||
child: Text(itemData.label),
|
||||
);
|
||||
}
|
||||
|
||||
// Close the menu and reset layout calculations, as in when the menu has
|
||||
// changed and saved values are no longer relevant. This should be called in
|
||||
// setState or another context where a rebuild is happening.
|
||||
void _reset() {
|
||||
// Change _TextSelectionToolbarContainer's key when the menu changes in
|
||||
// order to cause it to rebuild. This lets it recalculate its
|
||||
// saved width for the new set of children, and it prevents AnimatedSize
|
||||
// from animating the size change.
|
||||
_containerKey = UniqueKey();
|
||||
// If the menu items change, make sure the overflow menu is closed. This
|
||||
// prevents an empty overflow menu.
|
||||
_overflowOpen = false;
|
||||
}
|
||||
|
||||
void _onChangedClipboardStatus() {
|
||||
setState(() {
|
||||
// Inform the widget that the value of clipboardStatus has changed.
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier();
|
||||
_clipboardStatus.addListener(_onChangedClipboardStatus);
|
||||
_clipboardStatus.update();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_TextSelectionToolbar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// If the children are changing, the current page should be reset.
|
||||
if (((widget.handleCut == null) != (oldWidget.handleCut == null))
|
||||
|| ((widget.handleCopy == null) != (oldWidget.handleCopy == null))
|
||||
|| ((widget.handlePaste == null) != (oldWidget.handlePaste == null))
|
||||
|| ((widget.handleSelectAll == null) != (oldWidget.handleSelectAll == null))) {
|
||||
_reset();
|
||||
}
|
||||
if (oldWidget.clipboardStatus == null && widget.clipboardStatus != null) {
|
||||
_clipboardStatus.removeListener(_onChangedClipboardStatus);
|
||||
_clipboardStatus.dispose();
|
||||
_clipboardStatus = widget.clipboardStatus!;
|
||||
} else if (oldWidget.clipboardStatus != null) {
|
||||
if (widget.clipboardStatus == null) {
|
||||
_clipboardStatus = ClipboardStatusNotifier();
|
||||
_clipboardStatus.addListener(_onChangedClipboardStatus);
|
||||
oldWidget.clipboardStatus!.removeListener(_onChangedClipboardStatus);
|
||||
} else if (widget.clipboardStatus != oldWidget.clipboardStatus) {
|
||||
_clipboardStatus = widget.clipboardStatus!;
|
||||
_clipboardStatus.addListener(_onChangedClipboardStatus);
|
||||
oldWidget.clipboardStatus!.removeListener(_onChangedClipboardStatus);
|
||||
}
|
||||
}
|
||||
if (widget.handlePaste != null) {
|
||||
_clipboardStatus.update();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
// When used in an Overlay, this can be disposed after its creator has
|
||||
// already disposed _clipboardStatus.
|
||||
if (!_clipboardStatus.disposed) {
|
||||
_clipboardStatus.removeListener(_onChangedClipboardStatus);
|
||||
if (widget.clipboardStatus == null) {
|
||||
_clipboardStatus.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Don't render the menu until the state of the clipboard is known.
|
||||
if (widget.handlePaste != null
|
||||
&& _clipboardStatus.value == ClipboardStatus.unknown) {
|
||||
return const SizedBox(width: 0.0, height: 0.0);
|
||||
}
|
||||
|
||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
||||
final List<_ItemData> itemDatas = <_ItemData>[
|
||||
if (widget.handleCut != null)
|
||||
_ItemData(widget.handleCut!, localizations.cutButtonLabel),
|
||||
if (widget.handleCopy != null)
|
||||
_ItemData(widget.handleCopy!, localizations.copyButtonLabel),
|
||||
if (widget.handlePaste != null
|
||||
&& _clipboardStatus.value == ClipboardStatus.pasteable)
|
||||
_ItemData(widget.handlePaste!, localizations.pasteButtonLabel),
|
||||
if (widget.handleSelectAll != null)
|
||||
_ItemData(widget.handleSelectAll!, localizations.selectAllButtonLabel),
|
||||
];
|
||||
|
||||
// If there is no option available, build an empty widget.
|
||||
if (itemDatas.isEmpty) {
|
||||
return const SizedBox(width: 0.0, height: 0.0);
|
||||
}
|
||||
|
||||
return _TextSelectionToolbarContainer(
|
||||
key: _containerKey,
|
||||
overflowOpen: _overflowOpen,
|
||||
child: AnimatedSize(
|
||||
vsync: this,
|
||||
// This duration was eyeballed on a Pixel 2 emulator running Android
|
||||
// API 28.
|
||||
duration: const Duration(milliseconds: 140),
|
||||
child: Material(
|
||||
// This value was eyeballed to match the native text selection menu on
|
||||
// a Pixel 2 running Android 10.
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7.0)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 1.0,
|
||||
type: MaterialType.card,
|
||||
child: _TextSelectionToolbarItems(
|
||||
isAbove: widget.isAbove,
|
||||
overflowOpen: _overflowOpen,
|
||||
children: <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 {
|
||||
navButtonParentData.offset = Offset(fitWidth, 0.0);
|
||||
nextSize = Size(nextSize.width + navButton.size.width, nextSize.height);
|
||||
}
|
||||
} else {
|
||||
navButtonParentData.shouldPaint = false;
|
||||
}
|
||||
|
||||
size = nextSize;
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
_lastIndexThatFits = -1;
|
||||
if (firstChild == null) {
|
||||
size = constraints.smallest;
|
||||
return;
|
||||
}
|
||||
|
||||
_layoutChildren();
|
||||
_placeChildren();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
visitChildren((RenderObject renderObjectChild) {
|
||||
final RenderBox child = renderObjectChild as RenderBox;
|
||||
final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData;
|
||||
if (!childParentData.shouldPaint) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.paintChild(child, childParentData.offset + offset);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! ToolbarItemsParentData) {
|
||||
child.parentData = ToolbarItemsParentData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
|
||||
// The x, y parameters have the top left of the node's box as the origin.
|
||||
RenderBox? child = lastChild;
|
||||
while (child != null) {
|
||||
final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData;
|
||||
|
||||
// Don't hit test children aren't shown.
|
||||
if (!childParentData.shouldPaint) {
|
||||
child = childParentData.previousSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
final bool isHit = result.addWithPaintOffset(
|
||||
offset: childParentData.offset,
|
||||
position: position,
|
||||
hitTest: (BoxHitTestResult result, Offset transformed) {
|
||||
assert(transformed == position - childParentData.offset);
|
||||
return child!.hitTest(result, position: transformed);
|
||||
},
|
||||
);
|
||||
if (isHit)
|
||||
return true;
|
||||
child = childParentData.previousSibling;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Visit only the children that should be painted.
|
||||
@override
|
||||
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
|
||||
visitChildren((RenderObject renderObjectChild) {
|
||||
final RenderBox child = renderObjectChild as RenderBox;
|
||||
final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData;
|
||||
if (childParentData.shouldPaint) {
|
||||
visitor(renderObjectChild);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Centers the toolbar around the given anchor, ensuring that it remains on
|
||||
/// screen.
|
||||
class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate {
|
||||
_TextSelectionToolbarLayout(this.anchor, this.upperBounds, this.fitsAbove);
|
||||
|
||||
/// Anchor position of the toolbar in global coordinates.
|
||||
final Offset anchor;
|
||||
|
||||
/// The upper-most valid y value for the anchor.
|
||||
final double upperBounds;
|
||||
|
||||
/// Whether the closed toolbar fits above the anchor position.
|
||||
///
|
||||
/// If the closed toolbar doesn't fit, then the menu is rendered below the
|
||||
/// anchor position. It should never happen that the toolbar extends below the
|
||||
/// padded bottom of the screen.
|
||||
///
|
||||
/// If the closed toolbar does fit but it doesn't fit when the overflow menu
|
||||
/// is open, then the toolbar is still rendered above the anchor position. It
|
||||
/// then grows downward, overlapping the selection.
|
||||
final bool fitsAbove;
|
||||
|
||||
// Return the value that centers width as closely as possible to position
|
||||
// while fitting inside of min and max.
|
||||
static double _centerOn(double position, double width, double min, double max) {
|
||||
// If it overflows on the left, put it as far left as possible.
|
||||
if (position - width / 2.0 < min) {
|
||||
return min;
|
||||
}
|
||||
|
||||
// If it overflows on the right, put it as far right as possible.
|
||||
if (position + width / 2.0 > max) {
|
||||
return max - width;
|
||||
}
|
||||
|
||||
// Otherwise it fits while perfectly centered.
|
||||
return position - width / 2.0;
|
||||
}
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
return constraints.loosen();
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
return Offset(
|
||||
_centerOn(
|
||||
anchor.dx,
|
||||
childSize.width,
|
||||
_kToolbarScreenPadding,
|
||||
size.width - _kToolbarScreenPadding,
|
||||
),
|
||||
fitsAbove
|
||||
? math.max(upperBounds, anchor.dy - childSize.height)
|
||||
: anchor.dy,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_TextSelectionToolbarLayout oldDelegate) {
|
||||
return anchor != oldDelegate.anchor;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a single text selection handle which points up and to the left.
|
||||
class _TextSelectionHandlePainter extends CustomPainter {
|
||||
_TextSelectionHandlePainter({ required this.color });
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Paint paint = Paint()..color = color;
|
||||
final double radius = size.width/2.0;
|
||||
final Rect circle = Rect.fromCircle(center: Offset(radius, radius), radius: radius);
|
||||
final Rect point = Rect.fromLTWH(0.0, 0.0, radius, radius);
|
||||
final Path path = Path()..addOval(circle)..addRect(point);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) {
|
||||
return color != oldPainter.color;
|
||||
}
|
||||
}
|
||||
|
||||
class _MaterialTextSelectionControls extends TextSelectionControls {
|
||||
/// Android Material styled text selection controls.
|
||||
class MaterialTextSelectionControls extends TextSelectionControls {
|
||||
/// Returns the size of the Material handle.
|
||||
@override
|
||||
Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize);
|
||||
@ -748,49 +39,17 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
|
||||
TextSelectionDelegate delegate,
|
||||
ClipboardStatusNotifier clipboardStatus,
|
||||
) {
|
||||
assert(debugCheckHasMediaQuery(context));
|
||||
assert(debugCheckHasMaterialLocalizations(context));
|
||||
|
||||
// The toolbar should appear below the TextField when there is not enough
|
||||
// space above the TextField to show it.
|
||||
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
|
||||
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1
|
||||
? endpoints[1]
|
||||
: endpoints[0];
|
||||
const double closedToolbarHeightNeeded = _kToolbarScreenPadding
|
||||
+ _kToolbarHeight
|
||||
+ _kToolbarContentDistance;
|
||||
final double paddingTop = MediaQuery.of(context).padding.top;
|
||||
final double availableHeight = globalEditableRegion.top
|
||||
+ startTextSelectionPoint.point.dy
|
||||
- textLineHeight
|
||||
- paddingTop;
|
||||
final bool fitsAbove = closedToolbarHeightNeeded <= availableHeight;
|
||||
final Offset anchor = Offset(
|
||||
globalEditableRegion.left + selectionMidpoint.dx,
|
||||
fitsAbove
|
||||
? globalEditableRegion.top + startTextSelectionPoint.point.dy - textLineHeight - _kToolbarContentDistance
|
||||
: globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
CustomSingleChildLayout(
|
||||
delegate: _TextSelectionToolbarLayout(
|
||||
anchor,
|
||||
_kToolbarScreenPadding + paddingTop,
|
||||
fitsAbove,
|
||||
),
|
||||
child: _TextSelectionToolbar(
|
||||
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,
|
||||
isAbove: fitsAbove,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -854,5 +113,180 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
|
||||
}
|
||||
}
|
||||
|
||||
// The label and callback for the available default text selection menu buttons.
|
||||
class _TextSelectionToolbarItemData {
|
||||
const _TextSelectionToolbarItemData({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
}
|
||||
|
||||
// The highest level toolbar widget, built directly by buildToolbar.
|
||||
class _TextSelectionControlsToolbar extends StatefulWidget {
|
||||
const _TextSelectionControlsToolbar({
|
||||
Key? key,
|
||||
required this.clipboardStatus,
|
||||
required this.delegate,
|
||||
required this.endpoints,
|
||||
required this.globalEditableRegion,
|
||||
required this.handleCut,
|
||||
required this.handleCopy,
|
||||
required this.handlePaste,
|
||||
required this.handleSelectAll,
|
||||
required this.selectionMidpoint,
|
||||
required this.textLineHeight,
|
||||
}) : super(key: key);
|
||||
|
||||
final ClipboardStatusNotifier clipboardStatus;
|
||||
final TextSelectionDelegate delegate;
|
||||
final List<TextSelectionPoint> endpoints;
|
||||
final Rect globalEditableRegion;
|
||||
final VoidCallback? handleCut;
|
||||
final VoidCallback? handleCopy;
|
||||
final VoidCallback? handlePaste;
|
||||
final VoidCallback? handleSelectAll;
|
||||
final Offset selectionMidpoint;
|
||||
final double textLineHeight;
|
||||
|
||||
@override
|
||||
_TextSelectionControlsToolbarState createState() => _TextSelectionControlsToolbarState();
|
||||
}
|
||||
|
||||
class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToolbar> with TickerProviderStateMixin {
|
||||
void _onChangedClipboardStatus() {
|
||||
setState(() {
|
||||
// Inform the widget that the value of clipboardStatus has changed.
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.clipboardStatus.addListener(_onChangedClipboardStatus);
|
||||
widget.clipboardStatus.update();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_TextSelectionControlsToolbar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.clipboardStatus != oldWidget.clipboardStatus) {
|
||||
widget.clipboardStatus.addListener(_onChangedClipboardStatus);
|
||||
oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus);
|
||||
}
|
||||
widget.clipboardStatus.update();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
// When used in an Overlay, it can happen that this is disposed after its
|
||||
// creator has already disposed _clipboardStatus.
|
||||
if (!widget.clipboardStatus.disposed) {
|
||||
widget.clipboardStatus.removeListener(_onChangedClipboardStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// If there are no buttons to be shown, don't render anything.
|
||||
if (widget.handleCut == null && widget.handleCopy == null
|
||||
&& widget.handlePaste == null && widget.handleSelectAll == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// If the paste button is desired, don't render anything until the state of
|
||||
// the clipboard is known, since it's used to determine if paste is shown.
|
||||
if (widget.handlePaste != null
|
||||
&& widget.clipboardStatus.value == ClipboardStatus.unknown) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Calculate the positioning of the menu. It is placed above the selection
|
||||
// if there is enough room, or otherwise below.
|
||||
final TextSelectionPoint startTextSelectionPoint = widget.endpoints[0];
|
||||
final TextSelectionPoint endTextSelectionPoint = widget.endpoints.length > 1
|
||||
? widget.endpoints[1]
|
||||
: widget.endpoints[0];
|
||||
final Offset anchorAbove = Offset(
|
||||
widget.globalEditableRegion.left + widget.selectionMidpoint.dx,
|
||||
widget.globalEditableRegion.top + startTextSelectionPoint.point.dy - widget.textLineHeight - _kToolbarContentDistance
|
||||
);
|
||||
final Offset anchorBelow = Offset(
|
||||
widget.globalEditableRegion.left + widget.selectionMidpoint.dx,
|
||||
widget.globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow,
|
||||
);
|
||||
|
||||
// Determine which buttons will appear so that the order and total number is
|
||||
// known. A button's position in the menu can slightly affect its
|
||||
// appearance.
|
||||
assert(debugCheckHasMaterialLocalizations(context));
|
||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
||||
final List<_TextSelectionToolbarItemData> itemDatas = <_TextSelectionToolbarItemData>[
|
||||
if (widget.handleCut != null)
|
||||
_TextSelectionToolbarItemData(
|
||||
label: localizations.cutButtonLabel,
|
||||
onPressed: widget.handleCut!,
|
||||
),
|
||||
if (widget.handleCopy != null)
|
||||
_TextSelectionToolbarItemData(
|
||||
label: localizations.copyButtonLabel,
|
||||
onPressed: widget.handleCopy!,
|
||||
),
|
||||
if (widget.handlePaste != null
|
||||
&& widget.clipboardStatus.value == ClipboardStatus.pasteable)
|
||||
_TextSelectionToolbarItemData(
|
||||
label: localizations.pasteButtonLabel,
|
||||
onPressed: widget.handlePaste!,
|
||||
),
|
||||
if (widget.handleSelectAll != null)
|
||||
_TextSelectionToolbarItemData(
|
||||
label: localizations.selectAllButtonLabel,
|
||||
onPressed: widget.handleSelectAll!,
|
||||
),
|
||||
];
|
||||
|
||||
// If there is no option available, build an empty widget.
|
||||
if (itemDatas.isEmpty) {
|
||||
return const SizedBox(width: 0.0, height: 0.0);
|
||||
}
|
||||
|
||||
return TextSelectionToolbar(
|
||||
anchorAbove: anchorAbove,
|
||||
anchorBelow: anchorBelow,
|
||||
children: itemDatas.asMap().entries.map((MapEntry<int, _TextSelectionToolbarItemData> entry) {
|
||||
return TextSelectionToolbarTextButton(
|
||||
padding: TextSelectionToolbarTextButton.getPadding(entry.key, itemDatas.length),
|
||||
onPressed: entry.value.onPressed,
|
||||
child: Text(entry.value.label),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a single text selection handle which points up and to the left.
|
||||
class _TextSelectionHandlePainter extends CustomPainter {
|
||||
_TextSelectionHandlePainter({ required this.color });
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Paint paint = Paint()..color = color;
|
||||
final double radius = size.width/2.0;
|
||||
final Rect circle = Rect.fromCircle(center: Offset(radius, radius), radius: radius);
|
||||
final Rect point = Rect.fromLTWH(0.0, 0.0, radius, radius);
|
||||
final Path path = Path()..addOval(circle)..addRect(point);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) {
|
||||
return color != oldPainter.color;
|
||||
}
|
||||
}
|
||||
|
||||
/// Text selection controls that follow the Material Design specification.
|
||||
final TextSelectionControls materialTextSelectionControls = _MaterialTextSelectionControls();
|
||||
final TextSelectionControls materialTextSelectionControls = MaterialTextSelectionControls();
|
||||
|
751
packages/flutter/lib/src/material/text_selection_toolbar.dart
Normal file
751
packages/flutter/lib/src/material/text_selection_toolbar.dart
Normal file
@ -0,0 +1,751 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'debug.dart';
|
||||
import 'icon_button.dart';
|
||||
import 'icons.dart';
|
||||
import 'material.dart';
|
||||
import 'material_localizations.dart';
|
||||
|
||||
// Minimal padding from all edges of the selection toolbar to all edges of the
|
||||
// viewport.
|
||||
const double _kToolbarScreenPadding = 8.0;
|
||||
const double _kToolbarHeight = 44.0;
|
||||
|
||||
/// The type for a Function that builds a toolbar's container with the given
|
||||
/// child.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [TextSelectionToolbar.toolbarBuilder], which is of this type.
|
||||
typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child);
|
||||
|
||||
/// A fully-functional Material-style text selection toolbar.
|
||||
///
|
||||
/// Tries to position itself above [anchorAbove], but if it doesn't fit, then
|
||||
/// it positions itself below [anchorBelow].
|
||||
///
|
||||
/// If any children don't fit in the menu, an overflow menu will automatically
|
||||
/// be created.
|
||||
class TextSelectionToolbar extends StatelessWidget {
|
||||
/// Creates an instance of TextSelectionToolbar.
|
||||
const TextSelectionToolbar({
|
||||
Key? key,
|
||||
required this.anchorAbove,
|
||||
required this.anchorBelow,
|
||||
this.toolbarBuilder = _defaultToolbarBuilder,
|
||||
required this.children,
|
||||
}) : assert(children.length > 0),
|
||||
super(key: key);
|
||||
|
||||
/// The focal point above which the toolbar attempts to position itself.
|
||||
///
|
||||
/// If there is not enough room above before reaching the top of the screen,
|
||||
/// then the toolbar will position itself below [anchorBelow].
|
||||
final Offset anchorAbove;
|
||||
|
||||
/// The focal point below which the toolbar attempts to position itself, if it
|
||||
/// doesn't fit above [anchorAbove].
|
||||
final Offset anchorBelow;
|
||||
|
||||
/// The children that will be displayed in the text selection toolbar.
|
||||
///
|
||||
/// Typically these are buttons.
|
||||
///
|
||||
/// Must not be empty.
|
||||
///
|
||||
/// See also:
|
||||
/// * [TextSelectionToolbarTextButton], which builds a default Material-
|
||||
/// style text selection toolbar text button.
|
||||
final List<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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'colors.dart';
|
||||
import 'constants.dart';
|
||||
import 'text_button.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
enum _TextSelectionToolbarItemPosition {
|
||||
/// The first item among multiple in the menu.
|
||||
first,
|
||||
|
||||
/// One of several items, not the first or last.
|
||||
middle,
|
||||
|
||||
/// The last item among multiple in the menu.
|
||||
last,
|
||||
|
||||
/// The only item in the menu.
|
||||
only,
|
||||
}
|
||||
|
||||
/// A button styled like a Material native Android text selection menu button.
|
||||
class TextSelectionToolbarTextButton extends StatelessWidget {
|
||||
/// Creates an instance of TextSelectionToolbarTextButton.
|
||||
const TextSelectionToolbarTextButton({
|
||||
Key? key,
|
||||
required this.child,
|
||||
required this.padding,
|
||||
this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
// These values were eyeballed to match the native text selection menu on a
|
||||
// Pixel 2 running Android 10.
|
||||
static const double _kMiddlePadding = 9.5;
|
||||
static const double _kEndPadding = 14.5;
|
||||
|
||||
/// The child of this button.
|
||||
///
|
||||
/// Usually a [Text].
|
||||
final Widget child;
|
||||
|
||||
/// Called when this button is pressed.
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// The padding between the button's edge and its child.
|
||||
///
|
||||
/// In a standard Material [TextSelectionToolbar], the padding depends on the
|
||||
/// button's position within the toolbar.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [getPadding], which calculates the standard padding based on the
|
||||
/// button's position.
|
||||
/// * [ButtonStyle.padding], which is where this padding is applied.
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// Returns the standard padding for a button at index out of a total number
|
||||
/// of buttons.
|
||||
///
|
||||
/// Standard Material [TextSelectionToolbar]s have buttons with different
|
||||
/// padding depending on their position in the toolbar.
|
||||
static EdgeInsets getPadding(int index, int total) {
|
||||
assert(total > 0 && index >= 0 && index < total);
|
||||
final _TextSelectionToolbarItemPosition position = _getPosition(index, total);
|
||||
return EdgeInsets.only(
|
||||
left: _getLeftPadding(position),
|
||||
right: _getRightPadding(position),
|
||||
);
|
||||
}
|
||||
|
||||
static double _getLeftPadding(_TextSelectionToolbarItemPosition position) {
|
||||
if (position == _TextSelectionToolbarItemPosition.first
|
||||
|| position == _TextSelectionToolbarItemPosition.only) {
|
||||
return _kEndPadding;
|
||||
}
|
||||
return _kMiddlePadding;
|
||||
}
|
||||
|
||||
static double _getRightPadding(_TextSelectionToolbarItemPosition position) {
|
||||
if (position == _TextSelectionToolbarItemPosition.last
|
||||
|| position == _TextSelectionToolbarItemPosition.only) {
|
||||
return _kEndPadding;
|
||||
}
|
||||
return _kMiddlePadding;
|
||||
}
|
||||
|
||||
static _TextSelectionToolbarItemPosition _getPosition(int index, int total) {
|
||||
if (index == 0) {
|
||||
return total == 1
|
||||
? _TextSelectionToolbarItemPosition.only
|
||||
: _TextSelectionToolbarItemPosition.first;
|
||||
}
|
||||
if (index == total - 1) {
|
||||
return _TextSelectionToolbarItemPosition.last;
|
||||
}
|
||||
return _TextSelectionToolbarItemPosition.middle;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO(hansmuller): Should be colorScheme.onSurface
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final bool isDark = theme.colorScheme.brightness == Brightness.dark;
|
||||
final Color primary = isDark ? Colors.white : Colors.black87;
|
||||
|
||||
return TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
primary: primary,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension),
|
||||
padding: padding,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
@ -567,13 +567,17 @@ class TextSelectionOverlay {
|
||||
endpoints[0].point.dy - renderObject.preferredLineHeight,
|
||||
);
|
||||
|
||||
return FadeTransition(
|
||||
return Directionality(
|
||||
textDirection: Directionality.of(this.context),
|
||||
child: FadeTransition(
|
||||
opacity: _toolbarOpacity,
|
||||
child: CompositedTransformFollower(
|
||||
link: toolbarLayerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: -editingRegion.topLeft,
|
||||
child: selectionControls!.buildToolbar(
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return selectionControls!.buildToolbar(
|
||||
context,
|
||||
editingRegion,
|
||||
renderObject.preferredLineHeight,
|
||||
@ -581,6 +585,9 @@ class TextSelectionOverlay {
|
||||
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.
|
||||
|
211
packages/flutter/test/material/text_selection_toolbar_test.dart
Normal file
211
packages/flutter/test/material/text_selection_toolbar_test.dart
Normal file
@ -0,0 +1,211 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../widgets/text.dart' show textOffsetToPosition;
|
||||
|
||||
// A custom text selection menu that just displays a single custom button.
|
||||
class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls {
|
||||
static const double _kToolbarContentDistanceBelow = 20.0;
|
||||
static const double _kToolbarContentDistance = 8.0;
|
||||
|
||||
@override
|
||||
Widget buildToolbar(
|
||||
BuildContext context,
|
||||
Rect globalEditableRegion,
|
||||
double textLineHeight,
|
||||
Offset selectionMidpoint,
|
||||
List<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);
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('position in the toolbar changes width', (WidgetTester tester) async {
|
||||
late StateSetter setState;
|
||||
int index = 1;
|
||||
int total = 3;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter setter) {
|
||||
setState = setter;
|
||||
return TextSelectionToolbarTextButton(
|
||||
child: const Text('button'),
|
||||
padding: TextSelectionToolbarTextButton.getPadding(index, total),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final Size middleSize = tester.getSize(find.byType(TextSelectionToolbarTextButton));
|
||||
|
||||
setState(() {
|
||||
index = 0;
|
||||
total = 3;
|
||||
});
|
||||
await tester.pump();
|
||||
final Size firstSize = tester.getSize(find.byType(TextSelectionToolbarTextButton));
|
||||
expect(firstSize.width, greaterThan(middleSize.width));
|
||||
|
||||
setState(() {
|
||||
index = 2;
|
||||
total = 3;
|
||||
});
|
||||
await tester.pump();
|
||||
final Size lastSize = tester.getSize(find.byType(TextSelectionToolbarTextButton));
|
||||
expect(lastSize.width, greaterThan(middleSize.width));
|
||||
expect(lastSize.width, equals(firstSize.width));
|
||||
|
||||
setState(() {
|
||||
index = 0;
|
||||
total = 1;
|
||||
});
|
||||
await tester.pump();
|
||||
final Size onlySize = tester.getSize(find.byType(TextSelectionToolbarTextButton));
|
||||
expect(onlySize.width, greaterThan(middleSize.width));
|
||||
expect(onlySize.width, greaterThan(firstSize.width));
|
||||
expect(onlySize.width, greaterThan(lastSize.width));
|
||||
});
|
||||
}
|
@ -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();
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user