Unverified Commit d9f3d2e8 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Cupertino text selection menu customization (#73578)

  * Making a custom iOS-style text selection menu is now much easier.
  * Exposes a new widget for the toolbar, a new widget for the buttons, and a new widget for the layout.
parent 7cb0c16e
...@@ -55,6 +55,8 @@ export 'src/cupertino/tab_view.dart'; ...@@ -55,6 +55,8 @@ export 'src/cupertino/tab_view.dart';
export 'src/cupertino/text_field.dart'; export 'src/cupertino/text_field.dart';
export 'src/cupertino/text_form_field_row.dart'; export 'src/cupertino/text_form_field_row.dart';
export 'src/cupertino/text_selection.dart'; export 'src/cupertino/text_selection.dart';
export 'src/cupertino/text_selection_toolbar.dart';
export 'src/cupertino/text_selection_toolbar_button.dart';
export 'src/cupertino/text_theme.dart'; export 'src/cupertino/text_theme.dart';
export 'src/cupertino/theme.dart'; export 'src/cupertino/theme.dart';
export 'src/cupertino/thumb_painter.dart'; export 'src/cupertino/thumb_painter.dart';
......
...@@ -2,16 +2,14 @@ ...@@ -2,16 +2,14 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:collection';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'button.dart';
import 'colors.dart';
import 'localizations.dart'; import 'localizations.dart';
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_button.dart';
import 'theme.dart'; import 'theme.dart';
// Read off from the output on iOS 12. This color does not vary with the // Read off from the output on iOS 12. This color does not vary with the
...@@ -20,76 +18,40 @@ const double _kSelectionHandleOverlap = 1.5; ...@@ -20,76 +18,40 @@ const double _kSelectionHandleOverlap = 1.5;
// Extracted from https://developer.apple.com/design/resources/. // Extracted from https://developer.apple.com/design/resources/.
const double _kSelectionHandleRadius = 6; const double _kSelectionHandleRadius = 6;
// Minimal padding from all edges of the selection toolbar to all edges of the
// screen.
const double _kToolbarScreenPadding = 8.0;
// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the // Minimal padding from tip of the selection toolbar arrow to horizontal edges of the
// screen. Eyeballed value. // screen. Eyeballed value.
const double _kArrowScreenPadding = 26.0; const double _kArrowScreenPadding = 26.0;
// Vertical distance between the tip of the arrow and the line of text the arrow
// is pointing to. The value used here is eyeballed.
const double _kToolbarContentDistance = 8.0;
// Values derived from https://developer.apple.com/design/resources/.
// 92% Opacity ~= 0xEB
// Values extracted from https://developer.apple.com/design/resources/.
// The height of the toolbar, including the arrow.
const double _kToolbarHeight = 43.0;
const Size _kToolbarArrowSize = Size(14.0, 7.0);
const Radius _kToolbarBorderRadius = Radius.circular(8);
// Colors extracted from https://developer.apple.com/design/resources/.
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
const Color _kToolbarBackgroundColor = Color(0xEB202020);
const Color _kToolbarDividerColor = Color(0xFF808080);
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
color: CupertinoColors.white,
);
const TextStyle _kToolbarButtonDisabledFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
color: CupertinoColors.inactiveGray,
);
// Eyeballed value.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0);
// Generates the child that's passed into CupertinoTextSelectionToolbar. // Generates the child that's passed into CupertinoTextSelectionToolbar.
class _CupertinoTextSelectionToolbarWrapper extends StatefulWidget { class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
const _CupertinoTextSelectionToolbarWrapper({ const _CupertinoTextSelectionControlsToolbar({
Key? key, Key? key,
required this.arrowTipX, required this.clipboardStatus,
required this.barTopY, required this.endpoints,
this.clipboardStatus, required this.globalEditableRegion,
this.handleCut, required this.handleCopy,
this.handleCopy, required this.handleCut,
this.handlePaste, required this.handlePaste,
this.handleSelectAll, required this.handleSelectAll,
required this.isArrowPointingDown, required this.selectionMidpoint,
required this.textLineHeight,
}) : super(key: key); }) : super(key: key);
final double arrowTipX;
final double barTopY;
final ClipboardStatusNotifier? clipboardStatus; final ClipboardStatusNotifier? clipboardStatus;
final VoidCallback? handleCut; final List<TextSelectionPoint> endpoints;
final Rect globalEditableRegion;
final VoidCallback? handleCopy; final VoidCallback? handleCopy;
final VoidCallback? handleCut;
final VoidCallback? handlePaste; final VoidCallback? handlePaste;
final VoidCallback? handleSelectAll; final VoidCallback? handleSelectAll;
final bool isArrowPointingDown; final Offset selectionMidpoint;
final double textLineHeight;
@override @override
_CupertinoTextSelectionToolbarWrapperState createState() => _CupertinoTextSelectionToolbarWrapperState(); _CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
} }
class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSelectionToolbarWrapper> { class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
late ClipboardStatusNotifier _clipboardStatus; late ClipboardStatusNotifier _clipboardStatus;
void _onChangedClipboardStatus() { void _onChangedClipboardStatus() {
...@@ -107,7 +69,7 @@ class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSel ...@@ -107,7 +69,7 @@ class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSel
} }
@override @override
void didUpdateWidget(_CupertinoTextSelectionToolbarWrapper oldWidget) { void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.clipboardStatus == null && widget.clipboardStatus != null) { if (oldWidget.clipboardStatus == null && widget.clipboardStatus != null) {
_clipboardStatus.removeListener(_onChangedClipboardStatus); _clipboardStatus.removeListener(_onChangedClipboardStatus);
...@@ -150,11 +112,32 @@ class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSel ...@@ -150,11 +112,32 @@ class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSel
return const SizedBox(width: 0.0, height: 0.0); return const SizedBox(width: 0.0, height: 0.0);
} }
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
// The toolbar should appear below the TextField when there is not enough
// space above the TextField to show it, assuming there's always enough
// space at the bottom in this case.
final double anchorX = (widget.selectionMidpoint.dx + widget.globalEditableRegion.left).clamp(
_kArrowScreenPadding + mediaQuery.padding.left,
mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
);
// The y-coordinate has to be calculated instead of directly quoting
// selectionMidpoint.dy, since the caller
// (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is
// going to be facing up or down.
final Offset anchorAbove = Offset(
anchorX,
widget.endpoints.first.point.dy - widget.textLineHeight + widget.globalEditableRegion.top,
);
final Offset anchorBelow = Offset(
anchorX,
widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
);
final List<Widget> items = <Widget>[]; final List<Widget> items = <Widget>[];
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
final EdgeInsets arrowPadding = widget.isArrowPointingDown
? EdgeInsets.only(bottom: _kToolbarArrowSize.height)
: EdgeInsets.only(top: _kToolbarArrowSize.height);
final Widget onePhysicalPixelVerticalDivider = final Widget onePhysicalPixelVerticalDivider =
SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio); SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
...@@ -166,18 +149,9 @@ class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSel ...@@ -166,18 +149,9 @@ class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSel
items.add(onePhysicalPixelVerticalDivider); items.add(onePhysicalPixelVerticalDivider);
} }
items.add(CupertinoButton( items.add(CupertinoTextSelectionToolbarButton.text(
child: Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle,
),
borderRadius: null,
color: _kToolbarBackgroundColor,
minSize: _kToolbarHeight,
onPressed: onPressed, onPressed: onPressed,
padding: _kToolbarButtonPadding.add(arrowPadding), text: text,
pressedOpacity: 0.7,
)); ));
} }
...@@ -195,222 +169,17 @@ class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSel ...@@ -195,222 +169,17 @@ class _CupertinoTextSelectionToolbarWrapperState extends State<_CupertinoTextSel
addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!); addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
} }
return CupertinoTextSelectionToolbar._( // If there is no option available, build an empty widget.
barTopY: widget.barTopY, if (items.isEmpty) {
arrowTipX: widget.arrowTipX, return const SizedBox(width: 0.0, height: 0.0);
isArrowPointingDown: widget.isArrowPointingDown,
child: items.isEmpty ? null : _CupertinoTextSelectionToolbarContent(
isArrowPointingDown: widget.isArrowPointingDown,
children: items,
),
);
}
}
/// An iOS-style toolbar that appears in response to text selection.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting text.
///
/// See also:
///
/// * [TextSelectionControls.buildToolbar], where [CupertinoTextSelectionToolbar]
/// will be used to build an iOS-style toolbar.
@visibleForTesting
class CupertinoTextSelectionToolbar extends SingleChildRenderObjectWidget {
const CupertinoTextSelectionToolbar._({
Key? key,
required double barTopY,
required double arrowTipX,
required bool isArrowPointingDown,
Widget? child,
}) : _barTopY = barTopY,
_arrowTipX = arrowTipX,
_isArrowPointingDown = isArrowPointingDown,
super(key: key, child: child);
// The y-coordinate of toolbar's top edge, in global coordinate system.
final double _barTopY;
// The y-coordinate of the tip of the arrow, in global coordinate system.
final double _arrowTipX;
// Whether the arrow should point down and be attached to the bottom
// of the toolbar, or point up and be attached to the top of the toolbar.
final bool _isArrowPointingDown;
@override
_ToolbarRenderBox createRenderObject(BuildContext context) => _ToolbarRenderBox(_barTopY, _arrowTipX, _isArrowPointingDown, null);
@override
void updateRenderObject(BuildContext context, _ToolbarRenderBox renderObject) {
renderObject
..barTopY = _barTopY
..arrowTipX = _arrowTipX
..isArrowPointingDown = _isArrowPointingDown;
}
}
class _ToolbarParentData extends BoxParentData {
// The x offset from the tip of the arrow to the center of the toolbar.
// Positive if the tip of the arrow has a larger x-coordinate than the
// center of the toolbar.
double? arrowXOffsetFromCenter;
@override
String toString() => 'offset=$offset, arrowXOffsetFromCenter=$arrowXOffsetFromCenter';
}
class _ToolbarRenderBox extends RenderShiftedBox {
_ToolbarRenderBox(
this._barTopY,
this._arrowTipX,
this._isArrowPointingDown,
RenderBox? child,
) : super(child);
@override
bool get isRepaintBoundary => true;
double _barTopY;
set barTopY(double value) {
if (_barTopY == value) {
return;
}
_barTopY = value;
markNeedsLayout();
markNeedsSemanticsUpdate();
}
double _arrowTipX;
set arrowTipX(double value) {
if (_arrowTipX == value) {
return;
}
_arrowTipX = value;
markNeedsLayout();
markNeedsSemanticsUpdate();
}
bool _isArrowPointingDown;
set isArrowPointingDown(bool value) {
if (_isArrowPointingDown == value) {
return;
}
_isArrowPointingDown = value;
markNeedsLayout();
markNeedsSemanticsUpdate();
}
final BoxConstraints heightConstraint = const BoxConstraints.tightFor(height: _kToolbarHeight);
@override
void setupParentData(RenderObject child) {
if (child.parentData is! _ToolbarParentData) {
child.parentData = _ToolbarParentData();
}
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return constraints.biggest;
}
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
size = constraints.biggest;
if (child == null) {
return;
}
final BoxConstraints enforcedConstraint = constraints
.deflate(const EdgeInsets.symmetric(horizontal: _kToolbarScreenPadding))
.loosen();
child!.layout(heightConstraint.enforce(enforcedConstraint), parentUsesSize: true,);
final _ToolbarParentData childParentData = child!.parentData! as _ToolbarParentData;
// The local x-coordinate of the center of the toolbar.
final double lowerBound = child!.size.width/2 + _kToolbarScreenPadding;
final double upperBound = size.width - child!.size.width/2 - _kToolbarScreenPadding;
final double adjustedCenterX = _arrowTipX.clamp(lowerBound, upperBound);
childParentData.offset = Offset(adjustedCenterX - child!.size.width / 2, _barTopY);
childParentData.arrowXOffsetFromCenter = _arrowTipX - adjustedCenterX;
}
// The path is described in the toolbar's coordinate system.
Path _clipPath() {
final _ToolbarParentData childParentData = child!.parentData! as _ToolbarParentData;
final Path rrect = Path()
..addRRect(
RRect.fromRectAndRadius(
Offset(0, _isArrowPointingDown ? 0 : _kToolbarArrowSize.height)
& Size(child!.size.width, child!.size.height - _kToolbarArrowSize.height),
_kToolbarBorderRadius,
),
);
final double arrowTipX = child!.size.width / 2 + childParentData.arrowXOffsetFromCenter!;
final double arrowBottomY = _isArrowPointingDown
? child!.size.height - _kToolbarArrowSize.height
: _kToolbarArrowSize.height;
final double arrowTipY = _isArrowPointingDown ? child!.size.height : 0;
final Path arrow = Path()
..moveTo(arrowTipX, arrowTipY)
..lineTo(arrowTipX - _kToolbarArrowSize.width / 2, arrowBottomY)
..lineTo(arrowTipX + _kToolbarArrowSize.width / 2, arrowBottomY)
..close();
return Path.combine(PathOperation.union, rrect, arrow);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child == null) {
return;
} }
final _ToolbarParentData childParentData = child!.parentData! as _ToolbarParentData; return CupertinoTextSelectionToolbar(
_clipPathLayer = context.pushClipPath( anchorAbove: anchorAbove,
needsCompositing, anchorBelow: anchorBelow,
offset + childParentData.offset, children: items,
Offset.zero & child!.size,
_clipPath(),
(PaintingContext innerContext, Offset innerOffset) => innerContext.paintChild(child!, innerOffset),
oldLayer: _clipPathLayer
); );
} }
ClipPathLayer? _clipPathLayer;
Paint? _debugPaint;
@override
void debugPaintSize(PaintingContext context, Offset offset) {
assert(() {
if (child == null) {
return true;
}
_debugPaint ??= Paint()
..shader = ui.Gradient.linear(
const Offset(0.0, 0.0),
const Offset(10.0, 10.0),
const <Color>[Color(0x00000000), Color(0xFFFF00FF), Color(0xFFFF00FF), Color(0x00000000)],
const <double>[0.25, 0.25, 0.75, 0.75],
TileMode.repeated,
)
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
final _ToolbarParentData childParentData = child!.parentData! as _ToolbarParentData;
context.canvas.drawPath(_clipPath().shift(offset + childParentData.offset), _debugPaint!);
return true;
}());
}
} }
/// Draws a single text selection handle with a bar and a ball. /// Draws a single text selection handle with a bar and a ball.
...@@ -445,7 +214,8 @@ class _TextSelectionHandlePainter extends CustomPainter { ...@@ -445,7 +214,8 @@ class _TextSelectionHandlePainter extends CustomPainter {
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color; bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
} }
class _CupertinoTextSelectionControls extends TextSelectionControls { /// iOS Cupertino styled text selection controls.
class CupertinoTextSelectionControls extends TextSelectionControls {
/// Returns the size of the Cupertino handle. /// Returns the size of the Cupertino handle.
@override @override
Size getHandleSize(double textLineHeight) { Size getHandleSize(double textLineHeight) {
...@@ -461,45 +231,21 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -461,45 +231,21 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
BuildContext context, BuildContext context,
Rect globalEditableRegion, Rect globalEditableRegion,
double textLineHeight, double textLineHeight,
Offset position, Offset selectionMidpoint,
List<TextSelectionPoint> endpoints, List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate, TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus, ClipboardStatusNotifier clipboardStatus,
) { ) {
assert(debugCheckHasMediaQuery(context)); return _CupertinoTextSelectionControlsToolbar(
final MediaQueryData mediaQuery = MediaQuery.of(context);
// The toolbar should appear below the TextField when there is not enough
// space above the TextField to show it, assuming there's always enough space
// at the bottom in this case.
final double toolbarHeightNeeded = mediaQuery.padding.top
+ _kToolbarScreenPadding
+ _kToolbarHeight
+ _kToolbarContentDistance;
final double availableHeight = globalEditableRegion.top + endpoints.first.point.dy - textLineHeight;
final bool isArrowPointingDown = toolbarHeightNeeded <= availableHeight;
final double arrowTipX = (position.dx + globalEditableRegion.left).clamp(
_kArrowScreenPadding + mediaQuery.padding.left,
mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
);
// The y-coordinate has to be calculated instead of directly quoting position.dy,
// since the caller (TextSelectionOverlay._buildToolbar) does not know whether
// the toolbar is going to be facing up or down.
final double localBarTopY = isArrowPointingDown
? endpoints.first.point.dy - textLineHeight - _kToolbarContentDistance - _kToolbarHeight
: endpoints.last.point.dy + _kToolbarContentDistance;
return _CupertinoTextSelectionToolbarWrapper(
arrowTipX: arrowTipX,
barTopY: localBarTopY + globalEditableRegion.top,
clipboardStatus: clipboardStatus, clipboardStatus: clipboardStatus,
endpoints: endpoints,
globalEditableRegion: globalEditableRegion,
handleCut: canCut(delegate) ? () => handleCut(delegate) : null, handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
handleCopy: canCopy(delegate) ? () => handleCopy(delegate, clipboardStatus) : null, handleCopy: canCopy(delegate) ? () => handleCopy(delegate, clipboardStatus) : null,
handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
isArrowPointingDown: isArrowPointingDown, selectionMidpoint: selectionMidpoint,
textLineHeight: textLineHeight,
); );
} }
...@@ -569,694 +315,5 @@ class _CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -569,694 +315,5 @@ class _CupertinoTextSelectionControls extends TextSelectionControls {
} }
} }
// Renders the content of the selection menu and maintains the page state.
class _CupertinoTextSelectionToolbarContent extends StatefulWidget {
const _CupertinoTextSelectionToolbarContent({
Key? key,
required this.children,
required this.isArrowPointingDown,
}) : assert(children != null),
// This ignore is used because .isNotEmpty isn't compatible with const.
assert(children.length > 0), // ignore: prefer_is_empty
super(key: key);
final List<Widget> children;
final bool isArrowPointingDown;
@override
_CupertinoTextSelectionToolbarContentState createState() => _CupertinoTextSelectionToolbarContentState();
}
class _CupertinoTextSelectionToolbarContentState extends State<_CupertinoTextSelectionToolbarContent> with TickerProviderStateMixin {
// Controls the fading of the buttons within the menu during page transitions.
late AnimationController _controller;
int _page = 0;
int? _nextPage;
void _handleNextPage() {
_controller.reverse();
_controller.addStatusListener(_statusListener);
_nextPage = _page + 1;
}
void _handlePreviousPage() {
_controller.reverse();
_controller.addStatusListener(_statusListener);
_nextPage = _page - 1;
}
void _statusListener(AnimationStatus status) {
if (status != AnimationStatus.dismissed) {
return;
}
setState(() {
_page = _nextPage!;
_nextPage = null;
});
_controller.forward();
_controller.removeStatusListener(_statusListener);
}
@override
void initState() {
super.initState();
_controller = AnimationController(
value: 1.0,
vsync: this,
// This was eyeballed on a physical iOS device running iOS 13.
duration: const Duration(milliseconds: 150),
);
}
@override
void didUpdateWidget(_CupertinoTextSelectionToolbarContent oldWidget) {
// If the children are changing, the current page should be reset.
if (widget.children != oldWidget.children) {
_page = 0;
_nextPage = null;
_controller.forward();
_controller.removeStatusListener(_statusListener);
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final EdgeInsets arrowPadding = widget.isArrowPointingDown
? EdgeInsets.only(bottom: _kToolbarArrowSize.height)
: EdgeInsets.only(top: _kToolbarArrowSize.height);
return DecoratedBox(
decoration: const BoxDecoration(color: _kToolbarDividerColor),
child: FadeTransition(
opacity: _controller,
child: _CupertinoTextSelectionToolbarItems(
page: _page,
backButton: CupertinoButton(
borderRadius: null,
color: _kToolbarBackgroundColor,
minSize: _kToolbarHeight,
onPressed: _handlePreviousPage,
padding: arrowPadding,
pressedOpacity: 0.7,
child: const Text('◀', style: _kToolbarButtonFontStyle),
),
dividerWidth: 1.0 / MediaQuery.of(context).devicePixelRatio,
nextButton: CupertinoButton(
borderRadius: null,
color: _kToolbarBackgroundColor,
minSize: _kToolbarHeight,
onPressed: _handleNextPage,
padding: arrowPadding,
pressedOpacity: 0.7,
child: const Text('▶', style: _kToolbarButtonFontStyle),
),
nextButtonDisabled: CupertinoButton(
borderRadius: null,
color: _kToolbarBackgroundColor,
disabledColor: _kToolbarBackgroundColor,
minSize: _kToolbarHeight,
onPressed: null,
padding: arrowPadding,
pressedOpacity: 1.0,
child: const Text('▶', style: _kToolbarButtonDisabledFontStyle),
),
children: widget.children,
),
),
);
}
}
// The custom RenderObjectWidget that, together with
// _CupertinoTextSelectionToolbarItemsRenderBox and
// _CupertinoTextSelectionToolbarItemsElement, paginates the menu items.
class _CupertinoTextSelectionToolbarItems extends RenderObjectWidget {
_CupertinoTextSelectionToolbarItems({
Key? key,
required this.page,
required this.children,
required this.backButton,
required this.dividerWidth,
required this.nextButton,
required this.nextButtonDisabled,
}) : assert(children != null),
assert(children.isNotEmpty),
assert(backButton != null),
assert(dividerWidth != null),
assert(nextButton != null),
assert(nextButtonDisabled != null),
assert(page != null),
super(key: key);
final Widget backButton;
final List<Widget> children;
final double dividerWidth;
final Widget nextButton;
final Widget nextButtonDisabled;
final int page;
@override
_CupertinoTextSelectionToolbarItemsRenderBox createRenderObject(BuildContext context) {
return _CupertinoTextSelectionToolbarItemsRenderBox(
dividerWidth: dividerWidth,
page: page,
);
}
@override
void updateRenderObject(BuildContext context, _CupertinoTextSelectionToolbarItemsRenderBox renderObject) {
renderObject
..page = page
..dividerWidth = dividerWidth;
}
@override
_CupertinoTextSelectionToolbarItemsElement createElement() => _CupertinoTextSelectionToolbarItemsElement(this);
}
// The custom RenderObjectElement that helps paginate the menu items.
class _CupertinoTextSelectionToolbarItemsElement extends RenderObjectElement {
_CupertinoTextSelectionToolbarItemsElement(
_CupertinoTextSelectionToolbarItems widget,
) : super(widget);
late List<Element> _children;
final Map<_CupertinoTextSelectionToolbarItemsSlot, Element> slotToChild = <_CupertinoTextSelectionToolbarItemsSlot, Element>{};
// We keep a set of forgotten children to avoid O(n^2) work walking _children
// repeatedly to remove children.
final Set<Element> _forgottenChildren = HashSet<Element>();
@override
_CupertinoTextSelectionToolbarItems get widget => super.widget as _CupertinoTextSelectionToolbarItems;
@override
_CupertinoTextSelectionToolbarItemsRenderBox get renderObject => super.renderObject as _CupertinoTextSelectionToolbarItemsRenderBox;
void _updateRenderObject(RenderBox? child, _CupertinoTextSelectionToolbarItemsSlot slot) {
switch (slot) {
case _CupertinoTextSelectionToolbarItemsSlot.backButton:
renderObject.backButton = child;
break;
case _CupertinoTextSelectionToolbarItemsSlot.nextButton:
renderObject.nextButton = child;
break;
case _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled:
renderObject.nextButtonDisabled = child;
break;
}
}
@override
void insertRenderObjectChild(RenderObject child, dynamic slot) {
if (slot is _CupertinoTextSelectionToolbarItemsSlot) {
assert(child is RenderBox);
_updateRenderObject(child as RenderBox, slot);
assert(renderObject.slottedChildren.containsKey(slot));
return;
}
if (slot is IndexedSlot) {
assert(renderObject.debugValidateChild(child));
renderObject.insert(child as RenderBox, after: slot.value?.renderObject as RenderBox?);
return;
}
assert(false, 'slot must be _CupertinoTextSelectionToolbarItemsSlot or IndexedSlot');
}
// This is not reachable for children that don't have an IndexedSlot.
@override
void moveRenderObjectChild(RenderObject child, IndexedSlot<Element> oldSlot, IndexedSlot<Element> newSlot) {
assert(child.parent == renderObject);
renderObject.move(child as RenderBox, after: newSlot.value.renderObject as RenderBox?);
}
static bool _shouldPaint(Element child) {
return (child.renderObject!.parentData! as ToolbarItemsParentData).shouldPaint;
}
@override
void removeRenderObjectChild(RenderObject child, dynamic slot) {
// Check if the child is in a slot.
if (slot is _CupertinoTextSelectionToolbarItemsSlot) {
assert(child is RenderBox);
assert(renderObject.slottedChildren.containsKey(slot));
_updateRenderObject(null, slot);
assert(!renderObject.slottedChildren.containsKey(slot));
return;
}
// Otherwise look for it in the list of children.
assert(slot is IndexedSlot);
assert(child.parent == renderObject);
renderObject.remove(child as RenderBox);
}
@override
void visitChildren(ElementVisitor visitor) {
slotToChild.values.forEach(visitor);
for (final Element child in _children) {
if (!_forgottenChildren.contains(child))
visitor(child);
}
}
@override
void forgetChild(Element child) {
assert(slotToChild.containsValue(child) || _children.contains(child));
assert(!_forgottenChildren.contains(child));
// Handle forgetting a child in children or in a slot.
if (slotToChild.containsKey(child.slot)) {
final _CupertinoTextSelectionToolbarItemsSlot slot = child.slot as _CupertinoTextSelectionToolbarItemsSlot;
slotToChild.remove(slot);
} else {
_forgottenChildren.add(child);
}
super.forgetChild(child);
}
// Mount or update slotted child.
void _mountChild(Widget widget, _CupertinoTextSelectionToolbarItemsSlot slot) {
final Element? oldChild = slotToChild[slot];
final Element? newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
slotToChild.remove(slot);
}
if (newChild != null) {
slotToChild[slot] = newChild;
}
}
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
// Mount slotted children.
_mountChild(widget.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton);
_mountChild(widget.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
_mountChild(widget.nextButtonDisabled, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
// Mount list children.
_children = List<Element>.filled(widget.children.length, _NullElement.instance);
Element? previousChild;
for (int i = 0; i < _children.length; i += 1) {
final Element newChild = inflateWidget(widget.children[i], IndexedSlot<Element?>(i, previousChild));
_children[i] = newChild;
previousChild = newChild;
}
}
@override
void debugVisitOnstageChildren(ElementVisitor visitor) {
// Visit slot children.
for (final Element child in slotToChild.values) {
if (_shouldPaint(child) && !_forgottenChildren.contains(child)) {
visitor(child);
}
}
// Visit list children.
_children
.where((Element child) => !_forgottenChildren.contains(child) && _shouldPaint(child))
.forEach(visitor);
}
@override
void update(_CupertinoTextSelectionToolbarItems newWidget) {
super.update(newWidget);
assert(widget == newWidget);
// Update slotted children.
_mountChild(widget.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton);
_mountChild(widget.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
_mountChild(widget.nextButtonDisabled, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
// Update list children.
_children = updateChildren(_children, widget.children, forgottenChildren: _forgottenChildren);
_forgottenChildren.clear();
}
}
// The custom RenderBox that helps paginate the menu items.
class _CupertinoTextSelectionToolbarItemsRenderBox extends RenderBox with ContainerRenderObjectMixin<RenderBox, ToolbarItemsParentData>, RenderBoxContainerDefaultsMixin<RenderBox, ToolbarItemsParentData> {
_CupertinoTextSelectionToolbarItemsRenderBox({
required double dividerWidth,
required int page,
}) : assert(dividerWidth != null),
assert(page != null),
_dividerWidth = dividerWidth,
_page = page,
super();
final Map<_CupertinoTextSelectionToolbarItemsSlot, RenderBox> slottedChildren = <_CupertinoTextSelectionToolbarItemsSlot, RenderBox>{};
RenderBox? _updateChild(RenderBox? oldChild, RenderBox? newChild, _CupertinoTextSelectionToolbarItemsSlot slot) {
if (oldChild != null) {
dropChild(oldChild);
slottedChildren.remove(slot);
}
if (newChild != null) {
slottedChildren[slot] = newChild;
adoptChild(newChild);
}
return newChild;
}
bool _isSlottedChild(RenderBox child) {
return child == _backButton || child == _nextButton || child == _nextButtonDisabled;
}
int _page;
int get page => _page;
set page(int value) {
if (value == _page) {
return;
}
_page = value;
markNeedsLayout();
}
double _dividerWidth;
double get dividerWidth => _dividerWidth;
set dividerWidth(double value) {
if (value == _dividerWidth) {
return;
}
_dividerWidth = value;
markNeedsLayout();
}
RenderBox? _backButton;
RenderBox? get backButton => _backButton;
set backButton(RenderBox? value) {
_backButton = _updateChild(_backButton, value, _CupertinoTextSelectionToolbarItemsSlot.backButton);
}
RenderBox? _nextButton;
RenderBox? get nextButton => _nextButton;
set nextButton(RenderBox? value) {
_nextButton = _updateChild(_nextButton, value, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
}
RenderBox? _nextButtonDisabled;
RenderBox? get nextButtonDisabled => _nextButtonDisabled;
set nextButtonDisabled(RenderBox? value) {
_nextButtonDisabled = _updateChild(_nextButtonDisabled, value, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
}
@override
void performLayout() {
if (firstChild == null) {
size = constraints.smallest;
return;
}
// Layout slotted children.
_backButton!.layout(constraints.loosen(), parentUsesSize: true);
_nextButton!.layout(constraints.loosen(), parentUsesSize: true);
_nextButtonDisabled!.layout(constraints.loosen(), parentUsesSize: true);
final double subsequentPageButtonsWidth =
_backButton!.size.width + _nextButton!.size.width;
double currentButtonPosition = 0.0;
late double toolbarWidth; // The width of the whole widget.
late double firstPageWidth;
int currentPage = 0;
int i = -1;
visitChildren((RenderObject renderObjectChild) {
i++;
final RenderBox child = renderObjectChild as RenderBox;
final ToolbarItemsParentData childParentData =
child.parentData! as ToolbarItemsParentData;
childParentData.shouldPaint = false;
// Skip slotted children and children on pages after the visible page.
if (_isSlottedChild(child) || currentPage > _page) {
return;
}
double paginationButtonsWidth = 0.0;
if (currentPage == 0) {
// If this is the last child, it's ok to fit without a forward button.
paginationButtonsWidth =
i == childCount - 1 ? 0.0 : _nextButton!.size.width;
} else {
paginationButtonsWidth = subsequentPageButtonsWidth;
}
// The width of the menu is set by the first page.
child.layout(
BoxConstraints.loose(Size(
(currentPage == 0 ? constraints.maxWidth : firstPageWidth) - paginationButtonsWidth,
constraints.maxHeight,
)),
parentUsesSize: true,
);
// If this child causes the current page to overflow, move to the next
// page and relayout the child.
final double currentWidth =
currentButtonPosition + paginationButtonsWidth + child.size.width;
if (currentWidth > constraints.maxWidth) {
currentPage++;
currentButtonPosition = _backButton!.size.width + dividerWidth;
paginationButtonsWidth = _backButton!.size.width + _nextButton!.size.width;
child.layout(
BoxConstraints.loose(Size(
firstPageWidth - paginationButtonsWidth,
constraints.maxHeight,
)),
parentUsesSize: true,
);
}
childParentData.offset = Offset(currentButtonPosition, 0.0);
currentButtonPosition += child.size.width + dividerWidth;
childParentData.shouldPaint = currentPage == page;
if (currentPage == 0) {
firstPageWidth = currentButtonPosition + _nextButton!.size.width;
}
if (currentPage == page) {
toolbarWidth = currentButtonPosition;
}
});
// It shouldn't be possible to navigate beyond the last page.
assert(page <= currentPage);
// Position page nav buttons.
if (currentPage > 0) {
final ToolbarItemsParentData nextButtonParentData =
_nextButton!.parentData! as ToolbarItemsParentData;
final ToolbarItemsParentData nextButtonDisabledParentData =
_nextButtonDisabled!.parentData! as ToolbarItemsParentData;
final ToolbarItemsParentData backButtonParentData =
_backButton!.parentData! as ToolbarItemsParentData;
// The forward button always shows if there is more than one page, even on
// the last page (it's just disabled).
if (page == currentPage) {
nextButtonDisabledParentData.offset = Offset(toolbarWidth, 0.0);
nextButtonDisabledParentData.shouldPaint = true;
toolbarWidth += nextButtonDisabled!.size.width;
} else {
nextButtonParentData.offset = Offset(toolbarWidth, 0.0);
nextButtonParentData.shouldPaint = true;
toolbarWidth += nextButton!.size.width;
}
if (page > 0) {
backButtonParentData.offset = Offset.zero;
backButtonParentData.shouldPaint = true;
// No need to add the width of the back button to toolbarWidth here. It's
// already been taken care of when laying out the children to
// accommodate the back button.
}
} else {
// No divider for the next button when there's only one page.
toolbarWidth -= dividerWidth;
}
size = constraints.constrain(Size(toolbarWidth, _kToolbarHeight));
}
@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) {
final Offset childOffset = childParentData.offset + offset;
context.paintChild(child, childOffset);
}
});
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! ToolbarItemsParentData) {
child.parentData = ToolbarItemsParentData();
}
}
// Returns true iff the single child is hit by the given position.
static bool hitTestChild(RenderBox? child, BoxHitTestResult result, { required Offset position }) {
if (child == null) {
return false;
}
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
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
// Hit test list children.
// 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 that aren't shown.
if (!childParentData.shouldPaint) {
child = childParentData.previousSibling;
continue;
}
if (hitTestChild(child, result, position: position)) {
return true;
}
child = childParentData.previousSibling;
}
// Hit test slot children.
if (hitTestChild(backButton, result, position: position)) {
return true;
}
if (hitTestChild(nextButton, result, position: position)) {
return true;
}
if (hitTestChild(nextButtonDisabled, result, position: position)) {
return true;
}
return false;
}
@override
void attach(PipelineOwner owner) {
// Attach list children.
super.attach(owner);
// Attach slot children.
for (final RenderBox child in slottedChildren.values) {
child.attach(owner);
}
}
@override
void detach() {
// Detach list children.
super.detach();
// Detach slot children.
for (final RenderBox child in slottedChildren.values) {
child.detach();
}
}
@override
void redepthChildren() {
visitChildren((RenderObject renderObjectChild) {
final RenderBox child = renderObjectChild as RenderBox;
redepthChild(child);
});
}
@override
void visitChildren(RenderObjectVisitor visitor) {
// Visit the slotted children.
if (_backButton != null) {
visitor(_backButton!);
}
if (_nextButton != null) {
visitor(_nextButton!);
}
if (_nextButtonDisabled != null) {
visitor(_nextButtonDisabled!);
}
// Visit the list children.
super.visitChildren(visitor);
}
// 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);
}
});
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
final List<DiagnosticsNode> value = <DiagnosticsNode>[];
visitChildren((RenderObject renderObjectChild) {
final RenderBox child = renderObjectChild as RenderBox;
if (child == backButton) {
value.add(child.toDiagnosticsNode(name: 'back button'));
} else if (child == nextButton) {
value.add(child.toDiagnosticsNode(name: 'next button'));
} else if (child == nextButtonDisabled) {
value.add(child.toDiagnosticsNode(name: 'next button disabled'));
// List children.
} else {
value.add(child.toDiagnosticsNode(name: 'menu item'));
}
});
return value;
}
}
// The slots that can be occupied by widgets in
// _CupertinoTextSelectionToolbarItems, excluding the list of children.
enum _CupertinoTextSelectionToolbarItemsSlot {
backButton,
nextButton,
nextButtonDisabled,
}
/// Text selection controls that follows iOS design conventions. /// Text selection controls that follows iOS design conventions.
final TextSelectionControls cupertinoTextSelectionControls = _CupertinoTextSelectionControls(); final TextSelectionControls cupertinoTextSelectionControls = CupertinoTextSelectionControls();
class _NullElement extends Element {
_NullElement() : super(_NullWidget());
static _NullElement instance = _NullElement();
@override
bool get debugDoingBuild => throw UnimplementedError();
@override
void performRebuild() { }
}
class _NullWidget extends Widget {
@override
Element createElement() => throw UnimplementedError();
}
// 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:collection';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
import 'text_selection_toolbar_button.dart';
// Values extracted from https://developer.apple.com/design/resources/.
// The height of the toolbar, including the arrow.
const double _kToolbarHeight = 43.0;
// Vertical distance between the tip of the arrow and the line of text the arrow
// is pointing to. The value used here is eyeballed.
const double _kToolbarContentDistance = 8.0;
// Minimal padding from all edges of the selection toolbar to all edges of the
// screen.
const double _kToolbarScreenPadding = 8.0;
const Size _kToolbarArrowSize = Size(14.0, 7.0);
// Values extracted from https://developer.apple.com/design/resources/.
const Radius _kToolbarBorderRadius = Radius.circular(8);
// Colors extracted from https://developer.apple.com/design/resources/.
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
const Color _kToolbarDividerColor = Color(0xFF808080);
/// The type for a Function that builds a toolbar's container with the given
/// child.
///
/// The anchor is provided in global coordinates.
///
/// See also:
///
/// * [CupertinoTextSelectionToolbar.toolbarBuilder], which is of this type.
/// * [TextSelectionToolbar.toolbarBuilder], which is similar, but for an
/// Material-style toolbar.
typedef CupertinoToolbarBuilder = Widget Function(
BuildContext context,
Offset anchor,
bool isAbove,
Widget child,
);
/// An iOS-style text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
/// text.
///
/// 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.
///
/// See also:
///
/// * [TextSelectionControls.buildToolbar], where this is used by default to
/// build an iOS-style toolbar.
/// * [TextSelectionToolbar], which is similar, but builds an Android-style
/// toolbar.
class CupertinoTextSelectionToolbar extends StatelessWidget {
/// Creates an instance of CupertinoTextSelectionToolbar.
const CupertinoTextSelectionToolbar({
Key? key,
required this.anchorAbove,
required this.anchorBelow,
required this.children,
this.toolbarBuilder = _defaultToolbarBuilder,
}) : assert(children.length > 0),
super(key: key);
/// {@macro flutter.material.TextSelectionToolbar.anchorAbove}
final Offset anchorAbove;
/// {@macro flutter.material.TextSelectionToolbar.anchorBelow}
final Offset anchorBelow;
/// {@macro flutter.material.TextSelectionToolbar.children}
///
/// See also:
/// * [CupertinoTextSelectionToolbarButton], which builds a default
/// Cupertino-style text selection toolbar text button.
final List<Widget> children;
/// {@macro flutter.material.TextSelectionToolbar.toolbarBuilder}
///
/// The given anchor and isAbove can be used to position an arrow, as in the
/// default Cupertino toolbar.
final CupertinoToolbarBuilder toolbarBuilder;
// Builds a toolbar just like the default iOS toolbar, with the right color
// background and a rounded cutout with an arrow.
static Widget _defaultToolbarBuilder(BuildContext context, Offset anchor, bool isAbove, Widget child) {
return _CupertinoTextSelectionToolbarShape(
anchor: anchor,
isAbove: isAbove,
child: DecoratedBox(
decoration: const BoxDecoration(color: _kToolbarDividerColor),
child: child,
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
final double toolbarHeightNeeded = paddingAbove
+ _kToolbarContentDistance
+ _kToolbarHeight;
final bool fitsAbove = anchorAbove.dy >= toolbarHeightNeeded;
const Offset contentPaddingAdjustment = Offset(0.0, _kToolbarContentDistance);
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding,
paddingAbove,
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
child: CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate(
anchorAbove: anchorAbove - localAdjustment - contentPaddingAdjustment,
anchorBelow: anchorBelow - localAdjustment + contentPaddingAdjustment,
),
child: _CupertinoTextSelectionToolbarContent(
anchor: fitsAbove ? anchorAbove : anchorBelow,
isAbove: fitsAbove,
toolbarBuilder: toolbarBuilder,
children: children,
),
),
);
}
}
// Clips the child so that it has the shape of the default iOS text selection
// toolbar, with rounded corners and an arrow pointing at the anchor.
//
// The anchor should be in global coordinates.
class _CupertinoTextSelectionToolbarShape extends SingleChildRenderObjectWidget {
const _CupertinoTextSelectionToolbarShape({
Key? key,
required Offset anchor,
required bool isAbove,
Widget? child,
}) : _anchor = anchor,
_isAbove = isAbove,
super(key: key, child: child);
final Offset _anchor;
// Whether the arrow should point down and be attached to the bottom
// of the toolbar, or point up and be attached to the top of the toolbar.
final bool _isAbove;
@override
_RenderCupertinoTextSelectionToolbarShape createRenderObject(BuildContext context) => _RenderCupertinoTextSelectionToolbarShape(
_anchor,
_isAbove,
null,
);
@override
void updateRenderObject(BuildContext context, _RenderCupertinoTextSelectionToolbarShape renderObject) {
renderObject
..anchor = _anchor
..isAbove = _isAbove;
}
}
// Clips the child into the shape of the default iOS text selection toolbar.
//
// The shape is a rounded rectangle with a protruding arrow pointing at the
// given anchor in the direction indicated by isAbove.
//
// In order to allow the child to render itself independent of isAbove, its
// height is clipped on both the top and the bottom, leaving the arrow remaining
// on the necessary side.
class _RenderCupertinoTextSelectionToolbarShape extends RenderShiftedBox {
_RenderCupertinoTextSelectionToolbarShape(
this._anchor,
this._isAbove,
RenderBox? child,
) : super(child);
@override
bool get isRepaintBoundary => true;
Offset _anchor;
set anchor(Offset value) {
if (value == _anchor) {
return;
}
_anchor = value;
markNeedsLayout();
}
bool _isAbove;
set isAbove(bool value) {
if (_isAbove == value) {
return;
}
_isAbove = value;
markNeedsLayout();
}
// The child is tall enough to have the arrow clipped out of it on both sides
// top and bottom. Since _kToolbarHeight includes the height of one arrow, the
// total height that the child is given is that plus one more arrow height.
// The extra height on the opposite side of the arrow will be clipped out. By
// using this appraoch, the buttons don't need any special padding that
// depends on isAbove.
final BoxConstraints _heightConstraint = BoxConstraints.tightFor(
height: _kToolbarHeight + _kToolbarArrowSize.height,
);
@override
void performLayout() {
if (child == null) {
return;
}
final BoxConstraints enforcedConstraint = constraints.loosen();
child!.layout(_heightConstraint.enforce(enforcedConstraint), parentUsesSize: true);
// The height of one arrow will be clipped off of the child, so adjust the
// size and position to remove that piece from the layout.
final BoxParentData childParentData = child!.parentData! as BoxParentData;
childParentData.offset = Offset(
0.0,
_isAbove ? -_kToolbarArrowSize.height : 0.0,
);
size = Size(
child!.size.width,
child!.size.height - _kToolbarArrowSize.height,
);
}
// The path is described in the toolbar's coordinate system.
Path _clipPath() {
final BoxParentData childParentData = child!.parentData! as BoxParentData;
final Path rrect = Path()
..addRRect(
RRect.fromRectAndRadius(
Offset(0.0, _kToolbarArrowSize.height)
& Size(
child!.size.width,
child!.size.height - _kToolbarArrowSize.height * 2,
),
_kToolbarBorderRadius,
),
);
final Offset localAnchor = globalToLocal(_anchor);
final double centerX = childParentData.offset.dx + child!.size.width / 2;
final double arrowXOffsetFromCenter = localAnchor.dx - centerX;
final double arrowTipX = child!.size.width / 2 + arrowXOffsetFromCenter;
final double arrowBaseY = _isAbove
? child!.size.height - _kToolbarArrowSize.height
: _kToolbarArrowSize.height;
final double arrowTipY = _isAbove ? child!.size.height : 0;
final Path arrow = Path()
..moveTo(arrowTipX, arrowTipY)
..lineTo(arrowTipX - _kToolbarArrowSize.width / 2, arrowBaseY)
..lineTo(arrowTipX + _kToolbarArrowSize.width / 2, arrowBaseY)
..close();
return Path.combine(PathOperation.union, rrect, arrow);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child == null) {
return;
}
final BoxParentData childParentData = child!.parentData! as BoxParentData;
_clipPathLayer = context.pushClipPath(
needsCompositing,
offset + childParentData.offset,
Offset.zero & child!.size,
_clipPath(),
(PaintingContext innerContext, Offset innerOffset) => innerContext.paintChild(child!, innerOffset),
oldLayer: _clipPathLayer
);
}
ClipPathLayer? _clipPathLayer;
Paint? _debugPaint;
@override
void debugPaintSize(PaintingContext context, Offset offset) {
assert(() {
if (child == null) {
return true;
}
_debugPaint ??= Paint()
..shader = ui.Gradient.linear(
const Offset(0.0, 0.0),
const Offset(10.0, 10.0),
const <Color>[Color(0x00000000), Color(0xFFFF00FF), Color(0xFFFF00FF), Color(0x00000000)],
const <double>[0.25, 0.25, 0.75, 0.75],
TileMode.repeated,
)
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
final BoxParentData childParentData = child!.parentData! as BoxParentData;
context.canvas.drawPath(_clipPath().shift(offset + childParentData.offset), _debugPaint!);
return true;
}());
}
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
// Positions outside of the clipped area of the child are not counted as
// hits.
final BoxParentData childParentData = child!.parentData! as BoxParentData;
final Rect hitBox = Rect.fromLTWH(
childParentData.offset.dx,
childParentData.offset.dy + _kToolbarArrowSize.height,
child!.size.width,
child!.size.height - _kToolbarArrowSize.height * 2,
);
if (!hitBox.contains(position)) {
return false;
}
return super.hitTestChildren(result, position: position);
}
}
// A toolbar containing the given children. If they overflow the width
// available, then the menu will be paginated with the overflowing children
// displayed on subsequent pages.
//
// The anchor should be in global coordinates.
class _CupertinoTextSelectionToolbarContent extends StatefulWidget {
const _CupertinoTextSelectionToolbarContent({
Key? key,
required this.anchor,
required this.isAbove,
required this.toolbarBuilder,
required this.children,
}) : assert(children != null),
// This ignore is used because .isNotEmpty isn't compatible with const.
assert(children.length > 0), // ignore: prefer_is_empty
super(key: key);
final Offset anchor;
final List<Widget> children;
final bool isAbove;
final CupertinoToolbarBuilder toolbarBuilder;
@override
_CupertinoTextSelectionToolbarContentState createState() => _CupertinoTextSelectionToolbarContentState();
}
class _CupertinoTextSelectionToolbarContentState extends State<_CupertinoTextSelectionToolbarContent> with TickerProviderStateMixin {
// Controls the fading of the buttons within the menu during page transitions.
late AnimationController _controller;
int _page = 0;
int? _nextPage;
void _handleNextPage() {
_controller.reverse();
_controller.addStatusListener(_statusListener);
_nextPage = _page + 1;
}
void _handlePreviousPage() {
_controller.reverse();
_controller.addStatusListener(_statusListener);
_nextPage = _page - 1;
}
void _statusListener(AnimationStatus status) {
if (status != AnimationStatus.dismissed) {
return;
}
setState(() {
_page = _nextPage!;
_nextPage = null;
});
_controller.forward();
_controller.removeStatusListener(_statusListener);
}
@override
void initState() {
super.initState();
_controller = AnimationController(
value: 1.0,
vsync: this,
// This was eyeballed on a physical iOS device running iOS 13.
duration: const Duration(milliseconds: 150),
);
}
@override
void didUpdateWidget(_CupertinoTextSelectionToolbarContent oldWidget) {
// If the children are changing, the current page should be reset.
if (widget.children != oldWidget.children) {
_page = 0;
_nextPage = null;
_controller.forward();
_controller.removeStatusListener(_statusListener);
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.toolbarBuilder(context, widget.anchor, widget.isAbove, FadeTransition(
opacity: _controller,
child: _CupertinoTextSelectionToolbarItems(
page: _page,
backButton: CupertinoTextSelectionToolbarButton.text(
onPressed: _handlePreviousPage,
text: '◀',
),
dividerWidth: 1.0 / MediaQuery.of(context).devicePixelRatio,
nextButton: CupertinoTextSelectionToolbarButton.text(
onPressed: _handleNextPage,
text: '▶',
),
nextButtonDisabled: CupertinoTextSelectionToolbarButton.text(
text: '▶',
),
children: widget.children,
),
));
}
}
// The custom RenderObjectWidget that, together with
// _RenderCupertinoTextSelectionToolbarItems and
// _CupertinoTextSelectionToolbarItemsElement, paginates the menu items.
class _CupertinoTextSelectionToolbarItems extends RenderObjectWidget {
_CupertinoTextSelectionToolbarItems({
Key? key,
required this.page,
required this.children,
required this.backButton,
required this.dividerWidth,
required this.nextButton,
required this.nextButtonDisabled,
}) : assert(children != null),
assert(children.isNotEmpty),
assert(backButton != null),
assert(dividerWidth != null),
assert(nextButton != null),
assert(nextButtonDisabled != null),
assert(page != null),
super(key: key);
final Widget backButton;
final List<Widget> children;
final double dividerWidth;
final Widget nextButton;
final Widget nextButtonDisabled;
final int page;
@override
_RenderCupertinoTextSelectionToolbarItems createRenderObject(BuildContext context) {
return _RenderCupertinoTextSelectionToolbarItems(
dividerWidth: dividerWidth,
page: page,
);
}
@override
void updateRenderObject(BuildContext context, _RenderCupertinoTextSelectionToolbarItems renderObject) {
renderObject
..page = page
..dividerWidth = dividerWidth;
}
@override
_CupertinoTextSelectionToolbarItemsElement createElement() => _CupertinoTextSelectionToolbarItemsElement(this);
}
// The custom RenderObjectElement that helps paginate the menu items.
class _CupertinoTextSelectionToolbarItemsElement extends RenderObjectElement {
_CupertinoTextSelectionToolbarItemsElement(
_CupertinoTextSelectionToolbarItems widget,
) : super(widget);
late List<Element> _children;
final Map<_CupertinoTextSelectionToolbarItemsSlot, Element> slotToChild = <_CupertinoTextSelectionToolbarItemsSlot, Element>{};
// We keep a set of forgotten children to avoid O(n^2) work walking _children
// repeatedly to remove children.
final Set<Element> _forgottenChildren = HashSet<Element>();
@override
_CupertinoTextSelectionToolbarItems get widget => super.widget as _CupertinoTextSelectionToolbarItems;
@override
_RenderCupertinoTextSelectionToolbarItems get renderObject => super.renderObject as _RenderCupertinoTextSelectionToolbarItems;
void _updateRenderObject(RenderBox? child, _CupertinoTextSelectionToolbarItemsSlot slot) {
switch (slot) {
case _CupertinoTextSelectionToolbarItemsSlot.backButton:
renderObject.backButton = child;
break;
case _CupertinoTextSelectionToolbarItemsSlot.nextButton:
renderObject.nextButton = child;
break;
case _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled:
renderObject.nextButtonDisabled = child;
break;
}
}
@override
void insertRenderObjectChild(RenderObject child, dynamic slot) {
if (slot is _CupertinoTextSelectionToolbarItemsSlot) {
assert(child is RenderBox);
_updateRenderObject(child as RenderBox, slot);
assert(renderObject.slottedChildren.containsKey(slot));
return;
}
if (slot is IndexedSlot) {
assert(renderObject.debugValidateChild(child));
renderObject.insert(child as RenderBox, after: slot.value?.renderObject as RenderBox?);
return;
}
assert(false, 'slot must be _CupertinoTextSelectionToolbarItemsSlot or IndexedSlot');
}
// This is not reachable for children that don't have an IndexedSlot.
@override
void moveRenderObjectChild(RenderObject child, IndexedSlot<Element> oldSlot, IndexedSlot<Element> newSlot) {
assert(child.parent == renderObject);
renderObject.move(child as RenderBox, after: newSlot.value.renderObject as RenderBox?);
}
static bool _shouldPaint(Element child) {
return (child.renderObject!.parentData! as ToolbarItemsParentData).shouldPaint;
}
@override
void removeRenderObjectChild(RenderObject child, dynamic slot) {
// Check if the child is in a slot.
if (slot is _CupertinoTextSelectionToolbarItemsSlot) {
assert(child is RenderBox);
assert(renderObject.slottedChildren.containsKey(slot));
_updateRenderObject(null, slot);
assert(!renderObject.slottedChildren.containsKey(slot));
return;
}
// Otherwise look for it in the list of children.
assert(slot is IndexedSlot);
assert(child.parent == renderObject);
renderObject.remove(child as RenderBox);
}
@override
void visitChildren(ElementVisitor visitor) {
slotToChild.values.forEach(visitor);
for (final Element child in _children) {
if (!_forgottenChildren.contains(child))
visitor(child);
}
}
@override
void forgetChild(Element child) {
assert(slotToChild.containsValue(child) || _children.contains(child));
assert(!_forgottenChildren.contains(child));
// Handle forgetting a child in children or in a slot.
if (slotToChild.containsKey(child.slot)) {
final _CupertinoTextSelectionToolbarItemsSlot slot = child.slot as _CupertinoTextSelectionToolbarItemsSlot;
slotToChild.remove(slot);
} else {
_forgottenChildren.add(child);
}
super.forgetChild(child);
}
// Mount or update slotted child.
void _mountChild(Widget widget, _CupertinoTextSelectionToolbarItemsSlot slot) {
final Element? oldChild = slotToChild[slot];
final Element? newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
slotToChild.remove(slot);
}
if (newChild != null) {
slotToChild[slot] = newChild;
}
}
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
// Mount slotted children.
_mountChild(widget.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton);
_mountChild(widget.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
_mountChild(widget.nextButtonDisabled, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
// Mount list children.
_children = List<Element>.filled(widget.children.length, _NullElement.instance);
Element? previousChild;
for (int i = 0; i < _children.length; i += 1) {
final Element newChild = inflateWidget(widget.children[i], IndexedSlot<Element?>(i, previousChild));
_children[i] = newChild;
previousChild = newChild;
}
}
@override
void debugVisitOnstageChildren(ElementVisitor visitor) {
// Visit slot children.
for (final Element child in slotToChild.values) {
if (_shouldPaint(child) && !_forgottenChildren.contains(child)) {
visitor(child);
}
}
// Visit list children.
_children
.where((Element child) => !_forgottenChildren.contains(child) && _shouldPaint(child))
.forEach(visitor);
}
@override
void update(_CupertinoTextSelectionToolbarItems newWidget) {
super.update(newWidget);
assert(widget == newWidget);
// Update slotted children.
_mountChild(widget.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton);
_mountChild(widget.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
_mountChild(widget.nextButtonDisabled, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
// Update list children.
_children = updateChildren(_children, widget.children, forgottenChildren: _forgottenChildren);
_forgottenChildren.clear();
}
}
// The custom RenderBox that helps paginate the menu items.
class _RenderCupertinoTextSelectionToolbarItems extends RenderBox with ContainerRenderObjectMixin<RenderBox, ToolbarItemsParentData>, RenderBoxContainerDefaultsMixin<RenderBox, ToolbarItemsParentData> {
_RenderCupertinoTextSelectionToolbarItems({
required double dividerWidth,
required int page,
}) : assert(dividerWidth != null),
assert(page != null),
_dividerWidth = dividerWidth,
_page = page,
super();
final Map<_CupertinoTextSelectionToolbarItemsSlot, RenderBox> slottedChildren = <_CupertinoTextSelectionToolbarItemsSlot, RenderBox>{};
RenderBox? _updateChild(RenderBox? oldChild, RenderBox? newChild, _CupertinoTextSelectionToolbarItemsSlot slot) {
if (oldChild != null) {
dropChild(oldChild);
slottedChildren.remove(slot);
}
if (newChild != null) {
slottedChildren[slot] = newChild;
adoptChild(newChild);
}
return newChild;
}
bool _isSlottedChild(RenderBox child) {
return child == _backButton || child == _nextButton || child == _nextButtonDisabled;
}
int _page;
int get page => _page;
set page(int value) {
if (value == _page) {
return;
}
_page = value;
markNeedsLayout();
}
double _dividerWidth;
double get dividerWidth => _dividerWidth;
set dividerWidth(double value) {
if (value == _dividerWidth) {
return;
}
_dividerWidth = value;
markNeedsLayout();
}
RenderBox? _backButton;
RenderBox? get backButton => _backButton;
set backButton(RenderBox? value) {
_backButton = _updateChild(_backButton, value, _CupertinoTextSelectionToolbarItemsSlot.backButton);
}
RenderBox? _nextButton;
RenderBox? get nextButton => _nextButton;
set nextButton(RenderBox? value) {
_nextButton = _updateChild(_nextButton, value, _CupertinoTextSelectionToolbarItemsSlot.nextButton);
}
RenderBox? _nextButtonDisabled;
RenderBox? get nextButtonDisabled => _nextButtonDisabled;
set nextButtonDisabled(RenderBox? value) {
_nextButtonDisabled = _updateChild(_nextButtonDisabled, value, _CupertinoTextSelectionToolbarItemsSlot.nextButtonDisabled);
}
@override
void performLayout() {
if (firstChild == null) {
size = constraints.smallest;
return;
}
// Layout slotted children.
_backButton!.layout(constraints.loosen(), parentUsesSize: true);
_nextButton!.layout(constraints.loosen(), parentUsesSize: true);
_nextButtonDisabled!.layout(constraints.loosen(), parentUsesSize: true);
final double subsequentPageButtonsWidth =
_backButton!.size.width + _nextButton!.size.width;
double currentButtonPosition = 0.0;
late double toolbarWidth; // The width of the whole widget.
late double greatestHeight = 0.0;
late double firstPageWidth;
int currentPage = 0;
int i = -1;
visitChildren((RenderObject renderObjectChild) {
i++;
final RenderBox child = renderObjectChild as RenderBox;
final ToolbarItemsParentData childParentData =
child.parentData! as ToolbarItemsParentData;
childParentData.shouldPaint = false;
// Skip slotted children and children on pages after the visible page.
if (_isSlottedChild(child) || currentPage > _page) {
return;
}
double paginationButtonsWidth = 0.0;
if (currentPage == 0) {
// If this is the last child, it's ok to fit without a forward button.
paginationButtonsWidth =
i == childCount - 1 ? 0.0 : _nextButton!.size.width;
} else {
paginationButtonsWidth = subsequentPageButtonsWidth;
}
// The width of the menu is set by the first page.
child.layout(
BoxConstraints.loose(Size(
(currentPage == 0 ? constraints.maxWidth : firstPageWidth) - paginationButtonsWidth,
constraints.maxHeight,
)),
parentUsesSize: true,
);
greatestHeight = child.size.height > greatestHeight
? child.size.height
: greatestHeight;
// If this child causes the current page to overflow, move to the next
// page and relayout the child.
final double currentWidth =
currentButtonPosition + paginationButtonsWidth + child.size.width;
if (currentWidth > constraints.maxWidth) {
currentPage++;
currentButtonPosition = _backButton!.size.width + dividerWidth;
paginationButtonsWidth = _backButton!.size.width + _nextButton!.size.width;
child.layout(
BoxConstraints.loose(Size(
firstPageWidth - paginationButtonsWidth,
constraints.maxHeight,
)),
parentUsesSize: true,
);
}
childParentData.offset = Offset(currentButtonPosition, 0.0);
currentButtonPosition += child.size.width + dividerWidth;
childParentData.shouldPaint = currentPage == page;
if (currentPage == 0) {
firstPageWidth = currentButtonPosition + _nextButton!.size.width;
}
if (currentPage == page) {
toolbarWidth = currentButtonPosition;
}
});
// It shouldn't be possible to navigate beyond the last page.
assert(page <= currentPage);
// Position page nav buttons.
if (currentPage > 0) {
final ToolbarItemsParentData nextButtonParentData =
_nextButton!.parentData! as ToolbarItemsParentData;
final ToolbarItemsParentData nextButtonDisabledParentData =
_nextButtonDisabled!.parentData! as ToolbarItemsParentData;
final ToolbarItemsParentData backButtonParentData =
_backButton!.parentData! as ToolbarItemsParentData;
// The forward button always shows if there is more than one page, even on
// the last page (it's just disabled).
if (page == currentPage) {
nextButtonDisabledParentData.offset = Offset(toolbarWidth, 0.0);
nextButtonDisabledParentData.shouldPaint = true;
toolbarWidth += nextButtonDisabled!.size.width;
} else {
nextButtonParentData.offset = Offset(toolbarWidth, 0.0);
nextButtonParentData.shouldPaint = true;
toolbarWidth += nextButton!.size.width;
}
if (page > 0) {
backButtonParentData.offset = Offset.zero;
backButtonParentData.shouldPaint = true;
// No need to add the width of the back button to toolbarWidth here. It's
// already been taken care of when laying out the children to
// accommodate the back button.
}
} else {
// No divider for the next button when there's only one page.
toolbarWidth -= dividerWidth;
}
size = constraints.constrain(Size(toolbarWidth, greatestHeight));
}
@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) {
final Offset childOffset = childParentData.offset + offset;
context.paintChild(child, childOffset);
}
});
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! ToolbarItemsParentData) {
child.parentData = ToolbarItemsParentData();
}
}
// Returns true iff the single child is hit by the given position.
static bool hitTestChild(RenderBox? child, BoxHitTestResult result, { required Offset position }) {
if (child == null) {
return false;
}
final ToolbarItemsParentData childParentData =
child.parentData! as ToolbarItemsParentData;
if (!childParentData.shouldPaint) {
return false;
}
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
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
// Hit test list children.
// 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 that aren't shown.
if (!childParentData.shouldPaint) {
child = childParentData.previousSibling;
continue;
}
if (hitTestChild(child, result, position: position)) {
return true;
}
child = childParentData.previousSibling;
}
// Hit test slot children.
if (hitTestChild(backButton, result, position: position)) {
return true;
}
if (hitTestChild(nextButton, result, position: position)) {
return true;
}
if (hitTestChild(nextButtonDisabled, result, position: position)) {
return true;
}
return false;
}
@override
void attach(PipelineOwner owner) {
// Attach list children.
super.attach(owner);
// Attach slot children.
for (final RenderBox child in slottedChildren.values) {
child.attach(owner);
}
}
@override
void detach() {
// Detach list children.
super.detach();
// Detach slot children.
for (final RenderBox child in slottedChildren.values) {
child.detach();
}
}
@override
void redepthChildren() {
visitChildren((RenderObject renderObjectChild) {
final RenderBox child = renderObjectChild as RenderBox;
redepthChild(child);
});
}
@override
void visitChildren(RenderObjectVisitor visitor) {
// Visit the slotted children.
if (_backButton != null) {
visitor(_backButton!);
}
if (_nextButton != null) {
visitor(_nextButton!);
}
if (_nextButtonDisabled != null) {
visitor(_nextButtonDisabled!);
}
// Visit the list children.
super.visitChildren(visitor);
}
// 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);
}
});
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
final List<DiagnosticsNode> value = <DiagnosticsNode>[];
visitChildren((RenderObject renderObjectChild) {
final RenderBox child = renderObjectChild as RenderBox;
if (child == backButton) {
value.add(child.toDiagnosticsNode(name: 'back button'));
} else if (child == nextButton) {
value.add(child.toDiagnosticsNode(name: 'next button'));
} else if (child == nextButtonDisabled) {
value.add(child.toDiagnosticsNode(name: 'next button disabled'));
// List children.
} else {
value.add(child.toDiagnosticsNode(name: 'menu item'));
}
});
return value;
}
}
// The slots that can be occupied by widgets in
// _CupertinoTextSelectionToolbarItems, excluding the list of children.
enum _CupertinoTextSelectionToolbarItemsSlot {
backButton,
nextButton,
nextButtonDisabled,
}
class _NullElement extends Element {
_NullElement() : super(_NullWidget());
static _NullElement instance = _NullElement();
@override
bool get debugDoingBuild => throw UnimplementedError();
@override
void performRebuild() { }
}
class _NullWidget extends Widget {
@override
Element createElement() => throw UnimplementedError();
}
// 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 'button.dart';
import 'colors.dart';
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
);
// Colors extracted from https://developer.apple.com/design/resources/.
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
const Color _kToolbarBackgroundColor = Color(0xEB202020);
// Eyeballed value.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 16.0, horizontal: 18.0);
/// A button in the style of the iOS text selection toolbar buttons.
class CupertinoTextSelectionToolbarButton extends StatelessWidget {
/// Create an instance of [CupertinoTextSelectionToolbarButton].
const CupertinoTextSelectionToolbarButton({
Key? key,
this.onPressed,
required this.child,
}) : super(key: key);
/// Create an instance of [CupertinoTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default iOS text selection toolbar button.
CupertinoTextSelectionToolbarButton.text({
Key? key,
this.onPressed,
required String text,
}) : child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: onPressed != null ? CupertinoColors.white : CupertinoColors.inactiveGray,
),
),
super(key: key);
/// The child of this button.
///
/// Usually a [Text] or an [Icon].
final Widget child;
/// Called when this button is pressed.
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return CupertinoButton(
borderRadius: null,
color: _kToolbarBackgroundColor,
disabledColor: _kToolbarBackgroundColor,
onPressed: onPressed,
padding: _kToolbarButtonPadding,
pressedOpacity: onPressed == null ? 1.0 : 0.7,
child: child,
);
}
}
...@@ -25,6 +25,8 @@ const double _kToolbarHeight = 44.0; ...@@ -25,6 +25,8 @@ const double _kToolbarHeight = 44.0;
/// See also: /// See also:
/// ///
/// * [TextSelectionToolbar.toolbarBuilder], which is of this type. /// * [TextSelectionToolbar.toolbarBuilder], which is of this type.
/// * [CupertinoTextSelectionToolbar.toolbarBuilder], which is similar, but
/// for a Cupertino-style toolbar.
typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child); typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child);
/// A fully-functional Material-style text selection toolbar. /// A fully-functional Material-style text selection toolbar.
...@@ -34,6 +36,13 @@ typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child); ...@@ -34,6 +36,13 @@ typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child);
/// ///
/// If any children don't fit in the menu, an overflow menu will automatically /// If any children don't fit in the menu, an overflow menu will automatically
/// be created. /// be created.
///
/// See also:
///
/// * [TextSelectionControls.buildToolbar], where this is used by default to
/// build an Android-style toolbar.
/// * [CupertinoTextSelectionToolbar], which is similar, but builds an iOS-
/// style toolbar.
class TextSelectionToolbar extends StatelessWidget { class TextSelectionToolbar extends StatelessWidget {
/// Creates an instance of TextSelectionToolbar. /// Creates an instance of TextSelectionToolbar.
const TextSelectionToolbar({ const TextSelectionToolbar({
...@@ -45,31 +54,39 @@ class TextSelectionToolbar extends StatelessWidget { ...@@ -45,31 +54,39 @@ class TextSelectionToolbar extends StatelessWidget {
}) : assert(children.length > 0), }) : assert(children.length > 0),
super(key: key); super(key: key);
/// {@template flutter.material.TextSelectionToolbar.anchorAbove}
/// The focal point above which the toolbar attempts to position itself. /// 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, /// If there is not enough room above before reaching the top of the screen,
/// then the toolbar will position itself below [anchorBelow]. /// then the toolbar will position itself below [anchorBelow].
/// {@endtemplate}
final Offset anchorAbove; final Offset anchorAbove;
/// {@template flutter.material.TextSelectionToolbar.anchorBelow}
/// The focal point below which the toolbar attempts to position itself, if it /// The focal point below which the toolbar attempts to position itself, if it
/// doesn't fit above [anchorAbove]. /// doesn't fit above [anchorAbove].
/// {@endtemplate}
final Offset anchorBelow; final Offset anchorBelow;
/// {@template flutter.material.TextSelectionToolbar.children}
/// The children that will be displayed in the text selection toolbar. /// The children that will be displayed in the text selection toolbar.
/// ///
/// Typically these are buttons. /// Typically these are buttons.
/// ///
/// Must not be empty. /// Must not be empty.
/// {@endtemplate}
/// ///
/// See also: /// See also:
/// * [TextSelectionToolbarTextButton], which builds a default Material- /// * [TextSelectionToolbarTextButton], which builds a default Material-
/// style text selection toolbar text button. /// style text selection toolbar text button.
final List<Widget> children; final List<Widget> children;
/// {@template flutter.material.TextSelectionToolbar.toolbarBuilder}
/// Builds the toolbar container. /// Builds the toolbar container.
/// ///
/// Useful for customizing the high-level background of the toolbar. The given /// Useful for customizing the high-level background of the toolbar. The given
/// child Widget will contain all of the [children]. /// child Widget will contain all of the [children].
/// {@endtemplate}
final ToolbarBuilder toolbarBuilder; final ToolbarBuilder toolbarBuilder;
// Build the default Android Material text selection menu toolbar. // Build the default Android Material text selection menu toolbar.
...@@ -81,29 +98,26 @@ class TextSelectionToolbar extends StatelessWidget { ...@@ -81,29 +98,26 @@ class TextSelectionToolbar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double paddingTop = MediaQuery.of(context).padding.top final double paddingAbove = MediaQuery.of(context).padding.top
+ _kToolbarScreenPadding; + _kToolbarScreenPadding;
final double availableHeight = anchorAbove.dy - paddingTop; final double availableHeight = anchorAbove.dy - paddingAbove;
final bool fitsAbove = _kToolbarHeight <= availableHeight; final bool fitsAbove = _kToolbarHeight <= availableHeight;
final Offset anchor = fitsAbove ? anchorAbove : anchorBelow; final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
final Offset localAnchor = Offset(
anchor.dx - _kToolbarScreenPadding,
anchor.dy - paddingTop,
);
return Padding( return Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding, _kToolbarScreenPadding,
paddingTop, paddingAbove,
_kToolbarScreenPadding, _kToolbarScreenPadding,
_kToolbarScreenPadding, _kToolbarScreenPadding,
), ),
child: Stack( child: Stack(
children: <Widget>[ children: <Widget>[
CustomSingleChildLayout( CustomSingleChildLayout(
delegate: _TextSelectionToolbarLayoutDelegate( delegate: TextSelectionToolbarLayoutDelegate(
localAnchor, anchorAbove: anchorAbove - localAdjustment,
fitsAbove, anchorBelow: anchorBelow - localAdjustment,
fitsAbove: fitsAbove,
), ),
child: _TextSelectionToolbarOverflowable( child: _TextSelectionToolbarOverflowable(
isAbove: fitsAbove, isAbove: fitsAbove,
...@@ -117,66 +131,6 @@ class TextSelectionToolbar extends StatelessWidget { ...@@ -117,66 +131,6 @@ class TextSelectionToolbar extends StatelessWidget {
} }
} }
// 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 // A toolbar containing the given children. If they overflow the width
// available, then the overflowing children will be displayed in an overflow // available, then the overflowing children will be displayed in an overflow
// menu. // menu.
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
/// Positions the toolbar above [anchorAbove] if it fits, or otherwise below
/// [anchorBelow].
///
/// See also:
///
/// * [TextSelectionToolbar], which uses this to position itself.
/// * [CupertinoTextSelectionToolbar], which also uses this to position
/// itself.
class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate {
/// Creates an instance of TextSelectionToolbarLayoutDelegate.
TextSelectionToolbarLayoutDelegate({
required this.anchorAbove,
required this.anchorBelow,
this.fitsAbove,
});
/// {@macro flutter.material.TextSelectionToolbar.anchorAbove}
///
/// Should be provided in local coordinates.
final Offset anchorAbove;
/// {@macro flutter.material.TextSelectionToolbar.anchorAbove}
///
/// Should be provided in local coordinates.
final Offset anchorBelow;
/// Whether or not the child should be considered to fit above anchorAbove.
///
/// Typically used to force the child to be drawn at anchorAbove even when it
/// doesn't fit, such as when the Material [TextSelectionToolbar] draws an
/// open overflow menu.
///
/// If not provided, it will be calculated.
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) {
final bool fitsAbove = this.fitsAbove ?? anchorAbove.dy >= childSize.height;
final Offset anchor = fitsAbove ? anchorAbove : anchorBelow;
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 anchorAbove != oldDelegate.anchorAbove
|| anchorBelow != oldDelegate.anchorBelow
|| fitsAbove != oldDelegate.fitsAbove;
}
}
...@@ -115,6 +115,7 @@ export 'src/widgets/status_transitions.dart'; ...@@ -115,6 +115,7 @@ export 'src/widgets/status_transitions.dart';
export 'src/widgets/table.dart'; export 'src/widgets/table.dart';
export 'src/widgets/text.dart'; export 'src/widgets/text.dart';
export 'src/widgets/text_selection.dart'; export 'src/widgets/text_selection.dart';
export 'src/widgets/text_selection_toolbar_layout_delegate.dart';
export 'src/widgets/texture.dart'; export 'src/widgets/texture.dart';
export 'src/widgets/ticker_provider.dart'; export 'src/widgets/ticker_provider.dart';
export 'src/widgets/title.dart'; export 'src/widgets/title.dart';
......
// 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/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('can press', (WidgetTester tester) async {
bool pressed = false;
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {
pressed = true;
},
),
),
),
);
expect(pressed, false);
await tester.tap(find.byType(CupertinoTextSelectionToolbarButton));
expect(pressed, true);
});
testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () { },
),
),
),
);
// Originall at full opacity.
FadeTransition opacity = tester.widget(find.descendant(
of: find.byType(CupertinoTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
));
expect(opacity.opacity.value, 1.0);
// Make a "down" gesture on the button.
final Offset center = tester.getCenter(find.byType(CupertinoTextSelectionToolbarButton));
final TestGesture gesture = await tester.startGesture(center);
await tester.pumpAndSettle();
// Opacity reduces during the down gesture.
opacity = tester.widget(find.descendant(
of: find.byType(CupertinoTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
));
expect(opacity.opacity.value, 0.7);
// Release the down gesture.
await gesture.up();
await tester.pumpAndSettle();
// Opacity is back to normal.
opacity = tester.widget(find.descendant(
of: find.byType(CupertinoTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
));
expect(opacity.opacity.value, 1.0);
});
}
// 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/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import '../widgets/text.dart' show textOffsetToPosition;
// These constants are copied from cupertino/text_selection_toolbar.dart.
const double _kArrowScreenPadding = 26.0;
const double _kToolbarContentDistance = 8.0;
const double _kToolbarHeight = 43.0;
// A custom text selection menu that just displays a single custom button.
class _CustomCupertinoTextSelectionControls extends CupertinoTextSelectionControls {
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
) {
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double anchorX = (selectionMidpoint.dx + globalEditableRegion.left).clamp(
_kArrowScreenPadding + mediaQuery.padding.left,
mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
);
final Offset anchorAbove = Offset(
anchorX,
endpoints.first.point.dy - textLineHeight + globalEditableRegion.top,
);
final Offset anchorBelow = Offset(
anchorX,
endpoints.last.point.dy + globalEditableRegion.top,
);
return CupertinoTextSelectionToolbar(
anchorAbove: anchorAbove,
anchorBelow: anchorBelow,
children: <Widget>[
CupertinoTextSelectionToolbarButton(
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(CupertinoApp),
matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type),
);
}
// Finding CupertinoTextSelectionToolbar 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('_CupertinoTextSelectionToolbarContent');
Finder _findOverflowNextButton() => find.text('▶');
Finder _findOverflowBackButton() => find.text('◀');
testWidgets('paginates children 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(
CupertinoApp(
home: Center(
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return CupertinoTextSelectionToolbar(
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(_findOverflowNextButton(), findsNothing);
expect(_findOverflowBackButton(), 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(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsNothing);
// Tap the overflow next button to show the next page of children.
await tester.tap(_findOverflowNextButton());
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(1));
expect(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsOneWidget);
// Tapping the overflow next button again does nothing because it is
// disabled and there are no more children to display.
await tester.tap(_findOverflowNextButton());
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(1));
expect(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsOneWidget);
// Tap the overflow back button to go back to the first page.
await tester.tap(_findOverflowBackButton());
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(7));
expect(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsNothing);
// Adding 7 more children overflows onto a third page.
setState(() {
children.add(Container(width: itemWidth, height: height));
children.add(Container(width: itemWidth, height: height));
children.add(Container(width: itemWidth, height: height));
children.add(Container(width: itemWidth, height: height));
children.add(Container(width: itemWidth, height: height));
children.add(Container(width: itemWidth, height: height));
});
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(7));
expect(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsNothing);
// Tap the overflow next button to show the second page of children.
await tester.tap(_findOverflowNextButton());
await tester.pumpAndSettle();
// With the back button, only six children fit on this page.
expect(find.byType(Container), findsNWidgets(6));
expect(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsOneWidget);
// Tap the overflow next button again to show the third page of children.
await tester.tap(_findOverflowNextButton());
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(1));
expect(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsOneWidget);
// Tap the overflow back button to go back to the second page.
await tester.tap(_findOverflowBackButton());
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(6));
expect(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsOneWidget);
// Tap the overflow back button to go back to the first page.
await tester.tap(_findOverflowBackButton());
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(7));
expect(_findOverflowNextButton(), findsOneWidget);
expect(_findOverflowBackButton(), findsNothing);
}, skip: kIsWeb);
testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async {
late StateSetter setState;
const double height = _kToolbarHeight;
const double anchorBelowY = 500.0;
double anchorAboveY = 0.0;
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return CupertinoTextSelectionToolbar(
anchorAbove: Offset(50.0, anchorAboveY),
anchorBelow: const Offset(50.0, anchorBelowY),
children: <Widget>[
Container(color: const Color(0xffff0000), width: 50.0, height: height),
Container(color: const Color(0xff00ff00), width: 50.0, height: height),
Container(color: const Color(0xff0000ff), 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 + _kToolbarContentDistance));
// Even when it barely doesn't fit.
setState(() {
anchorAboveY = 50.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(_findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistance));
// 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 - _kToolbarContentDistance));
}, skip: kIsWeb);
testWidgets('can create and use a custom toolbar', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Select me custom menu',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(
controller: controller,
selectionControls: _CustomCupertinoTextSelectionControls(),
),
),
),
);
// 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);
}
...@@ -14,7 +14,6 @@ import 'package:flutter/services.dart'; ...@@ -14,7 +14,6 @@ import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind; import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
import '../widgets/text.dart' show findRenderEditable, globalize, textOffsetToPosition; import '../widgets/text.dart' show findRenderEditable, globalize, textOffsetToPosition;
import 'feedback_tester.dart'; import 'feedback_tester.dart';
...@@ -1134,7 +1133,7 @@ void main() { ...@@ -1134,7 +1133,7 @@ void main() {
// Wait for context menu to be built. // Wait for context menu to be built.
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(CupertinoTextSelectionToolbar), paintsNothing); expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('text field build empty toolbar when no options available', (WidgetTester tester) async { testWidgets('text field build empty toolbar when no options available', (WidgetTester tester) async {
......
// 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/rendering.dart';
import 'package:flutter/widgets.dart';
void main() {
testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async {
late StateSetter setState;
const double height = 43.0;
const double anchorBelowY = 500.0;
double anchorAboveY = 0.0;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate(
anchorAbove: Offset(50.0, anchorAboveY),
anchorBelow: const Offset(50.0, anchorBelowY),
),
child: Container(
width: 200.0,
height: height,
color: const Color(0xffff0000),
),
);
},
),
),
),
);
// When the toolbar doesn't fit above aboveAnchor, it positions itself below
// belowAnchor.
double toolbarY = tester.getTopLeft(find.byType(Container)).dy;
expect(toolbarY, equals(anchorBelowY));
// Even when it barely doesn't fit.
setState(() {
anchorAboveY = height - 1.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(find.byType(Container)).dy;
expect(toolbarY, equals(anchorBelowY));
// When it does fit above aboveAnchor, it positions itself there.
setState(() {
anchorAboveY = height;
});
await tester.pump();
toolbarY = tester.getTopLeft(find.byType(Container)).dy;
expect(toolbarY, equals(anchorAboveY - height));
});
}
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