blob: 2b2f049e71ea67bede109ceaf58ea18e4740f901 [file] [log] [blame]
// 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';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:vector_math/vector_math_64.dart';
import 'actions.dart';
import 'basic.dart';
import 'debug.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'magnifier.dart';
import 'media_query.dart';
import 'overlay.dart';
import 'platform_selectable_region_context_menu.dart';
import 'selection_container.dart';
import 'text_editing_intents.dart';
import 'text_selection.dart';
// Examples can assume:
// FocusNode _focusNode = FocusNode();
// late GlobalKey key;
const Set<PointerDeviceKind> _kLongPressSelectionDevices = <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
};
/// A widget that introduces an area for user selections.
///
/// Flutter widgets are not selectable by default. Wrapping a widget subtree
/// with a [SelectableRegion] widget enables selection within that subtree (for
/// example, [Text] widgets automatically look for selectable regions to enable
/// selection). The wrapped subtree can be selected by users using mouse or
/// touch gestures, e.g. users can select widgets by holding the mouse
/// left-click and dragging across widgets, or they can use long press gestures
/// to select words on touch devices.
///
/// A [SelectableRegion] widget requires configuration; in particular specific
/// [selectionControls] must be provided.
///
/// The [SelectionArea] widget from the [material] library configures a
/// [SelectableRegion] in a platform-specific manner (e.g. using a Material
/// toolbar on Android, a Cupertino toolbar on iOS), and it may therefore be
/// simpler to use that widget rather than using [SelectableRegion] directly.
///
/// ## An overview of the selection system.
///
/// Every [Selectable] under the [SelectableRegion] can be selected. They form a
/// selection tree structure to handle the selection.
///
/// The [SelectableRegion] is a wrapper over [SelectionContainer]. It listens to
/// user gestures and sends corresponding [SelectionEvent]s to the
/// [SelectionContainer] it creates.
///
/// A [SelectionContainer] is a single [Selectable] that handles
/// [SelectionEvent]s on behalf of child [Selectable]s in the subtree. It
/// creates a [SelectionRegistrarScope] with its [SelectionContainer.delegate]
/// to collect child [Selectable]s and sends the [SelectionEvent]s it receives
/// from the parent [SelectionRegistrar] to the appropriate child [Selectable]s.
/// It creates an abstraction for the parent [SelectionRegistrar] as if it is
/// interacting with a single [Selectable].
///
/// The [SelectionContainer] created by [SelectableRegion] is the root node of a
/// selection tree. Each non-leaf node in the tree is a [SelectionContainer],
/// and the leaf node is a leaf widget whose render object implements
/// [Selectable]. They are connected through [SelectionRegistrarScope]s created
/// by [SelectionContainer]s.
///
/// Both [SelectionContainer]s and the leaf [Selectable]s need to register
/// themselves to the [SelectionRegistrar] from the
/// [SelectionContainer.maybeOf] if they want to participate in the
/// selection.
///
/// An example selection tree will look like:
///
/// {@tool snippet}
///
/// ```dart
/// MaterialApp(
/// home: SelectableRegion(
/// selectionControls: materialTextSelectionControls,
/// focusNode: _focusNode, // initialized to FocusNode()
/// child: Scaffold(
/// appBar: AppBar(title: const Text('Flutter Code Sample')),
/// body: ListView(
/// children: const <Widget>[
/// Text('Item 0', style: TextStyle(fontSize: 50.0)),
/// Text('Item 1', style: TextStyle(fontSize: 50.0)),
/// ],
/// ),
/// ),
/// ),
/// )
/// ```
/// {@end-tool}
///
///
/// SelectionContainer
/// (SelectableRegion)
/// / \
/// / \
/// / \
/// Selectable \
/// ("Flutter Code Sample") \
/// \
/// SelectionContainer
/// (ListView)
/// / \
/// / \
/// / \
/// Selectable Selectable
/// ("Item 0") ("Item 1")
///
///
/// ## Making a widget selectable
///
/// Some leaf widgets, such as [Text], have all of the selection logic wired up
/// automatically and can be selected as long as they are under a
/// [SelectableRegion].
///
/// To make a custom selectable widget, its render object needs to mix in
/// [Selectable] and implement the required APIs to handle [SelectionEvent]s
/// as well as paint appropriate selection highlights.
///
/// The render object also needs to register itself to a [SelectionRegistrar].
/// For the most cases, one can use [SelectionRegistrant] to auto-register
/// itself with the register returned from [SelectionContainer.maybeOf] as
/// seen in the example below.
///
/// {@tool dartpad}
/// This sample demonstrates how to create an adapter widget that makes any
/// child widget selectable.
///
/// ** See code in examples/api/lib/material/selectable_region/selectable_region.0.dart **
/// {@end-tool}
///
/// ## Complex layout
///
/// By default, the screen order is used as the selection order. If a group of
/// [Selectable]s needs to select differently, consider wrapping them with a
/// [SelectionContainer] to customize its selection behavior.
///
/// {@tool dartpad}
/// This sample demonstrates how to create a [SelectionContainer] that only
/// allows selecting everything or nothing with no partial selection.
///
/// ** See code in examples/api/lib/material/selection_container/selection_container.0.dart **
/// {@end-tool}
///
/// In the case where a group of widgets should be excluded from selection under
/// a [SelectableRegion], consider wrapping that group of widgets using
/// [SelectionContainer.disabled].
///
/// {@tool dartpad}
/// This sample demonstrates how to disable selection for a Text in a Column.
///
/// ** See code in examples/api/lib/material/selection_container/selection_container_disabled.0.dart **
/// {@end-tool}
///
/// To create a separate selection system from its parent selection area,
/// wrap part of the subtree with another [SelectableRegion]. The selection of the
/// child selection area can not extend past its subtree, and the selection of
/// the parent selection area can not extend inside the child selection area.
///
/// ## Tests
///
/// In a test, a region can be selected either by faking drag events (e.g. using
/// [WidgetTester.dragFrom]) or by sending intents to a widget inside the region
/// that has been given a [GlobalKey], e.g.:
///
/// ```dart
/// Actions.invoke(key.currentContext!, const SelectAllTextIntent(SelectionChangedCause.keyboard));
/// ```
///
/// See also:
/// * [SelectionArea], which creates a [SelectableRegion] with
/// platform-adaptive selection controls.
/// * [SelectionHandler], which contains APIs to handle selection events from the
/// [SelectableRegion].
/// * [Selectable], which provides API to participate in the selection system.
/// * [SelectionRegistrar], which [Selectable] needs to subscribe to receive
/// selection events.
/// * [SelectionContainer], which collects selectable widgets in the subtree
/// and provides api to dispatch selection event to the collected widget.
class SelectableRegion extends StatefulWidget {
/// Create a new [SelectableRegion] widget.
///
/// The [selectionControls] are used for building the selection handles and
/// toolbar for mobile devices.
const SelectableRegion({
super.key,
required this.focusNode,
required this.selectionControls,
required this.child,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
this.onSelectionChanged,
});
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// By default, [SelectableRegion]'s [TextMagnifierConfiguration] is disabled.
///
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details}
final TextMagnifierConfiguration magnifierConfiguration;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode;
/// The child widget this selection area applies to.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
/// The delegate to build the selection handles and toolbar for mobile
/// devices.
///
/// The [emptyTextSelectionControls] global variable provides a default
/// [TextSelectionControls] implementation with no controls.
final TextSelectionControls selectionControls;
/// Called when the selected content changes.
final ValueChanged<SelectedContent?>? onSelectionChanged;
@override
State<StatefulWidget> createState() => _SelectableRegionState();
}
class _SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar {
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
};
final Map<Type, GestureRecognizerFactory> _gestureRecognizers = <Type, GestureRecognizerFactory>{};
SelectionOverlay? _selectionOverlay;
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
final LayerLink _toolbarLayerLink = LayerLink();
final _SelectableRegionContainerDelegate _selectionDelegate = _SelectableRegionContainerDelegate();
// there should only ever be one selectable, which is the SelectionContainer.
Selectable? _selectable;
bool get _hasSelectionOverlayGeometry => _selectionDelegate.value.startSelectionPoint != null
|| _selectionDelegate.value.endSelectionPoint != null;
Orientation? _lastOrientation;
SelectedContent? _lastSelectedContent;
@override
void initState() {
super.initState();
widget.focusNode.addListener(_handleFocusChanged);
_initMouseGestureRecognizer();
_initTouchGestureRecognizer();
// Taps and right clicks.
_gestureRecognizers[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance.onTap = _clearSelection;
instance.onSecondaryTapDown = _handleRightClickDown;
},
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
return;
}
// Hide the text selection toolbar on mobile when orientation changes.
final Orientation orientation = MediaQuery.of(context).orientation;
if (_lastOrientation == null) {
_lastOrientation = orientation;
return;
}
if (orientation != _lastOrientation) {
_lastOrientation = orientation;
hideToolbar(defaultTargetPlatform == TargetPlatform.android);
}
}
@override
void didUpdateWidget(SelectableRegion oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged);
widget.focusNode.addListener(_handleFocusChanged);
if (widget.focusNode.hasFocus != oldWidget.focusNode.hasFocus) {
_handleFocusChanged();
}
}
}
Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
return Action<T>.overridable(context: context, defaultAction: defaultAction);
}
void _handleFocusChanged() {
if (!widget.focusNode.hasFocus) {
if (kIsWeb) {
PlatformSelectableRegionContextMenu.detach(_selectionDelegate);
}
_clearSelection();
}
if (kIsWeb) {
PlatformSelectableRegionContextMenu.attach(_selectionDelegate);
}
}
void _updateSelectionStatus() {
final TextSelection selection;
final SelectionGeometry geometry = _selectionDelegate.value;
switch(geometry.status) {
case SelectionStatus.uncollapsed:
case SelectionStatus.collapsed:
selection = const TextSelection(baseOffset: 0, extentOffset: 1);
break;
case SelectionStatus.none:
selection = const TextSelection.collapsed(offset: 1);
break;
}
textEditingValue = TextEditingValue(text: '__', selection: selection);
if (_hasSelectionOverlayGeometry) {
_updateSelectionOverlay();
} else {
_selectionOverlay?.dispose();
_selectionOverlay = null;
}
}
// gestures.
void _initMouseGestureRecognizer() {
_gestureRecognizers[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(debugOwner:this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }),
(PanGestureRecognizer instance) {
instance
..onDown = _startNewMouseSelectionGesture
..onStart = _handleMouseDragStart
..onUpdate = _handleMouseDragUpdate
..onEnd = _handleMouseDragEnd
..onCancel = _clearSelection
..dragStartBehavior = DragStartBehavior.down;
},
);
}
void _initTouchGestureRecognizer() {
_gestureRecognizers[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(debugOwner: this, supportedDevices: _kLongPressSelectionDevices),
(LongPressGestureRecognizer instance) {
instance
..onLongPressStart = _handleTouchLongPressStart
..onLongPressMoveUpdate = _handleTouchLongPressMoveUpdate
..onLongPressEnd = _handleTouchLongPressEnd
..onLongPressCancel = _clearSelection;
},
);
}
void _startNewMouseSelectionGesture(DragDownDetails details) {
widget.focusNode.requestFocus();
hideToolbar();
_clearSelection();
}
void _handleMouseDragStart(DragStartDetails details) {
_selectStartTo(offset: details.globalPosition);
}
void _handleMouseDragUpdate(DragUpdateDetails details) {
_selectEndTo(offset: details.globalPosition, continuous: true);
}
void _updateSelectedContentIfNeeded() {
if (_lastSelectedContent?.plainText != _selectable?.getSelectedContent()?.plainText) {
_lastSelectedContent = _selectable?.getSelectedContent();
widget.onSelectionChanged?.call(_lastSelectedContent);
}
}
void _handleMouseDragEnd(DragEndDetails details) {
_finalizeSelection();
_updateSelectedContentIfNeeded();
}
void _handleTouchLongPressStart(LongPressStartDetails details) {
widget.focusNode.requestFocus();
_selectWordAt(offset: details.globalPosition);
_showToolbar();
_showHandles();
_updateSelectedContentIfNeeded();
}
void _handleTouchLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
_selectEndTo(offset: details.globalPosition);
}
void _handleTouchLongPressEnd(LongPressEndDetails details) {
_finalizeSelection();
_updateSelectedContentIfNeeded();
}
void _handleRightClickDown(TapDownDetails details) {
widget.focusNode.requestFocus();
_selectWordAt(offset: details.globalPosition);
_showHandles();
_showToolbar(location: details.globalPosition);
_updateSelectedContentIfNeeded();
}
// Selection update helper methods.
Offset? _selectionEndPosition;
bool get _userDraggingSelectionEnd => _selectionEndPosition != null;
bool _scheduledSelectionEndEdgeUpdate = false;
/// Sends end [SelectionEdgeUpdateEvent] to the selectable subtree.
///
/// If the selectable subtree returns a [SelectionResult.pending], this method
/// continues to send [SelectionEdgeUpdateEvent]s every frame until the result
/// is not pending or users end their gestures.
void _triggerSelectionEndEdgeUpdate() {
// This method can be called when the drag is not in progress. This can
// happen if the the child scrollable returns SelectionResult.pending, and
// the selection area scheduled a selection update for the next frame, but
// the drag is lifted before the scheduled selection update is run.
if (_scheduledSelectionEndEdgeUpdate || !_userDraggingSelectionEnd) {
return;
}
if (_selectable?.dispatchSelectionEvent(
SelectionEdgeUpdateEvent.forEnd(globalPosition: _selectionEndPosition!)) == SelectionResult.pending) {
_scheduledSelectionEndEdgeUpdate = true;
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!_scheduledSelectionEndEdgeUpdate) {
return;
}
_scheduledSelectionEndEdgeUpdate = false;
_triggerSelectionEndEdgeUpdate();
});
return;
}
}
void _onAnyDragEnd(DragEndDetails details) {
_selectionOverlay!.hideMagnifier(shouldShowToolbar: true);
_stopSelectionEndEdgeUpdate();
_updateSelectedContentIfNeeded();
}
void _stopSelectionEndEdgeUpdate() {
_scheduledSelectionEndEdgeUpdate = false;
_selectionEndPosition = null;
}
Offset? _selectionStartPosition;
bool get _userDraggingSelectionStart => _selectionStartPosition != null;
bool _scheduledSelectionStartEdgeUpdate = false;
/// Sends start [SelectionEdgeUpdateEvent] to the selectable subtree.
///
/// If the selectable subtree returns a [SelectionResult.pending], this method
/// continues to send [SelectionEdgeUpdateEvent]s every frame until the result
/// is not pending or users end their gestures.
void _triggerSelectionStartEdgeUpdate() {
// This method can be called when the drag is not in progress. This can
// happen if the the child scrollable returns SelectionResult.pending, and
// the selection area scheduled a selection update for the next frame, but
// the drag is lifted before the scheduled selection update is run.
if (_scheduledSelectionStartEdgeUpdate || !_userDraggingSelectionStart) {
return;
}
if (_selectable?.dispatchSelectionEvent(
SelectionEdgeUpdateEvent.forStart(globalPosition: _selectionStartPosition!)) == SelectionResult.pending) {
_scheduledSelectionStartEdgeUpdate = true;
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!_scheduledSelectionStartEdgeUpdate) {
return;
}
_scheduledSelectionStartEdgeUpdate = false;
_triggerSelectionStartEdgeUpdate();
});
return;
}
}
void _stopSelectionStartEdgeUpdate() {
_scheduledSelectionStartEdgeUpdate = false;
_selectionEndPosition = null;
}
// SelectionOverlay helper methods.
late Offset _selectionStartHandleDragPosition;
late Offset _selectionEndHandleDragPosition;
late List<TextSelectionPoint> points;
void _handleSelectionStartHandleDragStart(DragStartDetails details) {
assert(_selectionDelegate.value.startSelectionPoint != null);
final Offset localPosition = _selectionDelegate.value.startSelectionPoint!.localPosition;
final Matrix4 globalTransform = _selectable!.getTransformTo(null);
_selectionStartHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition);
_selectionOverlay!.showMagnifier(_buildInfoForMagnifier(
details.globalPosition,
_selectionDelegate.value.startSelectionPoint!,
));
}
void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
_selectionStartHandleDragPosition = _selectionStartHandleDragPosition + details.delta;
// The value corresponds to the paint origin of the selection handle.
// Offset it to the center of the line to make it feel more natural.
_selectionStartPosition = _selectionStartHandleDragPosition - Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2);
_triggerSelectionStartEdgeUpdate();
_selectionOverlay!.updateMagnifier(_buildInfoForMagnifier(
details.globalPosition,
_selectionDelegate.value.startSelectionPoint!,
));
}
void _handleSelectionEndHandleDragStart(DragStartDetails details) {
assert(_selectionDelegate.value.endSelectionPoint != null);
final Offset localPosition = _selectionDelegate.value.endSelectionPoint!.localPosition;
final Matrix4 globalTransform = _selectable!.getTransformTo(null);
_selectionEndHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition);
_selectionOverlay!.showMagnifier(_buildInfoForMagnifier(
details.globalPosition,
_selectionDelegate.value.endSelectionPoint!,
));
}
void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
_selectionEndHandleDragPosition = _selectionEndHandleDragPosition + details.delta;
// The value corresponds to the paint origin of the selection handle.
// Offset it to the center of the line to make it feel more natural.
_selectionEndPosition = _selectionEndHandleDragPosition - Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2);
_triggerSelectionEndEdgeUpdate();
_selectionOverlay!.updateMagnifier(_buildInfoForMagnifier(
details.globalPosition,
_selectionDelegate.value.endSelectionPoint!,
));
}
MagnifierInfo _buildInfoForMagnifier(Offset globalGesturePosition, SelectionPoint selectionPoint) {
final Vector3 globalTransform = _selectable!.getTransformTo(null).getTranslation();
final Offset globalTransformAsOffset = Offset(globalTransform.x, globalTransform.y);
final Offset globalSelectionPointPosition = selectionPoint.localPosition + globalTransformAsOffset;
final Rect caretRect = Rect.fromLTWH(
globalSelectionPointPosition.dx,
globalSelectionPointPosition.dy - selectionPoint.lineHeight,
0,
selectionPoint.lineHeight
);
return MagnifierInfo(
globalGesturePosition: globalGesturePosition,
caretRect: caretRect,
fieldBounds: globalTransformAsOffset & _selectable!.size,
currentLineBoundaries: globalTransformAsOffset & _selectable!.size,
);
}
void _createSelectionOverlay() {
assert(_hasSelectionOverlayGeometry);
if (_selectionOverlay != null) {
return;
}
final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
if (startLocalPosition.dy > endLocalPosition.dy) {
points = <TextSelectionPoint>[
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
];
} else {
points = <TextSelectionPoint>[
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
];
}
_selectionOverlay = SelectionOverlay(
context: context,
debugRequiredFor: widget,
startHandleType: start?.handleType ?? TextSelectionHandleType.left,
lineHeightAtStart: start?.lineHeight ?? end!.lineHeight,
onStartHandleDragStart: _handleSelectionStartHandleDragStart,
onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
onStartHandleDragEnd: _onAnyDragEnd,
endHandleType: end?.handleType ?? TextSelectionHandleType.right,
lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight,
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
onEndHandleDragEnd: _onAnyDragEnd,
selectionEndpoints: points,
selectionControls: widget.selectionControls,
selectionDelegate: this,
clipboardStatus: null,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
toolbarLayerLink: _toolbarLayerLink,
magnifierConfiguration: widget.magnifierConfiguration
);
}
void _updateSelectionOverlay() {
if (_selectionOverlay == null) {
return;
}
assert(_hasSelectionOverlayGeometry);
final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
late List<TextSelectionPoint> points;
final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
if (startLocalPosition.dy > endLocalPosition.dy) {
points = <TextSelectionPoint>[
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
];
} else {
points = <TextSelectionPoint>[
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
];
}
_selectionOverlay!
..startHandleType = start?.handleType ?? TextSelectionHandleType.left
..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight
..endHandleType = end?.handleType ?? TextSelectionHandleType.right
..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight
..selectionEndpoints = points;
}
/// Shows the selection handles.
///
/// Returns true if the handles are shown, false if the handles can't be
/// shown.
bool _showHandles() {
if (_selectionOverlay != null) {
_selectionOverlay!.showHandles();
return true;
}
if (!_hasSelectionOverlayGeometry) {
return false;
}
_createSelectionOverlay();
_selectionOverlay!.showHandles();
return true;
}
/// Shows the text selection toolbar.
///
/// If the parameter `location` is set, the toolbar will be shown at the
/// location. Otherwise, the toolbar location will be calculated based on the
/// handles' locations. The `location` is in the coordinates system of the
/// [Overlay].
///
/// Returns true if the toolbar is shown, false if the toolbar can't be shown.
bool _showToolbar({Offset? location}) {
if (!_hasSelectionOverlayGeometry && _selectionOverlay == null) {
return false;
}
// Web is using native dom elements to enable clipboard functionality of the
// toolbar: copy, paste, select, cut. It might also provide additional
// functionality depending on the browser (such as translate). Due to this
// we should not show a Flutter toolbar for the editable text elements.
if (kIsWeb) {
return false;
}
if (_selectionOverlay == null) {
_createSelectionOverlay();
}
_selectionOverlay!.toolbarLocation = location;
_selectionOverlay!.showToolbar();
return true;
}
/// Sets or updates selection end edge to the `offset` location.
///
/// A selection always contains a select start edge and selection end edge.
/// They can be created by calling both [_selectStartTo] and [_selectEndTo], or
/// use other selection APIs, such as [_selectWordAt] or [selectAll].
///
/// This method sets or updates the selection end edge by sending
/// [SelectionEdgeUpdateEvent]s to the child [Selectable]s.
///
/// If `continuous` is set to true and the update causes scrolling, the
/// method will continue sending the same [SelectionEdgeUpdateEvent]s to the
/// child [Selectable]s every frame until the scrolling finishes or a
/// [_finalizeSelection] is called.
///
/// The `continuous` argument defaults to false.
///
/// The `offset` is in global coordinates.
///
/// See also:
/// * [_selectStartTo], which sets or updates selection start edge.
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location.
/// * [selectAll], which selects the entire content.
void _selectEndTo({required Offset offset, bool continuous = false}) {
if (!continuous) {
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: offset));
return;
}
if (_selectionEndPosition != offset) {
_selectionEndPosition = offset;
_triggerSelectionEndEdgeUpdate();
}
}
/// Sets or updates selection start edge to the `offset` location.
///
/// A selection always contains a select start edge and selection end edge.
/// They can be created by calling both [_selectStartTo] and [_selectEndTo], or
/// use other selection APIs, such as [_selectWordAt] or [selectAll].
///
/// This method sets or updates the selection start edge by sending
/// [SelectionEdgeUpdateEvent]s to the child [Selectable]s.
///
/// If `continuous` is set to true and the update causes scrolling, the
/// method will continue sending the same [SelectionEdgeUpdateEvent]s to the
/// child [Selectable]s every frame until the scrolling finishes or a
/// [_finalizeSelection] is called.
///
/// The `continuous` argument defaults to false.
///
/// The `offset` is in global coordinates.
///
/// See also:
/// * [_selectEndTo], which sets or updates selection end edge.
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location.
/// * [selectAll], which selects the entire content.
void _selectStartTo({required Offset offset, bool continuous = false}) {
if (!continuous) {
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: offset));
return;
}
if (_selectionStartPosition != offset) {
_selectionStartPosition = offset;
_triggerSelectionStartEdgeUpdate();
}
}
/// Selects a whole word at the `offset` location.
///
/// If the whole word is already in the current selection, selection won't
/// change. One call [_clearSelection] first if the selection needs to be
/// updated even if the word is already covered by the current selection.
///
/// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection
/// edges after calling this method.
///
/// See also:
/// * [_selectStartTo], which sets or updates selection start edge.
/// * [_selectEndTo], which sets or updates selection end edge.
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection.
/// * [selectAll], which selects the entire content.
void _selectWordAt({required Offset offset}) {
// There may be other selection ongoing.
_finalizeSelection();
_selectable?.dispatchSelectionEvent(SelectWordSelectionEvent(globalPosition: offset));
}
/// Stops any ongoing selection updates.
///
/// This method is different from [_clearSelection] that it does not remove
/// the current selection. It only stops the continuous updates.
///
/// A continuous update can happen as result of calling [_selectStartTo] or
/// [_selectEndTo] with `continuous` sets to true which causes a [Selectable]
/// to scroll. Calling this method will stop the update as well as the
/// scrolling.
void _finalizeSelection() {
_stopSelectionEndEdgeUpdate();
_stopSelectionStartEdgeUpdate();
}
/// Removes the ongoing selection.
void _clearSelection() {
_finalizeSelection();
_selectable?.dispatchSelectionEvent(const ClearSelectionEvent());
_updateSelectedContentIfNeeded();
}
Future<void> _copy() async {
final SelectedContent? data = _selectable?.getSelectedContent();
if (data == null) {
return;
}
await Clipboard.setData(ClipboardData(text: data.plainText));
}
// [TextSelectionDelegate] overrides.
@override
bool get cutEnabled => false;
@override
bool get pasteEnabled => false;
@override
void hideToolbar([bool hideHandles = true]) {
_selectionOverlay?.hideToolbar();
if (hideHandles) {
_selectionOverlay?.hideHandles();
}
}
@override
void selectAll([SelectionChangedCause? cause]) {
_clearSelection();
_selectable?.dispatchSelectionEvent(const SelectAllSelectionEvent());
if (cause == SelectionChangedCause.toolbar) {
_showToolbar();
_showHandles();
}
_updateSelectedContentIfNeeded();
}
@override
void copySelection(SelectionChangedCause cause) {
_copy();
_clearSelection();
}
// TODO(chunhtai): remove this workaround after decoupling text selection
// from text editing in TextSelectionDelegate.
@override
TextEditingValue textEditingValue = const TextEditingValue(text: '_');
@override
void bringIntoView(TextPosition position) {/* SelectableRegion must be in view at this point. */}
@override
void cutSelection(SelectionChangedCause cause) {
assert(false);
}
@override
void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) {/* SelectableRegion maintains its own state */}
@override
Future<void> pasteText(SelectionChangedCause cause) async {
assert(false);
}
// [SelectionRegistrar] override.
@override
void add(Selectable selectable) {
assert(_selectable == null);
_selectable = selectable;
_selectable!.addListener(_updateSelectionStatus);
_selectable!.pushHandleLayers(_startHandleLayerLink, _endHandleLayerLink);
}
@override
void remove(Selectable selectable) {
assert(_selectable == selectable);
_selectable!.removeListener(_updateSelectionStatus);
_selectable!.pushHandleLayers(null, null);
_selectable = null;
}
@override
void dispose() {
_selectable?.removeListener(_updateSelectionStatus);
_selectable?.pushHandleLayers(null, null);
_selectionDelegate.dispose();
// In case dispose was triggered before gesture end, remove the magnifier
// so it doesn't remain stuck in the overlay forever.
_selectionOverlay?.hideMagnifier(shouldShowToolbar: false);
_selectionOverlay?.dispose();
_selectionOverlay = null;
super.dispose();
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasOverlay(context));
Widget result = SelectionContainer(
registrar: this,
delegate: _selectionDelegate,
child: widget.child,
);
if (kIsWeb) {
result = PlatformSelectableRegionContextMenu(
child: result,
);
}
return CompositedTransformTarget(
link: _toolbarLayerLink,
child: RawGestureDetector(
gestures: _gestureRecognizers,
behavior: HitTestBehavior.translucent,
excludeFromSemantics: true,
child: Actions(
actions: _actions,
child: Focus(
includeSemantics: false,
focusNode: widget.focusNode,
child: result,
),
),
),
);
}
}
/// An action that does not override any [Action.overridable] in the subtree.
///
/// If this action is invoked by an [Action.overridable], it will immediately
/// invoke the [Action.overridable] and do nothing else. Otherwise, it will call
/// [invokeAction].
abstract class _NonOverrideAction<T extends Intent> extends ContextAction<T> {
Object? invokeAction(T intent, [BuildContext? context]);
@override
Object? invoke(T intent, [BuildContext? context]) {
if (callingAction != null) {
return callingAction!.invoke(intent);
}
return invokeAction(intent, context);
}
}
class _SelectAllAction extends _NonOverrideAction<SelectAllTextIntent> {
_SelectAllAction(this.state);
final _SelectableRegionState state;
@override
void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) {
state.selectAll(SelectionChangedCause.keyboard);
}
}
class _CopySelectionAction extends _NonOverrideAction<CopySelectionTextIntent> {
_CopySelectionAction(this.state);
final _SelectableRegionState state;
@override
void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) {
state._copy();
}
}
class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContainerDelegate {
final Set<Selectable> _hasReceivedStartEvent = <Selectable>{};
final Set<Selectable> _hasReceivedEndEvent = <Selectable>{};
Offset? _lastStartEdgeUpdateGlobalPosition;
Offset? _lastEndEdgeUpdateGlobalPosition;
@override
void remove(Selectable selectable) {
_hasReceivedStartEvent.remove(selectable);
_hasReceivedEndEvent.remove(selectable);
super.remove(selectable);
}
void _updateLastEdgeEventsFromGeometries() {
if (currentSelectionStartIndex != -1) {
final Selectable start = selectables[currentSelectionStartIndex];
final Offset localStartEdge = start.value.startSelectionPoint!.localPosition +
Offset(0, - start.value.startSelectionPoint!.lineHeight / 2);
_lastStartEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(start.getTransformTo(null), localStartEdge);
}
if (currentSelectionEndIndex != -1) {
final Selectable end = selectables[currentSelectionEndIndex];
final Offset localEndEdge = end.value.endSelectionPoint!.localPosition +
Offset(0, -end.value.endSelectionPoint!.lineHeight / 2);
_lastEndEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(end.getTransformTo(null), localEndEdge);
}
}
@override
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
final SelectionResult result = super.handleSelectAll(event);
for (final Selectable selectable in selectables) {
_hasReceivedStartEvent.add(selectable);
_hasReceivedEndEvent.add(selectable);
}
// Synthesize last update event so the edge updates continue to work.
_updateLastEdgeEventsFromGeometries();
return result;
}
/// Selects a word in a selectable at the location
/// [SelectWordSelectionEvent.globalPosition].
@override
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
final SelectionResult result = super.handleSelectWord(event);
if (currentSelectionStartIndex != -1) {
_hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]);
}
if (currentSelectionEndIndex != -1) {
_hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]);
}
_updateLastEdgeEventsFromGeometries();
return result;
}
@override
SelectionResult handleClearSelection(ClearSelectionEvent event) {
final SelectionResult result = super.handleClearSelection(event);
_hasReceivedStartEvent.clear();
_hasReceivedEndEvent.clear();
_lastStartEdgeUpdateGlobalPosition = null;
_lastEndEdgeUpdateGlobalPosition = null;
return result;
}
@override
SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) {
if (event.type == SelectionEventType.endEdgeUpdate) {
_lastEndEdgeUpdateGlobalPosition = event.globalPosition;
} else {
_lastStartEdgeUpdateGlobalPosition = event.globalPosition;
}
return super.handleSelectionEdgeUpdate(event);
}
@override
void dispose() {
_hasReceivedStartEvent.clear();
_hasReceivedEndEvent.clear();
super.dispose();
}
@override
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
switch (event.type) {
case SelectionEventType.startEdgeUpdate:
_hasReceivedStartEvent.add(selectable);
ensureChildUpdated(selectable);
break;
case SelectionEventType.endEdgeUpdate:
_hasReceivedEndEvent.add(selectable);
ensureChildUpdated(selectable);
break;
case SelectionEventType.clear:
_hasReceivedStartEvent.remove(selectable);
_hasReceivedEndEvent.remove(selectable);
break;
case SelectionEventType.selectAll:
case SelectionEventType.selectWord:
break;
}
return super.dispatchSelectionEventToChild(selectable, event);
}
@override
void ensureChildUpdated(Selectable selectable) {
if (_lastEndEdgeUpdateGlobalPosition != null && _hasReceivedEndEvent.add(selectable)) {
final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forEnd(
globalPosition: _lastEndEdgeUpdateGlobalPosition!,
);
if (currentSelectionEndIndex == -1) {
handleSelectionEdgeUpdate(synthesizedEvent);
}
selectable.dispatchSelectionEvent(synthesizedEvent);
}
if (_lastStartEdgeUpdateGlobalPosition != null && _hasReceivedStartEvent.add(selectable)) {
final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forStart(
globalPosition: _lastStartEdgeUpdateGlobalPosition!,
);
if (currentSelectionStartIndex == -1) {
handleSelectionEdgeUpdate(synthesizedEvent);
}
selectable.dispatchSelectionEvent(synthesizedEvent);
}
}
@override
void didChangeSelectables() {
if (_lastEndEdgeUpdateGlobalPosition != null) {
handleSelectionEdgeUpdate(
SelectionEdgeUpdateEvent.forEnd(
globalPosition: _lastEndEdgeUpdateGlobalPosition!,
),
);
}
if (_lastStartEdgeUpdateGlobalPosition != null) {
handleSelectionEdgeUpdate(
SelectionEdgeUpdateEvent.forStart(
globalPosition: _lastStartEdgeUpdateGlobalPosition!,
),
);
}
final Set<Selectable> selectableSet = selectables.toSet();
_hasReceivedEndEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable));
_hasReceivedStartEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable));
super.didChangeSelectables();
}
}
/// An abstract base class for updating multiple selectable children.
///
/// This class provide basic [SelectionEvent] handling and child [Selectable]
/// updating. The subclass needs to implement [ensureChildUpdated] to ensure
/// child [Selectable] is updated properly.
///
/// This class optimize the selection update by keeping track of the
/// [Selectable]s that currently contain the selection edges.
abstract class MultiSelectableSelectionContainerDelegate extends SelectionContainerDelegate with ChangeNotifier {
/// Gets the list of selectables this delegate is managing.
List<Selectable> selectables = <Selectable>[];
/// The number of additional pixels added to the selection handle drawable
/// area.
///
/// Selection handles that are outside of the drawable area will be hidden.
/// That logic prevents handles that get scrolled off the viewport from being
/// drawn on the screen.
///
/// The drawable area = current rectangle of [SelectionContainer] +
/// _kSelectionHandleDrawableAreaPadding on each side.
///
/// This was an eyeballed value to create smooth user experiences.
static const double _kSelectionHandleDrawableAreaPadding = 5.0;
/// The current selectable that contains the selection end edge.
@protected
int currentSelectionEndIndex = -1;
/// The current selectable that contains the selection start edge.
@protected
int currentSelectionStartIndex = -1;
LayerLink? _startHandleLayer;
Selectable? _startHandleLayerOwner;
LayerLink? _endHandleLayer;
Selectable? _endHandleLayerOwner;
bool _isHandlingSelectionEvent = false;
bool _scheduledSelectableUpdate = false;
bool _selectionInProgress = false;
Set<Selectable> _additions = <Selectable>{};
@override
void add(Selectable selectable) {
assert(!selectables.contains(selectable));
_additions.add(selectable);
_scheduleSelectableUpdate();
}
@override
void remove(Selectable selectable) {
if (_additions.remove(selectable)) {
return;
}
_removeSelectable(selectable);
_scheduleSelectableUpdate();
}
/// Notifies this delegate that layout of the container has changed.
void layoutDidChange() {
_updateSelectionGeometry();
}
void _scheduleSelectableUpdate() {
if (!_scheduledSelectableUpdate) {
_scheduledSelectableUpdate = true;
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!_scheduledSelectableUpdate) {
return;
}
_scheduledSelectableUpdate = false;
_updateSelectables();
});
}
}
void _updateSelectables() {
// Remove offScreen selectable.
if (_additions.isNotEmpty) {
_flushAdditions();
}
didChangeSelectables();
}
void _flushAdditions() {
final List<Selectable> mergingSelectables = _additions.toList()..sort(compareOrder);
final List<Selectable> existingSelectables = selectables;
selectables = <Selectable>[];
int mergingIndex = 0;
int existingIndex = 0;
int selectionStartIndex = currentSelectionStartIndex;
int selectionEndIndex = currentSelectionEndIndex;
// Merge two sorted lists.
while (mergingIndex < mergingSelectables.length || existingIndex < existingSelectables.length) {
if (mergingIndex >= mergingSelectables.length ||
(existingIndex < existingSelectables.length &&
compareOrder(existingSelectables[existingIndex], mergingSelectables[mergingIndex]) < 0)) {
if (existingIndex == currentSelectionStartIndex) {
selectionStartIndex = selectables.length;
}
if (existingIndex == currentSelectionEndIndex) {
selectionEndIndex = selectables.length;
}
selectables.add(existingSelectables[existingIndex]);
existingIndex += 1;
continue;
}
// If the merging selectable falls in the selection range, their selection
// needs to be updated.
final Selectable mergingSelectable = mergingSelectables[mergingIndex];
if (existingIndex < max(currentSelectionStartIndex, currentSelectionEndIndex) &&
existingIndex > min(currentSelectionStartIndex, currentSelectionEndIndex)) {
ensureChildUpdated(mergingSelectable);
}
mergingSelectable.addListener(_handleSelectableGeometryChange);
selectables.add(mergingSelectable);
mergingIndex += 1;
}
assert(mergingIndex == mergingSelectables.length &&
existingIndex == existingSelectables.length &&
selectables.length == existingIndex + mergingIndex);
assert(selectionStartIndex >= -1 || selectionStartIndex < selectables.length);
assert(selectionEndIndex >= -1 || selectionEndIndex < selectables.length);
// selection indices should not be set to -1 unless they originally were.
assert((currentSelectionStartIndex == -1) == (selectionStartIndex == -1));
assert((currentSelectionEndIndex == -1) == (selectionEndIndex == -1));
currentSelectionEndIndex = selectionEndIndex;
currentSelectionStartIndex = selectionStartIndex;
_additions = <Selectable>{};
}
void _removeSelectable(Selectable selectable) {
assert(selectables.contains(selectable), 'The selectable is not in this registrar.');
final int index = selectables.indexOf(selectable);
selectables.removeAt(index);
if (index <= currentSelectionEndIndex) {
currentSelectionEndIndex -= 1;
}
if (index <= currentSelectionStartIndex) {
currentSelectionStartIndex -= 1;
}
selectable.removeListener(_handleSelectableGeometryChange);
}
/// Called when this delegate finishes updating the selectables.
@protected
@mustCallSuper
void didChangeSelectables() {
_updateSelectionGeometry();
}
@override
SelectionGeometry get value => _selectionGeometry;
SelectionGeometry _selectionGeometry = const SelectionGeometry(
hasContent: false,
status: SelectionStatus.none,
);
/// Updates the [value] in this class and notifies listeners if necessary.
void _updateSelectionGeometry() {
final SelectionGeometry newValue = getSelectionGeometry();
if (_selectionGeometry != newValue) {
_selectionGeometry = newValue;
notifyListeners();
}
_updateHandleLayersAndOwners();
}
/// The compare function this delegate used for determining the selection
/// order of the selectables.
///
/// Defaults to screen order.
@protected
Comparator<Selectable> get compareOrder => _compareScreenOrder;
int _compareScreenOrder(Selectable a, Selectable b) {
final Rect rectA = MatrixUtils.transformRect(
a.getTransformTo(null),
Rect.fromLTWH(0, 0, a.size.width, a.size.height),
);
final Rect rectB = MatrixUtils.transformRect(
b.getTransformTo(null),
Rect.fromLTWH(0, 0, b.size.width, b.size.height),
);
final int result = _compareVertically(rectA, rectB);
if (result != 0) {
return result;
}
return _compareHorizontally(rectA, rectB);
}
/// Compares two rectangles in the screen order solely by their vertical
/// positions.
///
/// Returns positive if a is lower, negative if a is higher, 0 if their
/// order can't be determine solely by their vertical position.
static int _compareVertically(Rect a, Rect b) {
if ((a.top - b.top < precisionErrorTolerance && a.bottom - b.bottom > - precisionErrorTolerance) ||
(b.top - a.top < precisionErrorTolerance && b.bottom - a.bottom > - precisionErrorTolerance)) {
return 0;
}
if ((a.top - b.top).abs() > precisionErrorTolerance) {
return a.top > b.top ? 1 : -1;
}
return a.bottom > b.bottom ? 1 : -1;
}
/// Compares two rectangles in the screen order by their horizontal positions
/// assuming one of the rectangles enclose the other rect vertically.
///
/// Returns positive if a is lower, negative if a is higher.
static int _compareHorizontally(Rect a, Rect b) {
if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) {
// a encloses b.
return -1;
}
if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) {
// b encloses a.
return 1;
}
if ((a.left - b.left).abs() > precisionErrorTolerance) {
return a.left > b.left ? 1 : -1;
}
return a.right > b.right ? 1 : -1;
}
void _handleSelectableGeometryChange() {
// Geometries of selectable children may change multiple times when handling
// selection events. Ignore these updates since the selection geometry of
// this delegate will be updated after handling the selection events.
if (_isHandlingSelectionEvent) {
return;
}
_updateSelectionGeometry();
}
/// Gets the combined selection geometry for child selectables.
@protected
SelectionGeometry getSelectionGeometry() {
if (currentSelectionEndIndex == -1 ||
currentSelectionStartIndex == -1 ||
selectables.isEmpty) {
// There is no valid selection.
return SelectionGeometry(
status: SelectionStatus.none,
hasContent: selectables.isNotEmpty,
);
}
currentSelectionStartIndex = _adjustSelectionIndexBasedOnSelectionGeometry(
currentSelectionStartIndex,
currentSelectionEndIndex,
);
currentSelectionEndIndex = _adjustSelectionIndexBasedOnSelectionGeometry(
currentSelectionEndIndex,
currentSelectionStartIndex,
);
// Need to find the non-null start selection point.
SelectionGeometry startGeometry = selectables[currentSelectionStartIndex].value;
final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex;
int startIndexWalker = currentSelectionStartIndex;
while (startIndexWalker != currentSelectionEndIndex && startGeometry.startSelectionPoint == null) {
startIndexWalker += forwardSelection ? 1 : -1;
startGeometry = selectables[startIndexWalker].value;
}
SelectionPoint? startPoint;
if (startGeometry.startSelectionPoint != null) {
final Matrix4 startTransform = getTransformFrom(selectables[startIndexWalker]);
final Offset start = MatrixUtils.transformPoint(startTransform, startGeometry.startSelectionPoint!.localPosition);
// It can be NaN if it is detached or off-screen.
if (start.isFinite) {
startPoint = SelectionPoint(
localPosition: start,
lineHeight: startGeometry.startSelectionPoint!.lineHeight,
handleType: startGeometry.startSelectionPoint!.handleType,
);
}
}
// Need to find the non-null end selection point.
SelectionGeometry endGeometry = selectables[currentSelectionEndIndex].value;
int endIndexWalker = currentSelectionEndIndex;
while (endIndexWalker != currentSelectionStartIndex && endGeometry.endSelectionPoint == null) {
endIndexWalker += forwardSelection ? -1 : 1;
endGeometry = selectables[endIndexWalker].value;
}
SelectionPoint? endPoint;
if (endGeometry.endSelectionPoint != null) {
final Matrix4 endTransform = getTransformFrom(selectables[endIndexWalker]);
final Offset end = MatrixUtils.transformPoint(endTransform, endGeometry.endSelectionPoint!.localPosition);
// It can be NaN if it is detached or off-screen.
if (end.isFinite) {
endPoint = SelectionPoint(
localPosition: end,
lineHeight: endGeometry.endSelectionPoint!.lineHeight,
handleType: endGeometry.endSelectionPoint!.handleType,
);
}
}
return SelectionGeometry(
startSelectionPoint: startPoint,
endSelectionPoint: endPoint,
status: startGeometry != endGeometry
? SelectionStatus.uncollapsed
: startGeometry.status,
// Would have at least one selectable child.
hasContent: true,
);
}
// The currentSelectionStartIndex or currentSelectionEndIndex may not be
// the current index that contains selection edges. This can happen if the
// selection edge is in between two selectables. One of the selectable will
// have its selection collapsed at the index 0 or contentLength depends on
// whether the selection is reversed or not. The current selection index can
// be point to either one.
//
// This method adjusts the index to point to selectable with valid selection.
int _adjustSelectionIndexBasedOnSelectionGeometry(int currentIndex, int towardIndex) {
final bool forward = towardIndex > currentIndex;
while (currentIndex != towardIndex &&
selectables[currentIndex].value.status != SelectionStatus.uncollapsed) {
currentIndex += forward ? 1 : -1;
}
return currentIndex;
}
@override
void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) {
if (_startHandleLayer == startHandle && _endHandleLayer == endHandle) {
return;
}
_startHandleLayer = startHandle;
_endHandleLayer = endHandle;
_updateHandleLayersAndOwners();
}
/// Pushes both handle layers to the selectables that contain selection edges.
///
/// This method needs to be called every time the selectables that contain the
/// selection edges change, i.e. [currentSelectionStartIndex] or
/// [currentSelectionEndIndex] changes. Otherwise, the handle may be painted
/// in the wrong place.
void _updateHandleLayersAndOwners() {
LayerLink? effectiveStartHandle = _startHandleLayer;
LayerLink? effectiveEndHandle = _endHandleLayer;
if (effectiveStartHandle != null || effectiveEndHandle != null) {
final Rect drawableArea = Rect
.fromLTWH(0, 0, containerSize.width, containerSize.height)
.inflate(_kSelectionHandleDrawableAreaPadding);
final bool hideStartHandle = value.startSelectionPoint == null || !drawableArea.contains(value.startSelectionPoint!.localPosition);
final bool hideEndHandle = value.endSelectionPoint == null || !drawableArea.contains(value.endSelectionPoint!.localPosition);
effectiveStartHandle = hideStartHandle ? null : _startHandleLayer;
effectiveEndHandle = hideEndHandle ? null : _endHandleLayer;
}
if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) {
// No valid selection.
if (_startHandleLayerOwner != null) {
_startHandleLayerOwner!.pushHandleLayers(null, null);
_startHandleLayerOwner = null;
}
if (_endHandleLayerOwner != null) {
_endHandleLayerOwner!.pushHandleLayers(null, null);
_endHandleLayerOwner = null;
}
return;
}
if (selectables[currentSelectionStartIndex] != _startHandleLayerOwner) {
_startHandleLayerOwner?.pushHandleLayers(null, null);
}
if (selectables[currentSelectionEndIndex] != _endHandleLayerOwner) {
_endHandleLayerOwner?.pushHandleLayers(null, null);
}
_startHandleLayerOwner = selectables[currentSelectionStartIndex];
if (currentSelectionStartIndex == currentSelectionEndIndex) {
// Selection edges is on the same selectable.
_endHandleLayerOwner = _startHandleLayerOwner;
_startHandleLayerOwner!.pushHandleLayers(effectiveStartHandle, effectiveEndHandle);
return;
}
_startHandleLayerOwner!.pushHandleLayers(effectiveStartHandle, null);
_endHandleLayerOwner = selectables[currentSelectionEndIndex];
_endHandleLayerOwner!.pushHandleLayers(null, effectiveEndHandle);
}
/// Copies the selected contents of all selectables.
@override
SelectedContent? getSelectedContent() {
final List<SelectedContent> selections = <SelectedContent>[];
for (final Selectable selectable in selectables) {
final SelectedContent? data = selectable.getSelectedContent();
if (data != null) {
selections.add(data);
}
}
if (selections.isEmpty) {
return null;
}
final StringBuffer buffer = StringBuffer();
for (final SelectedContent selection in selections) {
buffer.write(selection.plainText);
}
return SelectedContent(
plainText: buffer.toString(),
);
}
/// Selects all contents of all selectables.
@protected
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
for (final Selectable selectable in selectables) {
dispatchSelectionEventToChild(selectable, event);
}
currentSelectionStartIndex = 0;
currentSelectionEndIndex = selectables.length - 1;
return SelectionResult.none;
}
/// Selects a word in a selectable at the location
/// [SelectWordSelectionEvent.globalPosition].
@protected
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
for (int index = 0; index < selectables.length; index += 1) {
final Rect localRect = Rect.fromLTWH(0, 0, selectables[index].size.width, selectables[index].size.height);
final Matrix4 transform = selectables[index].getTransformTo(null);
final Rect globalRect = MatrixUtils.transformRect(transform, localRect);
if (globalRect.contains(event.globalPosition)) {
final SelectionGeometry existingGeometry = selectables[index].value;
dispatchSelectionEventToChild(selectables[index], event);
if (selectables[index].value != existingGeometry) {
// Geometry has changed as a result of select word, need to clear the
// selection of other selectables to keep selection in sync.
selectables
.where((Selectable target) => target != selectables[index])
.forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent()));
currentSelectionStartIndex = currentSelectionEndIndex = index;
}
return SelectionResult.end;
}
}
return SelectionResult.none;
}
/// Removes the selection of all selectables this delegate manages.
@protected
SelectionResult handleClearSelection(ClearSelectionEvent event) {
for (final Selectable selectable in selectables) {
dispatchSelectionEventToChild(selectable, event);
}
currentSelectionEndIndex = -1;
currentSelectionStartIndex = -1;
return SelectionResult.none;
}
/// Updates the selection edges.
@protected
SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) {
if (event.type == SelectionEventType.endEdgeUpdate) {
return currentSelectionEndIndex == -1 ? _initSelection(event, isEnd: true) : _adjustSelection(event, isEnd: true);
}
return currentSelectionStartIndex == -1 ? _initSelection(event, isEnd: false) : _adjustSelection(event, isEnd: false);
}
@override
SelectionResult dispatchSelectionEvent(SelectionEvent event) {
final bool selectionWillbeInProgress = event is! ClearSelectionEvent;
if (!_selectionInProgress && selectionWillbeInProgress) {
// Sort the selectable every time a selection start.
selectables.sort(compareOrder);
}
_selectionInProgress = selectionWillbeInProgress;
_isHandlingSelectionEvent = true;
late SelectionResult result;
switch (event.type) {
case SelectionEventType.startEdgeUpdate:
case SelectionEventType.endEdgeUpdate:
result = handleSelectionEdgeUpdate(event as SelectionEdgeUpdateEvent);
break;
case SelectionEventType.clear:
result = handleClearSelection(event as ClearSelectionEvent);
break;
case SelectionEventType.selectAll:
result = handleSelectAll(event as SelectAllSelectionEvent);
break;
case SelectionEventType.selectWord:
result = handleSelectWord(event as SelectWordSelectionEvent);
break;
}
_isHandlingSelectionEvent = false;
_updateSelectionGeometry();
return result;
}
@override
void dispose() {
for (final Selectable selectable in selectables) {
selectable.removeListener(_handleSelectableGeometryChange);
}
selectables = const <Selectable>[];
_scheduledSelectableUpdate = false;
super.dispose();
}
/// Ensures the selectable child has received up to date selection event.
///
/// This method is called when a new [Selectable] is added to the delegate,
/// and its screen location falls into the previous selection.
///
/// Subclasses are responsible for updating the selection of this newly added
/// [Selectable].
@protected
void ensureChildUpdated(Selectable selectable);
/// Dispatches a selection event to a specific selectable.
///
/// Override this method if subclasses need to generate additional events or
/// treatments prior to sending the selection events.
@protected
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
return selectable.dispatchSelectionEvent(event);
}
/// Initializes the selection of the selectable children.
///
/// The goal is to find the selectable child that contains the selection edge.
/// Returns [SelectionResult.end] if the selection edge ends on any of the
/// children. Otherwise, it returns [SelectionResult.previous] if the selection
/// does not reach any of its children. Returns [SelectionResult.next]
/// if the selection reaches the end of its children.
///
/// Ideally, this method should only be called twice at the beginning of the
/// drag selection, once for start edge update event, once for end edge update
/// event.
SelectionResult _initSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) {
assert((isEnd && currentSelectionEndIndex == -1) || (!isEnd && currentSelectionStartIndex == -1));
int newIndex = -1;
bool hasFoundEdgeIndex = false;
SelectionResult? result;
for (int index = 0; index < selectables.length && !hasFoundEdgeIndex; index += 1) {
final Selectable child = selectables[index];
final SelectionResult childResult = dispatchSelectionEventToChild(child, event);
switch (childResult) {
case SelectionResult.next:
case SelectionResult.none:
newIndex = index;
break;
case SelectionResult.end:
newIndex = index;
result = SelectionResult.end;
hasFoundEdgeIndex = true;
break;
case SelectionResult.previous:
hasFoundEdgeIndex = true;
if (index == 0) {
newIndex = 0;
result = SelectionResult.previous;
}
result ??= SelectionResult.end;
break;
case SelectionResult.pending:
newIndex = index;
result = SelectionResult.pending;
hasFoundEdgeIndex = true;
break;
}
}
if (newIndex == -1) {
assert(selectables.isEmpty);
return SelectionResult.none;
}
if (isEnd) {
currentSelectionEndIndex = newIndex;
} else {
currentSelectionStartIndex = newIndex;
}
// The result can only be null if the loop went through the entire list
// without any of the selection returned end or previous. In this case, the
// caller of this method needs to find the next selectable in their list.
return result ?? SelectionResult.next;
}
/// Adjusts the selection based on the drag selection update event if there
/// is already a selectable child that contains the selection edge.
///
/// This method starts by sending the selection event to the current
/// selectable that contains the selection edge, and finds forward or backward
/// if that selectable no longer contains the selection edge.
SelectionResult _adjustSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) {
assert(() {
if (isEnd) {
assert(currentSelectionEndIndex < selectables.length && currentSelectionEndIndex >= 0);
return true;
}
assert(currentSelectionStartIndex < selectables.length && currentSelectionStartIndex >= 0);
return true;
}());
SelectionResult? finalResult;
int newIndex = isEnd ? currentSelectionEndIndex : currentSelectionStartIndex;
bool? forward;
late SelectionResult currentSelectableResult;
// This loop sends the selection event to the
// currentSelectionEndIndex/currentSelectionStartIndex to determine the
// direction of the search. If the result is `SelectionResult.next`, this
// loop look backward. Otherwise, it looks forward.
//
// The terminate condition are:
// 1. the selectable returns end, pending, none.
// 2. the selectable returns previous when looking forward.
// 2. the selectable returns next when looking backward.
while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) {
currentSelectableResult = dispatchSelectionEventToChild(selectables[newIndex], event);
switch (currentSelectableResult) {
case SelectionResult.end:
case SelectionResult.pending:
case SelectionResult.none:
finalResult = currentSelectableResult;
break;
case SelectionResult.next:
if (forward == false) {
newIndex += 1;
finalResult = SelectionResult.end;
} else if (newIndex == selectables.length - 1) {
finalResult = currentSelectableResult;
} else {
forward = true;
newIndex += 1;
}
break;
case SelectionResult.previous:
if (forward ?? false) {
newIndex -= 1;
finalResult = SelectionResult.end;
} else if (newIndex == 0) {
finalResult = currentSelectableResult;
} else {
forward = false;
newIndex -= 1;
}
break;
}
}
if (isEnd) {
currentSelectionEndIndex = newIndex;
} else {
currentSelectionStartIndex = newIndex;
}
return finalResult!;
}
}