| // 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!; |
| } |
| } |