Unverified Commit d486aa70 authored by chunhtai's avatar chunhtai Committed by GitHub

Refactor TextSelectionOverlay (#98153)

* Refactor TextSelectionOverlay

* fix test

* remove print

* fix more tests

* update

* added tests

* fix comments

* fmt

* fix test

* addressing comment

* remove dispose

* remove new line
parent e8bc5c5a
......@@ -1089,7 +1089,7 @@ class EditableText extends StatefulWidget {
/// {@endtemplate}
final SelectionChangedCallback? onSelectionChanged;
/// {@macro flutter.widgets.TextSelectionOverlay.onSelectionHandleTapped}
/// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped}
final VoidCallback? onSelectionHandleTapped;
/// {@template flutter.widgets.editableText.inputFormatters}
......
......@@ -239,33 +239,345 @@ abstract class TextSelectionControls {
}
}
/// An object that manages a pair of text selection handles.
/// An object that manages a pair of text selection handles for a
/// [RenderEditable].
///
/// The selection handles are displayed in the [Overlay] that most closely
/// encloses the given [BuildContext].
/// This class is a wrapper of [SelectionOverlay] to provide APIs specific for
/// [RenderEditable]s. To manage selection handles for custom widgets, use
/// [SelectionOverlay] instead.
class TextSelectionOverlay {
/// Creates an object that manages overlay entries for selection handles.
///
/// The [context] must not be null and must have an [Overlay] as an ancestor.
TextSelectionOverlay({
required TextEditingValue value,
required this.context,
this.debugRequiredFor,
required this.toolbarLayerLink,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required BuildContext context,
Widget? debugRequiredFor,
required LayerLink toolbarLayerLink,
required LayerLink startHandleLayerLink,
required LayerLink endHandleLayerLink,
required this.renderObject,
this.selectionControls,
bool handlesVisible = false,
required this.selectionDelegate,
this.dragStartBehavior = DragStartBehavior.start,
this.onSelectionHandleTapped,
this.clipboardStatus,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
VoidCallback? onSelectionHandleTapped,
ClipboardStatusNotifier? clipboardStatus,
}) : assert(value != null),
assert(context != null),
assert(handlesVisible != null),
_handlesVisible = handlesVisible,
_value = value {
renderObject.selectionStartInViewport.addListener(_updateHandleVisibilities);
renderObject.selectionEndInViewport.addListener(_updateHandleVisibilities);
_updateHandleVisibilities();
_selectionOverlay = SelectionOverlay(
context: context,
debugRequiredFor: debugRequiredFor,
// The metrics will be set when show handles.
startHandleType: TextSelectionHandleType.collapsed,
startHandlesVisible: _effectiveStartHandleVisibility,
lineHeightAtStart: 0.0,
onStartHandleDragStart: _handleSelectionStartHandleDragStart,
onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
endHandleType: TextSelectionHandleType.collapsed,
endHandlesVisible: _effectiveEndHandleVisibility,
lineHeightAtEnd: 0.0,
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
selectionEndPoints: const <TextSelectionPoint>[],
selectionControls: selectionControls,
selectionDelegate: selectionDelegate,
clipboardStatus: clipboardStatus,
startHandleLayerLink: startHandleLayerLink,
endHandleLayerLink: endHandleLayerLink,
toolbarLayerLink: toolbarLayerLink,
onSelectionHandleTapped: onSelectionHandleTapped,
dragStartBehavior: dragStartBehavior,
toolbarLocation: renderObject.lastSecondaryTapDownPosition,
);
}
// TODO(mpcomplete): what if the renderObject is removed or replaced, or
// moves? Not sure what cases I need to handle, or how to handle them.
/// The editable line in which the selected text is being displayed.
final RenderEditable renderObject;
/// {@macro flutter.widgets.SelectionOverlay.selectionControls}
final TextSelectionControls? selectionControls;
/// {@macro flutter.widgets.SelectionOverlay.selectionDelegate}
final TextSelectionDelegate selectionDelegate;
late final SelectionOverlay _selectionOverlay;
/// Retrieve current value.
@visibleForTesting
TextEditingValue get value => _value;
TextEditingValue _value;
TextSelection get _selection => _value.selection;
final ValueNotifier<bool> _effectiveStartHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
void _updateHandleVisibilities() {
_effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
}
/// Whether selection handles are visible.
///
/// Set to false if you want to hide the handles. Use this property to show or
/// hide the handle without rebuilding them.
///
/// Defaults to false.
bool get handlesVisible => _handlesVisible;
bool _handlesVisible = false;
set handlesVisible(bool visible) {
assert(visible != null);
if (_handlesVisible == visible)
return;
_handlesVisible = visible;
_updateHandleVisibilities();
}
/// {@macro flutter.widgets.SelectionOverlay.showHandles}
void showHandles() {
_updateSelectionOverlay();
_selectionOverlay.showHandles();
}
/// {@macro flutter.widgets.SelectionOverlay.hideHandles}
void hideHandles() => _selectionOverlay.hideHandles();
/// {@macro flutter.widgets.SelectionOverlay.showToolbar}
void showToolbar() {
_updateSelectionOverlay();
_selectionOverlay.showToolbar();
}
/// Updates the overlay after the selection has changed.
///
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
/// until the post-frame callbacks phase. Otherwise the update is done
/// synchronously. This means that it is safe to call during builds, but also
/// that if you do call this during a build, the UI will not update until the
/// next frame (i.e. many milliseconds later).
void update(TextEditingValue newValue) {
if (_value == newValue)
return;
_value = newValue;
_updateSelectionOverlay();
}
void _updateSelectionOverlay() {
_selectionOverlay
// Update selection handle metrics.
..startHandleType = _chooseType(
renderObject.textDirection,
TextSelectionHandleType.left,
TextSelectionHandleType.right,
)
..lineHeightAtStart = _getStartGlyphHeight()
..endHandleType = _chooseType(
renderObject.textDirection,
TextSelectionHandleType.right,
TextSelectionHandleType.left,
)
..lineHeightAtEnd = _getEndGlyphHeight()
// Update selection toolbar metrics.
..selectionEndPoints = renderObject.getEndpointsForSelection(_selection)
..toolbarLocation = renderObject.lastSecondaryTapDownPosition;
}
/// Causes the overlay to update its rendering.
///
/// This is intended to be called when the [renderObject] may have changed its
/// text metrics (e.g. because the text was scrolled).
void updateForScroll() => _updateSelectionOverlay();
/// Whether the handles are currently visible.
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
/// Whether the toolbar is currently visible.
bool get toolbarIsVisible => _selectionOverlay._toolbar != null;
/// {@macro flutter.widgets.SelectionOverlay.hide}
void hide() => _selectionOverlay.hide();
/// {@macro flutter.widgets.SelectionOverlay.hideToolbar}
void hideToolbar() => _selectionOverlay.hideToolbar();
/// {@macro flutter.widgets.SelectionOverlay.dispose}
void dispose() {
renderObject.selectionStartInViewport.removeListener(_updateHandleVisibilities);
renderObject.selectionEndInViewport.removeListener(_updateHandleVisibilities);
_selectionOverlay.dispose();
}
double _getStartGlyphHeight() {
final InlineSpan span = renderObject.text!;
final String prevText = span.toPlainText();
final String currText = selectionDelegate.textEditingValue.text;
final int firstSelectedGraphemeExtent;
Rect? startHandleRect;
// Only calculate handle rects if the text in the previous frame
// is the same as the text in the current frame. This is done because
// widget.renderObject contains the renderEditable from the previous frame.
// If the text changed between the current and previous frames then
// widget.renderObject.getRectForComposingRange might fail. In cases where
// the current frame is different from the previous we fall back to
// renderObject.preferredLineHeight.
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
final String selectedGraphemes = _selection.textInside(currText);
firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
startHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.start, end: _selection.start + firstSelectedGraphemeExtent));
}
return startHandleRect?.height ?? renderObject.preferredLineHeight;
}
double _getEndGlyphHeight() {
final InlineSpan span = renderObject.text!;
final String prevText = span.toPlainText();
final String currText = selectionDelegate.textEditingValue.text;
final int lastSelectedGraphemeExtent;
Rect? endHandleRect;
// See the explanation in _getStartGlyphHeight.
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
final String selectedGraphemes = _selection.textInside(currText);
lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
endHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.end - lastSelectedGraphemeExtent, end: _selection.end));
}
return endHandleRect?.height ?? renderObject.preferredLineHeight;
}
late Offset _dragEndPosition;
void _handleSelectionEndHandleDragStart(DragStartDetails details) {
final Size handleSize = selectionControls!.getHandleSize(
renderObject.preferredLineHeight,
);
_dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height);
}
void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
_dragEndPosition += details.delta;
final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition);
if (_selection.isCollapsed) {
_handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: true);
return;
}
final TextSelection newSelection = TextSelection(
baseOffset: _selection.baseOffset,
extentOffset: position.offset,
);
if (newSelection.baseOffset >= newSelection.extentOffset)
return; // don't allow order swapping.
_handleSelectionHandleChanged(newSelection, isEnd: true);
}
late Offset _dragStartPosition;
void _handleSelectionStartHandleDragStart(DragStartDetails details) {
final Size handleSize = selectionControls!.getHandleSize(
renderObject.preferredLineHeight,
);
_dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height);
}
void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
_dragStartPosition += details.delta;
final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition);
if (_selection.isCollapsed) {
_handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: false);
return;
}
final TextSelection newSelection = TextSelection(
baseOffset: position.offset,
extentOffset: _selection.extentOffset,
);
if (newSelection.baseOffset >= newSelection.extentOffset)
return; // don't allow order swapping.
_handleSelectionHandleChanged(newSelection, isEnd: false);
}
void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) {
final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base;
selectionDelegate.userUpdateTextEditingValue(
_value.copyWith(selection: newSelection),
SelectionChangedCause.drag,
);
selectionDelegate.bringIntoView(textPosition);
}
TextSelectionHandleType _chooseType(
TextDirection textDirection,
TextSelectionHandleType ltrType,
TextSelectionHandleType rtlType,
) {
if (_selection.isCollapsed)
return TextSelectionHandleType.collapsed;
assert(textDirection != null);
switch (textDirection) {
case TextDirection.ltr:
return ltrType;
case TextDirection.rtl:
return rtlType;
}
}
}
/// An object that manages a pair of selection handles.
///
/// The selection handles are displayed in the [Overlay] that most closely
/// encloses the given [BuildContext].
class SelectionOverlay {
/// Creates an object that manages overlay entries for selection handles.
///
/// The [context] must not be null and must have an [Overlay] as an ancestor.
SelectionOverlay({
required this.context,
this.debugRequiredFor,
required TextSelectionHandleType startHandleType,
required double lineHeightAtStart,
this.startHandlesVisible,
this.onStartHandleDragStart,
this.onStartHandleDragUpdate,
this.onStartHandleDragEnd,
required TextSelectionHandleType endHandleType,
required double lineHeightAtEnd,
this.endHandlesVisible,
this.onEndHandleDragStart,
this.onEndHandleDragUpdate,
this.onEndHandleDragEnd,
required List<TextSelectionPoint> selectionEndPoints,
required this.selectionControls,
required this.selectionDelegate,
required this.clipboardStatus,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.toolbarLayerLink,
this.dragStartBehavior = DragStartBehavior.start,
this.onSelectionHandleTapped,
Offset? toolbarLocation,
}) : _startHandleType = startHandleType,
_lineHeightAtStart = lineHeightAtStart,
_endHandleType = endHandleType,
_lineHeightAtEnd = lineHeightAtEnd,
_selectionEndPoints = selectionEndPoints,
_toolbarLocation = toolbarLocation {
final OverlayState? overlay = Overlay.of(context, rootOverlay: true);
assert(
overlay != null,
......@@ -273,9 +585,6 @@ class TextSelectionOverlay {
'Usually the Navigator created by WidgetsApp provides the overlay. Perhaps your '
'app content was created above the Navigator with the WidgetsApp builder parameter.',
);
renderObject.selectionStartInViewport.addListener(_updateHandleVisibilities);
renderObject.selectionEndInViewport.addListener(_updateHandleVisibilities);
_updateHandleVisibilities();
_toolbarController = AnimationController(duration: fadeDuration, vsync: overlay!);
}
......@@ -285,6 +594,104 @@ class TextSelectionOverlay {
/// will display the text selection handles in that [Overlay].
final BuildContext context;
/// The type of start selection handle.
///
/// Changing the value while the handles are visible causes them to rebuild.
TextSelectionHandleType get startHandleType => _startHandleType;
TextSelectionHandleType _startHandleType;
set startHandleType(TextSelectionHandleType value) {
if (_startHandleType == value)
return;
_startHandleType = value;
_markNeedsBuild();
}
/// The line height at the selection start.
///
/// This value is used for calculating the size of the start selection handle.
///
/// Changing the value while the handles are visible causes them to rebuild.
double get lineHeightAtStart => _lineHeightAtStart;
double _lineHeightAtStart;
set lineHeightAtStart(double value) {
if (_lineHeightAtStart == value)
return;
_lineHeightAtStart = value;
_markNeedsBuild();
}
/// Whether the start handle is visible.
///
/// If the value changes, the start handle uses [FadeTransition] to transition
/// itself on and off the screen.
///
/// If this is null, the start selection handle will always be visible.
final ValueListenable<bool>? startHandlesVisible;
/// Called when the users start dragging the start selection handles.
final ValueChanged<DragStartDetails>? onStartHandleDragStart;
/// Called when the users drag the start selection handles to new locations.
final ValueChanged<DragUpdateDetails>? onStartHandleDragUpdate;
/// Called when the users lift their fingers after dragging the start selection
/// handles.
final ValueChanged<DragEndDetails>? onStartHandleDragEnd;
/// The type of end selection handle.
///
/// Changing the value while the handles are visible causes them to rebuild.
TextSelectionHandleType get endHandleType => _endHandleType;
TextSelectionHandleType _endHandleType;
set endHandleType(TextSelectionHandleType value) {
if (_endHandleType == value)
return;
_endHandleType = value;
_markNeedsBuild();
}
/// The line height at the selection end.
///
/// This value is used for calculating the size of the end selection handle.
///
/// Changing the value while the handles are visible causes them to rebuild.
double get lineHeightAtEnd => _lineHeightAtEnd;
double _lineHeightAtEnd;
set lineHeightAtEnd(double value) {
if (_lineHeightAtEnd == value)
return;
_lineHeightAtEnd = value;
_markNeedsBuild();
}
/// Whether the end handle is visible.
///
/// If the value changes, the end handle uses [FadeTransition] to transition
/// itself on and off the screen.
///
/// If this is null, the end selection handle will always be visible.
final ValueListenable<bool>? endHandlesVisible;
/// Called when the users start dragging the end selection handles.
final ValueChanged<DragStartDetails>? onEndHandleDragStart;
/// Called when the users drag the end selection handles to new locations.
final ValueChanged<DragUpdateDetails>? onEndHandleDragUpdate;
/// Called when the users lift their fingers after dragging the end selection
/// handles.
final ValueChanged<DragEndDetails>? onEndHandleDragEnd;
/// The text selection positions of selection start and end.
List<TextSelectionPoint> get selectionEndPoints => _selectionEndPoints;
List<TextSelectionPoint> _selectionEndPoints;
set selectionEndPoints(List<TextSelectionPoint> value) {
if (!listEquals(_selectionEndPoints, value)) {
_markNeedsBuild();
}
_selectionEndPoints = value;
}
/// Debugging information for explaining why the [Overlay] is required.
final Widget? debugRequiredFor;
......@@ -300,16 +707,15 @@ class TextSelectionOverlay {
/// location of end selection handle.
final LayerLink endHandleLayerLink;
// TODO(mpcomplete): what if the renderObject is removed or replaced, or
// moves? Not sure what cases I need to handle, or how to handle them.
/// The editable line in which the selected text is being displayed.
final RenderEditable renderObject;
/// {@template flutter.widgets.SelectionOverlay.selectionControls}
/// Builds text selection handles and toolbar.
/// {@endtemplate}
final TextSelectionControls? selectionControls;
/// {@template flutter.widgets.SelectionOverlay.selectionDelegate}
/// The delegate for manipulating the current selection in the owning
/// text field.
/// {@endtemplate}
final TextSelectionDelegate selectionDelegate;
/// Determines the way that drag start behavior is handled.
......@@ -330,7 +736,7 @@ class TextSelectionOverlay {
/// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors.
final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.TextSelectionOverlay.onSelectionHandleTapped}
/// {@template flutter.widgets.SelectionOverlay.onSelectionHandleTapped}
/// A callback that's optionally invoked when a selection handle is tapped.
///
/// The [TextSelectionControls.buildHandle] implementation the text field
......@@ -353,18 +759,30 @@ class TextSelectionOverlay {
/// asynchronously (see [Clipboard.getData]).
final ClipboardStatusNotifier? clipboardStatus;
/// The location of where the toolbar should be drawn in relative to the
/// location of [toolbarLayerLink].
///
/// If this is null, the toolbar is drawn based on [selectionEndPoints] and
/// the rect of render object of [context].
///
/// This is useful for displaying toolbars at the mouse right-click locations
/// in desktop devices.
Offset? get toolbarLocation => _toolbarLocation;
Offset? _toolbarLocation;
set toolbarLocation(Offset? value) {
if (_toolbarLocation == value) {
return;
}
_toolbarLocation = value;
_markNeedsBuild();
}
/// Controls the fade-in and fade-out animations for the toolbar and handles.
static const Duration fadeDuration = Duration(milliseconds: 150);
late final AnimationController _toolbarController;
Animation<double> get _toolbarOpacity => _toolbarController.view;
/// Retrieve current value.
@visibleForTesting
TextEditingValue get value => _value;
TextEditingValue _value;
/// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed.
List<OverlayEntry>? _handles;
......@@ -372,40 +790,9 @@ class TextSelectionOverlay {
/// A copy/paste toolbar.
OverlayEntry? _toolbar;
TextSelection get _selection => _value.selection;
final ValueNotifier<bool> _effectiveStartHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
void _updateHandleVisibilities() {
_effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
}
/// Whether selection handles are visible.
///
/// Set to false if you want to hide the handles. Use this property to show or
/// hide the handle without rebuilding them.
///
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
/// until the post-frame callbacks phase. Otherwise the update is done
/// synchronously. This means that it is safe to call during builds, but also
/// that if you do call this during a build, the UI will not update until the
/// next frame (i.e. many milliseconds later).
///
/// Defaults to false.
bool get handlesVisible => _handlesVisible;
bool _handlesVisible = false;
set handlesVisible(bool visible) {
assert(visible != null);
if (_handlesVisible == visible)
return;
_handlesVisible = visible;
_updateHandleVisibilities();
}
/// {@template flutter.widgets.SelectionOverlay.showHandles}
/// Builds the handles by inserting them into the [context]'s overlay.
/// {@endtemplate}
void showHandles() {
if (_handles != null)
return;
......@@ -419,7 +806,9 @@ class TextSelectionOverlay {
.insertAll(_handles!);
}
/// {@template flutter.widgets.SelectionOverlay.hideHandles}
/// Destroys the handles by removing them from overlay.
/// {@endtemplate}
void hideHandles() {
if (_handles != null) {
_handles![0].remove();
......@@ -428,57 +817,48 @@ class TextSelectionOverlay {
}
}
/// {@template flutter.widgets.SelectionOverlay.showToolbar}
/// Shows the toolbar by inserting it into the [context]'s overlay.
/// {@endtemplate}
void showToolbar() {
assert(_toolbar == null);
if (_toolbar != null) {
return;
}
_toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insert(_toolbar!);
_toolbarController.forward(from: 0.0);
}
/// Updates the overlay after the selection has changed.
///
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
/// until the post-frame callbacks phase. Otherwise the update is done
/// synchronously. This means that it is safe to call during builds, but also
/// that if you do call this during a build, the UI will not update until the
/// next frame (i.e. many milliseconds later).
void update(TextEditingValue newValue) {
if (_value == newValue)
bool _buildScheduled = false;
void _markNeedsBuild() {
if (_handles == null && _toolbar == null)
return;
_value = newValue;
// If we are in build state, it will be too late to update visibility.
// We will need to schedule the build in next frame.
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback(_markNeedsBuild);
} else {
_markNeedsBuild();
}
}
/// Causes the overlay to update its rendering.
///
/// This is intended to be called when the [renderObject] may have changed its
/// text metrics (e.g. because the text was scrolled).
void updateForScroll() {
_markNeedsBuild();
if (_buildScheduled)
return;
_buildScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
_buildScheduled = false;
if (_handles != null) {
_handles![0].markNeedsBuild();
_handles![1].markNeedsBuild();
}
void _markNeedsBuild([ Duration? duration ]) {
_toolbar?.markNeedsBuild();
});
} else {
if (_handles != null) {
_handles![0].markNeedsBuild();
_handles![1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
}
}
/// Whether the handles are currently visible.
bool get handlesAreVisible => _handles != null && handlesVisible;
/// Whether the toolbar is currently visible.
bool get toolbarIsVisible => _toolbar != null;
/// {@template flutter.widgets.SelectionOverlay.hide}
/// Hides the entire overlay including the toolbar and the handles.
/// {@endtemplate}
void hide() {
if (_handles != null) {
_handles![0].remove();
......@@ -490,22 +870,25 @@ class TextSelectionOverlay {
}
}
/// {@template flutter.widgets.SelectionOverlay.hideToolbar}
/// Hides the toolbar part of the overlay.
///
/// To hide the whole overlay, see [hide].
/// {@endtemplate}
void hideToolbar() {
assert(_toolbar != null);
if (_toolbar == null)
return;
_toolbarController.stop();
_toolbar?.remove();
_toolbar = null;
}
/// Final cleanup.
/// {@template flutter.widgets.SelectionOverlay.dispose}
/// Disposes this object and release resources.
/// {@endtemplate}
void dispose() {
hide();
_toolbarController.dispose();
renderObject.selectionStartInViewport.removeListener(_updateHandleVisibilities);
renderObject.selectionEndInViewport.removeListener(_updateHandleVisibilities);
}
Widget _buildStartHandle(BuildContext context) {
......@@ -515,18 +898,15 @@ class TextSelectionOverlay {
handle = Container();
else {
handle = _SelectionHandleOverlay(
type: _chooseType(
renderObject.textDirection,
TextSelectionHandleType.left,
TextSelectionHandleType.right,
),
type: _startHandleType,
handleLayerLink: startHandleLayerLink,
onSelectionHandleTapped: onSelectionHandleTapped,
onSelectionHandleDragStart: _handleSelectionStartHandleDragStart,
onSelectionHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
onSelectionHandleDragStart: onStartHandleDragStart,
onSelectionHandleDragUpdate: onStartHandleDragUpdate,
onSelectionHandleDragEnd: onStartHandleDragEnd,
selectionControls: selectionControls,
visibility: _effectiveStartHandleVisibility,
preferredLineHeight: _getStartGlyphHeight(),
visibility: startHandlesVisible,
preferredLineHeight: _lineHeightAtStart,
dragStartBehavior: dragStartBehavior,
);
}
......@@ -538,22 +918,19 @@ class TextSelectionOverlay {
Widget _buildEndHandle(BuildContext context) {
final Widget handle;
final TextSelectionControls? selectionControls = this.selectionControls;
if (_selection.isCollapsed || selectionControls == null)
handle = Container(); // hide the second handle when collapsed
if (selectionControls == null || _startHandleType == TextSelectionHandleType.collapsed)
handle = Container(); // hide the second handle when collapsed.
else {
handle = _SelectionHandleOverlay(
type: _chooseType(
renderObject.textDirection,
TextSelectionHandleType.right,
TextSelectionHandleType.left,
),
type: _endHandleType,
handleLayerLink: endHandleLayerLink,
onSelectionHandleTapped: onSelectionHandleTapped,
onSelectionHandleDragStart: _handleSelectionEndHandleDragStart,
onSelectionHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
onSelectionHandleDragStart: onEndHandleDragStart,
onSelectionHandleDragUpdate: onEndHandleDragUpdate,
onSelectionHandleDragEnd: onEndHandleDragEnd,
selectionControls: selectionControls,
visibility: _effectiveEndHandleVisibility,
preferredLineHeight: _getEndGlyphHeight(),
visibility: endHandlesVisible,
preferredLineHeight: _lineHeightAtEnd,
dragStartBehavior: dragStartBehavior,
);
}
......@@ -562,126 +939,30 @@ class TextSelectionOverlay {
);
}
double _getStartGlyphHeight() {
final InlineSpan span = renderObject.text!;
final String prevText = span.toPlainText();
final String currText = selectionDelegate.textEditingValue.text;
final int firstSelectedGraphemeExtent;
Rect? startHandleRect;
// Only calculate handle rects if the text in the previous frame
// is the same as the text in the current frame. This is done because
// widget.renderObject contains the renderEditable from the previous frame.
// If the text changed between the current and previous frames then
// widget.renderObject.getRectForComposingRange might fail. In cases where
// the current frame is different from the previous we fall back to
// renderObject.preferredLineHeight.
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
final String selectedGraphemes = _selection.textInside(currText);
firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
startHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.start, end: _selection.start + firstSelectedGraphemeExtent));
}
return startHandleRect?.height ?? renderObject.preferredLineHeight;
}
double _getEndGlyphHeight() {
final InlineSpan span = renderObject.text!;
final String prevText = span.toPlainText();
final String currText = selectionDelegate.textEditingValue.text;
final int lastSelectedGraphemeExtent;
Rect? endHandleRect;
// See the explanation in _getStartGlyphHeight.
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
final String selectedGraphemes = _selection.textInside(currText);
lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
endHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.end - lastSelectedGraphemeExtent, end: _selection.end));
}
return endHandleRect?.height ?? renderObject.preferredLineHeight;
}
late Offset _dragEndPosition;
void _handleSelectionEndHandleDragStart(DragStartDetails details) {
final Size handleSize = selectionControls!.getHandleSize(
renderObject.preferredLineHeight,
);
_dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height);
}
void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
_dragEndPosition += details.delta;
final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition);
if (_selection.isCollapsed) {
_handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: true);
return;
}
final TextSelection newSelection = TextSelection(
baseOffset: _selection.baseOffset,
extentOffset: position.offset,
);
if (newSelection.baseOffset >= newSelection.extentOffset)
return; // don't allow order swapping.
_handleSelectionHandleChanged(newSelection, isEnd: true);
}
late Offset _dragStartPosition;
void _handleSelectionStartHandleDragStart(DragStartDetails details) {
final Size handleSize = selectionControls!.getHandleSize(
renderObject.preferredLineHeight,
);
_dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height);
}
void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
_dragStartPosition += details.delta;
final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition);
if (_selection.isCollapsed) {
_handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: false);
return;
}
final TextSelection newSelection = TextSelection(
baseOffset: position.offset,
extentOffset: _selection.extentOffset,
);
if (newSelection.baseOffset >= newSelection.extentOffset)
return; // don't allow order swapping.
_handleSelectionHandleChanged(newSelection, isEnd: false);
}
Widget _buildToolbar(BuildContext context) {
if (selectionControls == null)
return Container();
// Find the horizontal midpoint, just above the selected text.
final List<TextSelectionPoint> endpoints =
renderObject.getEndpointsForSelection(_selection);
final RenderBox renderBox = this.context.findRenderObject()! as RenderBox;
final Rect editingRegion = Rect.fromPoints(
renderObject.localToGlobal(Offset.zero),
renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)),
renderBox.localToGlobal(Offset.zero),
renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)),
);
final bool isMultiline = endpoints.last.point.dy - endpoints.first.point.dy >
renderObject.preferredLineHeight / 2;
final bool isMultiline = selectionEndPoints.last.point.dy - selectionEndPoints.first.point.dy >
lineHeightAtEnd / 2;
// If the selected text spans more than 1 line, horizontally center the toolbar.
// Derived from both iOS and Android.
final double midX = isMultiline
? editingRegion.width / 2
: (endpoints.first.point.dx + endpoints.last.point.dx) / 2;
: (selectionEndPoints.first.point.dx + selectionEndPoints.last.point.dx) / 2;
final Offset midpoint = Offset(
midX,
// The y-coordinate won't be made use of most likely.
endpoints[0].point.dy - renderObject.preferredLineHeight,
selectionEndPoints.first.point.dy - lineHeightAtStart,
);
return Directionality(
......@@ -697,12 +978,12 @@ class TextSelectionOverlay {
return selectionControls!.buildToolbar(
context,
editingRegion,
renderObject.preferredLineHeight,
lineHeightAtStart,
midpoint,
endpoints,
selectionEndPoints,
selectionDelegate,
clipboardStatus!,
renderObject.lastSecondaryTapDownPosition,
toolbarLocation,
);
},
),
......@@ -710,32 +991,6 @@ class TextSelectionOverlay {
),
);
}
void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) {
final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base;
selectionDelegate.userUpdateTextEditingValue(
_value.copyWith(selection: newSelection),
SelectionChangedCause.drag,
);
selectionDelegate.bringIntoView(textPosition);
}
TextSelectionHandleType _chooseType(
TextDirection textDirection,
TextSelectionHandleType ltrType,
TextSelectionHandleType rtlType,
) {
if (_selection.isCollapsed)
return TextSelectionHandleType.collapsed;
assert(textDirection != null);
switch (textDirection) {
case TextDirection.ltr:
return ltrType;
case TextDirection.rtl:
return rtlType;
}
}
}
/// This widget represents a single draggable selection handle.
......@@ -748,8 +1003,9 @@ class _SelectionHandleOverlay extends StatefulWidget {
this.onSelectionHandleTapped,
this.onSelectionHandleDragStart,
this.onSelectionHandleDragUpdate,
this.onSelectionHandleDragEnd,
required this.selectionControls,
required this.visibility,
this.visibility,
required this.preferredLineHeight,
this.dragStartBehavior = DragStartBehavior.start,
}) : super(key: key);
......@@ -758,8 +1014,9 @@ class _SelectionHandleOverlay extends StatefulWidget {
final VoidCallback? onSelectionHandleTapped;
final ValueChanged<DragStartDetails>? onSelectionHandleDragStart;
final ValueChanged<DragUpdateDetails>? onSelectionHandleDragUpdate;
final ValueChanged<DragEndDetails>? onSelectionHandleDragEnd;
final TextSelectionControls selectionControls;
final ValueListenable<bool> visibility;
final ValueListenable<bool>? visibility;
final double preferredLineHeight;
final TextSelectionHandleType type;
final DragStartBehavior dragStartBehavior;
......@@ -778,14 +1035,14 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S
void initState() {
super.initState();
_controller = AnimationController(duration: TextSelectionOverlay.fadeDuration, vsync: this);
_controller = AnimationController(duration: SelectionOverlay.fadeDuration, vsync: this);
_handleVisibilityChanged();
widget.visibility.addListener(_handleVisibilityChanged);
widget.visibility?.addListener(_handleVisibilityChanged);
}
void _handleVisibilityChanged() {
if (widget.visibility.value) {
if (widget.visibility?.value != false) {
_controller.forward();
} else {
_controller.reverse();
......@@ -795,14 +1052,14 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S
@override
void didUpdateWidget(_SelectionHandleOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.visibility.removeListener(_handleVisibilityChanged);
oldWidget.visibility?.removeListener(_handleVisibilityChanged);
_handleVisibilityChanged();
widget.visibility.addListener(_handleVisibilityChanged);
widget.visibility?.addListener(_handleVisibilityChanged);
}
@override
void dispose() {
widget.visibility.removeListener(_handleVisibilityChanged);
widget.visibility?.removeListener(_handleVisibilityChanged);
_controller.dispose();
super.dispose();
}
......@@ -850,6 +1107,7 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S
dragStartBehavior: widget.dragStartBehavior,
onPanStart: widget.onSelectionHandleDragStart,
onPanUpdate: widget.onSelectionHandleDragUpdate,
onPanEnd: widget.onSelectionHandleDragEnd,
child: Padding(
padding: EdgeInsets.only(
left: padding.left,
......
......@@ -1326,7 +1326,7 @@ void main() {
// Move the gesture very slightly
await gesture.moveBy(const Offset(1.0, 1.0));
await tester.pump(TextSelectionOverlay.fadeDuration * 0.5);
await tester.pump(SelectionOverlay.fadeDuration * 0.5);
handle = tester.widget(fadeFinder.at(0));
// The handle should still be fully opaque.
......
......@@ -4759,14 +4759,14 @@ void main() {
if (expectedRightVisibleBefore)
expect(right.opacity.value, equals(1.0));
await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2);
await tester.pump(SelectionOverlay.fadeDuration ~/ 2);
if (expectedLeftVisible != expectedLeftVisibleBefore)
expect(left.opacity.value, equals(0.5));
if (expectedRightVisible != expectedRightVisibleBefore)
expect(right.opacity.value, equals(0.5));
await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2);
await tester.pump(SelectionOverlay.fadeDuration ~/ 2);
if (expectedLeftVisible)
expect(left.opacity.value, equals(1.0));
......@@ -7462,14 +7462,14 @@ void main() {
if (expectedRightVisibleBefore)
expect(right.opacity.value, equals(1.0));
await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2);
await tester.pump(SelectionOverlay.fadeDuration ~/ 2);
if (expectedLeftVisible != expectedLeftVisibleBefore)
expect(left.opacity.value, equals(0.5));
if (expectedRightVisible != expectedRightVisibleBefore)
expect(right.opacity.value, equals(0.5));
await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2);
await tester.pump(SelectionOverlay.fadeDuration ~/ 2);
if (expectedLeftVisible)
expect(left.opacity.value, equals(1.0));
......
......@@ -568,7 +568,7 @@ void main() {
// Move the gesture very slightly
await gesture.moveBy(const Offset(1.0, 1.0));
await tester.pump(TextSelectionOverlay.fadeDuration * 0.5);
await tester.pump(SelectionOverlay.fadeDuration * 0.5);
handle = tester.widget(fadeFinder.at(0));
// The handle should still be fully opaque.
......
......@@ -723,6 +723,268 @@ void main() {
expect(hitRect.size.height, lessThan(textFieldRect.size.height));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
group('SelectionOverlay', () {
Future<SelectionOverlay> pumpApp(WidgetTester tester, {
ValueChanged<DragStartDetails>? onStartDragStart,
ValueChanged<DragUpdateDetails>? onStartDragUpdate,
ValueChanged<DragEndDetails>? onStartDragEnd,
ValueChanged<DragStartDetails>? onEndDragStart,
ValueChanged<DragUpdateDetails>? onEndDragUpdate,
ValueChanged<DragEndDetails>? onEndDragEnd,
VoidCallback? onSelectionHandleTapped,
TextSelectionControls? selectionControls,
}) async {
final UniqueKey column = UniqueKey();
final LayerLink startHandleLayerLink = LayerLink();
final LayerLink endHandleLayerLink = LayerLink();
final LayerLink toolbarLayerLink = LayerLink();
await tester.pumpWidget(MaterialApp(
home: Column(
key: column,
children: <Widget>[
CompositedTransformTarget(
link: startHandleLayerLink,
child: const Text('start handle'),
),
CompositedTransformTarget(
link: endHandleLayerLink,
child: const Text('end handle'),
),
CompositedTransformTarget(
link: toolbarLayerLink,
child: const Text('toolbar'),
),
],
),
));
return SelectionOverlay(
context: tester.element(find.byKey(column)),
onSelectionHandleTapped: onSelectionHandleTapped,
startHandleType: TextSelectionHandleType.collapsed,
startHandleLayerLink: startHandleLayerLink,
lineHeightAtStart: 0.0,
onStartHandleDragStart: onStartDragStart,
onStartHandleDragUpdate: onStartDragUpdate,
onStartHandleDragEnd: onStartDragEnd,
endHandleType: TextSelectionHandleType.collapsed,
endHandleLayerLink: endHandleLayerLink,
lineHeightAtEnd: 0.0,
onEndHandleDragStart: onEndDragStart,
onEndHandleDragUpdate: onEndDragUpdate,
onEndHandleDragEnd: onEndDragEnd,
clipboardStatus: FakeClipboardStatusNotifier(),
selectionDelegate: FakeTextSelectionDelegate(),
selectionControls: selectionControls,
selectionEndPoints: const <TextSelectionPoint>[],
toolbarLayerLink: toolbarLayerLink,
);
}
testWidgets('can show and hide handles', (WidgetTester tester) async {
final TextSelectionControlsSpy spy = TextSelectionControlsSpy();
final SelectionOverlay selectionOverlay = await pumpApp(
tester,
selectionControls: spy,
);
selectionOverlay
..startHandleType = TextSelectionHandleType.left
..endHandleType = TextSelectionHandleType.right
..selectionEndPoints = const <TextSelectionPoint>[
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
];
selectionOverlay.showHandles();
await tester.pump();
expect(find.byKey(spy.leftHandleKey), findsOneWidget);
expect(find.byKey(spy.rightHandleKey), findsOneWidget);
selectionOverlay.hideHandles();
await tester.pump();
expect(find.byKey(spy.leftHandleKey), findsNothing);
expect(find.byKey(spy.rightHandleKey), findsNothing);
selectionOverlay.showToolbar();
await tester.pump();
expect(find.byKey(spy.toolBarKey), findsOneWidget);
selectionOverlay.hideToolbar();
await tester.pump();
expect(find.byKey(spy.toolBarKey), findsNothing);
selectionOverlay.showHandles();
selectionOverlay.showToolbar();
await tester.pump();
expect(find.byKey(spy.leftHandleKey), findsOneWidget);
expect(find.byKey(spy.rightHandleKey), findsOneWidget);
expect(find.byKey(spy.toolBarKey), findsOneWidget);
selectionOverlay.hide();
await tester.pump();
expect(find.byKey(spy.leftHandleKey), findsNothing);
expect(find.byKey(spy.rightHandleKey), findsNothing);
expect(find.byKey(spy.toolBarKey), findsNothing);
});
testWidgets('only paints one collapsed handle', (WidgetTester tester) async {
final TextSelectionControlsSpy spy = TextSelectionControlsSpy();
final SelectionOverlay selectionOverlay = await pumpApp(
tester,
selectionControls: spy,
);
selectionOverlay
..startHandleType = TextSelectionHandleType.collapsed
..endHandleType = TextSelectionHandleType.collapsed
..selectionEndPoints = const <TextSelectionPoint>[
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
];
selectionOverlay.showHandles();
await tester.pump();
expect(find.byKey(spy.leftHandleKey), findsNothing);
expect(find.byKey(spy.rightHandleKey), findsNothing);
expect(find.byKey(spy.collapsedHandleKey), findsOneWidget);
});
testWidgets('can change handle parameter', (WidgetTester tester) async {
final TextSelectionControlsSpy spy = TextSelectionControlsSpy();
final SelectionOverlay selectionOverlay = await pumpApp(
tester,
selectionControls: spy,
);
selectionOverlay
..startHandleType = TextSelectionHandleType.left
..lineHeightAtStart = 10.0
..endHandleType = TextSelectionHandleType.right
..lineHeightAtEnd = 11.0
..selectionEndPoints = const <TextSelectionPoint>[
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
];
selectionOverlay.showHandles();
await tester.pump();
Text leftHandle = tester.widget(find.byKey(spy.leftHandleKey)) as Text;
Text rightHandle = tester.widget(find.byKey(spy.rightHandleKey)) as Text;
expect(leftHandle.data, 'height 10');
expect(rightHandle.data, 'height 11');
selectionOverlay
..startHandleType = TextSelectionHandleType.right
..lineHeightAtStart = 12.0
..endHandleType = TextSelectionHandleType.left
..lineHeightAtEnd = 13.0;
await tester.pump();
leftHandle = tester.widget(find.byKey(spy.leftHandleKey)) as Text;
rightHandle = tester.widget(find.byKey(spy.rightHandleKey)) as Text;
expect(leftHandle.data, 'height 13');
expect(rightHandle.data, 'height 12');
});
testWidgets('can trigger selection handle onTap', (WidgetTester tester) async {
bool selectionHandleTapped = false;
void handleTapped() => selectionHandleTapped = true;
final TextSelectionControlsSpy spy = TextSelectionControlsSpy();
final SelectionOverlay selectionOverlay = await pumpApp(
tester,
onSelectionHandleTapped: handleTapped,
selectionControls: spy,
);
selectionOverlay
..startHandleType = TextSelectionHandleType.left
..lineHeightAtStart = 10.0
..endHandleType = TextSelectionHandleType.right
..lineHeightAtEnd = 11.0
..selectionEndPoints = const <TextSelectionPoint>[
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
];
selectionOverlay.showHandles();
await tester.pump();
expect(find.byKey(spy.leftHandleKey), findsOneWidget);
expect(find.byKey(spy.rightHandleKey), findsOneWidget);
expect(selectionHandleTapped, isFalse);
await tester.tap(find.byKey(spy.leftHandleKey));
expect(selectionHandleTapped, isTrue);
selectionHandleTapped = false;
await tester.tap(find.byKey(spy.rightHandleKey));
expect(selectionHandleTapped, isTrue);
});
testWidgets('can trigger selection handle drag', (WidgetTester tester) async {
DragStartDetails? startDragStartDetails;
DragUpdateDetails? startDragUpdateDetails;
DragEndDetails? startDragEndDetails;
DragStartDetails? endDragStartDetails;
DragUpdateDetails? endDragUpdateDetails;
DragEndDetails? endDragEndDetails;
void startDragStart(DragStartDetails details) => startDragStartDetails = details;
void startDragUpdate(DragUpdateDetails details) => startDragUpdateDetails = details;
void startDragEnd(DragEndDetails details) => startDragEndDetails = details;
void endDragStart(DragStartDetails details) => endDragStartDetails = details;
void endDragUpdate(DragUpdateDetails details) => endDragUpdateDetails = details;
void endDragEnd(DragEndDetails details) => endDragEndDetails = details;
final TextSelectionControlsSpy spy = TextSelectionControlsSpy();
final SelectionOverlay selectionOverlay = await pumpApp(
tester,
onStartDragStart: startDragStart,
onStartDragUpdate: startDragUpdate,
onStartDragEnd: startDragEnd,
onEndDragStart: endDragStart,
onEndDragUpdate: endDragUpdate,
onEndDragEnd: endDragEnd,
selectionControls: spy,
);
selectionOverlay
..startHandleType = TextSelectionHandleType.left
..lineHeightAtStart = 10.0
..endHandleType = TextSelectionHandleType.right
..lineHeightAtEnd = 11.0
..selectionEndPoints = const <TextSelectionPoint>[
TextSelectionPoint(Offset(10, 10), TextDirection.ltr),
TextSelectionPoint(Offset(20, 20), TextDirection.ltr),
];
selectionOverlay.showHandles();
await tester.pump();
expect(find.byKey(spy.leftHandleKey), findsOneWidget);
expect(find.byKey(spy.rightHandleKey), findsOneWidget);
expect(startDragStartDetails, isNull);
expect(startDragUpdateDetails, isNull);
expect(startDragEndDetails, isNull);
expect(endDragStartDetails, isNull);
expect(endDragUpdateDetails, isNull);
expect(endDragEndDetails, isNull);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(spy.leftHandleKey)));
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 200));
expect(startDragStartDetails!.globalPosition, tester.getCenter(find.byKey(spy.leftHandleKey)));
const Offset newLocation = Offset(20, 20);
await gesture.moveTo(newLocation);
await tester.pump(const Duration(milliseconds: 20));
expect(startDragUpdateDetails!.globalPosition, newLocation);
await gesture.up();
await tester.pump(const Duration(milliseconds: 20));
expect(startDragEndDetails, isNotNull);
final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byKey(spy.rightHandleKey)));
addTearDown(gesture2.removePointer);
await tester.pump(const Duration(milliseconds: 200));
expect(endDragStartDetails!.globalPosition, tester.getCenter(find.byKey(spy.rightHandleKey)));
await gesture2.moveTo(newLocation);
await tester.pump(const Duration(milliseconds: 20));
expect(endDragUpdateDetails!.globalPosition, newLocation);
await gesture2.up();
await tester.pump(const Duration(milliseconds: 20));
expect(endDragEndDetails, isNotNull);
});
});
group('ClipboardStatusNotifier', () {
group('when Clipboard fails', () {
setUp(() {
......@@ -939,6 +1201,49 @@ class CustomTextSelectionControls extends TextSelectionControls {
}
}
class TextSelectionControlsSpy extends TextSelectionControls {
UniqueKey leftHandleKey = UniqueKey();
UniqueKey rightHandleKey = UniqueKey();
UniqueKey collapsedHandleKey = UniqueKey();
UniqueKey toolBarKey = UniqueKey();
@override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) {
switch (type) {
case TextSelectionHandleType.left:
return ElevatedButton(onPressed: onTap, child: Text('height ${textLineHeight.toInt()}', key: leftHandleKey));
case TextSelectionHandleType.right:
return ElevatedButton(onPressed: onTap, child: Text('height ${textLineHeight.toInt()}', key: rightHandleKey));
case TextSelectionHandleType.collapsed:
return ElevatedButton(onPressed: onTap, child: Text('height ${textLineHeight.toInt()}', key: collapsedHandleKey));
}
}
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return Text('dummy', key: toolBarKey);
}
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
return Offset.zero;
}
@override
Size getHandleSize(double textLineHeight) {
return Size(textLineHeight, textLineHeight);
}
}
class FakeClipboardStatusNotifier extends ClipboardStatusNotifier {
FakeClipboardStatusNotifier() : super(value: ClipboardStatus.unknown);
......
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