1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'package:flutter/cupertino.dart';
6/// @docImport 'package:flutter/material.dart';
7library;
8
9import 'dart:async';
10import 'dart:math' as math;
11
12import 'package:characters/characters.dart';
13import 'package:flutter/foundation.dart';
14import 'package:flutter/gestures.dart';
15import 'package:flutter/rendering.dart';
16import 'package:flutter/scheduler.dart';
17import 'package:flutter/services.dart';
18
19import 'basic.dart';
20import 'binding.dart';
21import 'constants.dart';
22import 'context_menu_controller.dart';
23import 'debug.dart';
24import 'editable_text.dart';
25import 'feedback.dart';
26import 'framework.dart';
27import 'gesture_detector.dart';
28import 'inherited_theme.dart';
29import 'magnifier.dart';
30import 'overlay.dart';
31import 'scrollable.dart';
32import 'tap_region.dart';
33import 'ticker_provider.dart';
34import 'transitions.dart';
35
36export 'package:flutter/rendering.dart' show TextSelectionPoint;
37export 'package:flutter/services.dart' show TextSelectionDelegate;
38
39/// The type for a Function that builds a toolbar's container with the given
40/// child.
41///
42/// See also:
43///
44/// * [TextSelectionToolbar.toolbarBuilder], which is of this type.
45/// type.
46/// * [CupertinoTextSelectionToolbar.toolbarBuilder], which is similar, but
47/// for a Cupertino-style toolbar.
48typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child);
49
50/// ParentData that determines whether or not to paint the corresponding child.
51///
52/// Used in the layout of the Cupertino and Material text selection menus, which
53/// decide whether or not to paint their buttons after laying them out and
54/// determining where they overflow.
55class ToolbarItemsParentData extends ContainerBoxParentData<RenderBox> {
56 /// Whether or not this child is painted.
57 ///
58 /// Children in the selection toolbar may be laid out for measurement purposes
59 /// but not painted. This allows these children to be identified.
60 bool shouldPaint = false;
61
62 @override
63 String toString() => '${super.toString()}; shouldPaint=$shouldPaint';
64}
65
66/// An interface for building the selection UI, to be provided by the
67/// implementer of the toolbar widget.
68///
69/// Parts of this class, including [buildToolbar], have been deprecated in favor
70/// of [EditableText.contextMenuBuilder], which is now the preferred way to
71/// customize the context menus.
72///
73/// ## Use with [EditableText.contextMenuBuilder]
74///
75/// For backwards compatibility during the deprecation period, when
76/// [EditableText.selectionControls] is set to an object that does not mix in
77/// [TextSelectionHandleControls], [EditableText.contextMenuBuilder] is ignored
78/// in favor of the deprecated [buildToolbar].
79///
80/// To migrate code from [buildToolbar] to the preferred
81/// [EditableText.contextMenuBuilder], while still using [buildHandle], mix in
82/// [TextSelectionHandleControls] into the [TextSelectionControls] subclass when
83/// moving any toolbar code to a callback passed to
84/// [EditableText.contextMenuBuilder].
85///
86/// In due course, [buildToolbar] will be removed, and the mixin will no longer
87/// be necessary as a way to flag to the framework that the code has been
88/// migrated and does not expect [buildToolbar] to be called.
89///
90/// For more information, see <https://docs.flutter.dev/release/breaking-changes/context-menus>.
91///
92/// See also:
93///
94/// * [SelectionArea], which selects appropriate text selection controls
95/// based on the current platform.
96abstract class TextSelectionControls {
97 /// Builds a selection handle of the given `type`.
98 ///
99 /// The top left corner of this widget is positioned at the bottom of the
100 /// selection position.
101 ///
102 /// The supplied [onTap] should be invoked when the handle is tapped, if such
103 /// interaction is allowed. As a counterexample, the default selection handle
104 /// on iOS [cupertinoTextSelectionControls] does not call [onTap] at all,
105 /// since its handles are not meant to be tapped.
106 Widget buildHandle(
107 BuildContext context,
108 TextSelectionHandleType type,
109 double textLineHeight, [
110 VoidCallback? onTap,
111 ]);
112
113 /// Get the anchor point of the handle relative to itself. The anchor point is
114 /// the point that is aligned with a specific point in the text. A handle
115 /// often visually "points to" that location.
116 Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight);
117
118 /// Builds a toolbar near a text selection.
119 ///
120 /// Typically displays buttons for copying and pasting text.
121 ///
122 /// The [globalEditableRegion] parameter is the TextField size of the global
123 /// coordinate system in logical pixels.
124 ///
125 /// The [textLineHeight] parameter is the [RenderEditable.preferredLineHeight]
126 /// of the [RenderEditable] we are building a toolbar for.
127 ///
128 /// The [selectionMidpoint] parameter is a general calculation midpoint
129 /// parameter of the toolbar. More detailed position information
130 /// is computable from the [endpoints] parameter.
131 @Deprecated(
132 'Use `contextMenuBuilder` instead. '
133 'This feature was deprecated after v3.3.0-0.5.pre.',
134 )
135 Widget buildToolbar(
136 BuildContext context,
137 Rect globalEditableRegion,
138 double textLineHeight,
139 Offset selectionMidpoint,
140 List<TextSelectionPoint> endpoints,
141 TextSelectionDelegate delegate,
142 ValueListenable<ClipboardStatus>? clipboardStatus,
143 Offset? lastSecondaryTapDownPosition,
144 );
145
146 /// Returns the size of the selection handle.
147 Size getHandleSize(double textLineHeight);
148
149 /// Whether the current selection of the text field managed by the given
150 /// `delegate` can be removed from the text field and placed into the
151 /// [Clipboard].
152 ///
153 /// By default, false is returned when nothing is selected in the text field.
154 ///
155 /// Subclasses can use this to decide if they should expose the cut
156 /// functionality to the user.
157 @Deprecated(
158 'Use `contextMenuBuilder` instead. '
159 'This feature was deprecated after v3.3.0-0.5.pre.',
160 )
161 bool canCut(TextSelectionDelegate delegate) {
162 return delegate.cutEnabled && !delegate.textEditingValue.selection.isCollapsed;
163 }
164
165 /// Whether the current selection of the text field managed by the given
166 /// `delegate` can be copied to the [Clipboard].
167 ///
168 /// By default, false is returned when nothing is selected in the text field.
169 ///
170 /// Subclasses can use this to decide if they should expose the copy
171 /// functionality to the user.
172 @Deprecated(
173 'Use `contextMenuBuilder` instead. '
174 'This feature was deprecated after v3.3.0-0.5.pre.',
175 )
176 bool canCopy(TextSelectionDelegate delegate) {
177 return delegate.copyEnabled && !delegate.textEditingValue.selection.isCollapsed;
178 }
179
180 /// Whether the text field managed by the given `delegate` supports pasting
181 /// from the clipboard.
182 ///
183 /// Subclasses can use this to decide if they should expose the paste
184 /// functionality to the user.
185 ///
186 /// This does not consider the contents of the clipboard. Subclasses may want
187 /// to, for example, disallow pasting when the clipboard contains an empty
188 /// string.
189 @Deprecated(
190 'Use `contextMenuBuilder` instead. '
191 'This feature was deprecated after v3.3.0-0.5.pre.',
192 )
193 bool canPaste(TextSelectionDelegate delegate) {
194 return delegate.pasteEnabled;
195 }
196
197 /// Whether the current selection of the text field managed by the given
198 /// `delegate` can be extended to include the entire content of the text
199 /// field.
200 ///
201 /// Subclasses can use this to decide if they should expose the select all
202 /// functionality to the user.
203 @Deprecated(
204 'Use `contextMenuBuilder` instead. '
205 'This feature was deprecated after v3.3.0-0.5.pre.',
206 )
207 bool canSelectAll(TextSelectionDelegate delegate) {
208 return delegate.selectAllEnabled &&
209 delegate.textEditingValue.text.isNotEmpty &&
210 delegate.textEditingValue.selection.isCollapsed;
211 }
212
213 /// Call [TextSelectionDelegate.cutSelection] to cut current selection.
214 ///
215 /// This is called by subclasses when their cut affordance is activated by
216 /// the user.
217 @Deprecated(
218 'Use `contextMenuBuilder` instead. '
219 'This feature was deprecated after v3.3.0-0.5.pre.',
220 )
221 void handleCut(TextSelectionDelegate delegate) {
222 delegate.cutSelection(SelectionChangedCause.toolbar);
223 }
224
225 /// Call [TextSelectionDelegate.copySelection] to copy current selection.
226 ///
227 /// This is called by subclasses when their copy affordance is activated by
228 /// the user.
229 @Deprecated(
230 'Use `contextMenuBuilder` instead. '
231 'This feature was deprecated after v3.3.0-0.5.pre.',
232 )
233 void handleCopy(TextSelectionDelegate delegate) {
234 delegate.copySelection(SelectionChangedCause.toolbar);
235 }
236
237 /// Call [TextSelectionDelegate.pasteText] to paste text.
238 ///
239 /// This is called by subclasses when their paste affordance is activated by
240 /// the user.
241 ///
242 /// This function is asynchronous since interacting with the clipboard is
243 /// asynchronous. Race conditions may exist with this API as currently
244 /// implemented.
245 // TODO(ianh): https://github.com/flutter/flutter/issues/11427
246 @Deprecated(
247 'Use `contextMenuBuilder` instead. '
248 'This feature was deprecated after v3.3.0-0.5.pre.',
249 )
250 Future<void> handlePaste(TextSelectionDelegate delegate) async {
251 delegate.pasteText(SelectionChangedCause.toolbar);
252 }
253
254 /// Call [TextSelectionDelegate.selectAll] to set the current selection to
255 /// contain the entire text value.
256 ///
257 /// Does not hide the toolbar.
258 ///
259 /// This is called by subclasses when their select-all affordance is activated
260 /// by the user.
261 @Deprecated(
262 'Use `contextMenuBuilder` instead. '
263 'This feature was deprecated after v3.3.0-0.5.pre.',
264 )
265 void handleSelectAll(TextSelectionDelegate delegate) {
266 delegate.selectAll(SelectionChangedCause.toolbar);
267 }
268}
269
270/// Text selection controls that do not show any toolbars or handles.
271///
272/// This is a placeholder, suitable for temporary use during development, but
273/// not practical for production. For example, it provides no way for the user
274/// to interact with selections: no context menus on desktop, no toolbars or
275/// drag handles on mobile, etc. For production, consider using
276/// [MaterialTextSelectionControls] or creating a custom subclass of
277/// [TextSelectionControls].
278///
279/// The [emptyTextSelectionControls] global variable has a
280/// suitable instance of this class.
281class EmptyTextSelectionControls extends TextSelectionControls {
282 @override
283 Size getHandleSize(double textLineHeight) => Size.zero;
284
285 @override
286 Widget buildToolbar(
287 BuildContext context,
288 Rect globalEditableRegion,
289 double textLineHeight,
290 Offset selectionMidpoint,
291 List<TextSelectionPoint> endpoints,
292 TextSelectionDelegate delegate,
293 ValueListenable<ClipboardStatus>? clipboardStatus,
294 Offset? lastSecondaryTapDownPosition,
295 ) => const SizedBox.shrink();
296
297 @override
298 Widget buildHandle(
299 BuildContext context,
300 TextSelectionHandleType type,
301 double textLineHeight, [
302 VoidCallback? onTap,
303 ]) {
304 return const SizedBox.shrink();
305 }
306
307 @override
308 Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
309 return Offset.zero;
310 }
311}
312
313/// Text selection controls that do not show any toolbars or handles.
314///
315/// This is a placeholder, suitable for temporary use during development, but
316/// not practical for production. For example, it provides no way for the user
317/// to interact with selections: no context menus on desktop, no toolbars or
318/// drag handles on mobile, etc. For production, consider using
319/// [materialTextSelectionControls] or creating a custom subclass of
320/// [TextSelectionControls].
321final TextSelectionControls emptyTextSelectionControls = EmptyTextSelectionControls();
322
323/// An object that manages a pair of text selection handles for a
324/// [RenderEditable].
325///
326/// This class is a wrapper of [SelectionOverlay] to provide APIs specific for
327/// [RenderEditable]s. To manage selection handles for custom widgets, use
328/// [SelectionOverlay] instead.
329class TextSelectionOverlay {
330 /// Creates an object that manages overlay entries for selection handles.
331 ///
332 /// The [context] must have an [Overlay] as an ancestor.
333 TextSelectionOverlay({
334 required TextEditingValue value,
335 required this.context,
336 Widget? debugRequiredFor,
337 required LayerLink toolbarLayerLink,
338 required LayerLink startHandleLayerLink,
339 required LayerLink endHandleLayerLink,
340 required this.renderObject,
341 this.selectionControls,
342 bool handlesVisible = false,
343 required this.selectionDelegate,
344 DragStartBehavior dragStartBehavior = DragStartBehavior.start,
345 VoidCallback? onSelectionHandleTapped,
346 ClipboardStatusNotifier? clipboardStatus,
347 this.contextMenuBuilder,
348 required TextMagnifierConfiguration magnifierConfiguration,
349 }) : _handlesVisible = handlesVisible,
350 _value = value {
351 assert(debugMaybeDispatchCreated('widgets', 'TextSelectionOverlay', this));
352 renderObject.selectionStartInViewport.addListener(_updateTextSelectionOverlayVisibilities);
353 renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities);
354 _updateTextSelectionOverlayVisibilities();
355 _selectionOverlay = SelectionOverlay(
356 magnifierConfiguration: magnifierConfiguration,
357 context: context,
358 debugRequiredFor: debugRequiredFor,
359 // The metrics will be set when show handles.
360 startHandleType: TextSelectionHandleType.collapsed,
361 startHandlesVisible: _effectiveStartHandleVisibility,
362 lineHeightAtStart: 0.0,
363 onStartHandleDragStart: _handleSelectionStartHandleDragStart,
364 onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
365 onEndHandleDragEnd: _handleAnyDragEnd,
366 endHandleType: TextSelectionHandleType.collapsed,
367 endHandlesVisible: _effectiveEndHandleVisibility,
368 lineHeightAtEnd: 0.0,
369 onEndHandleDragStart: _handleSelectionEndHandleDragStart,
370 onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
371 onStartHandleDragEnd: _handleAnyDragEnd,
372 toolbarVisible: _effectiveToolbarVisibility,
373 selectionEndpoints: const <TextSelectionPoint>[],
374 selectionControls: selectionControls,
375 selectionDelegate: selectionDelegate,
376 clipboardStatus: clipboardStatus,
377 startHandleLayerLink: startHandleLayerLink,
378 endHandleLayerLink: endHandleLayerLink,
379 toolbarLayerLink: toolbarLayerLink,
380 onSelectionHandleTapped: onSelectionHandleTapped,
381 dragStartBehavior: dragStartBehavior,
382 toolbarLocation: renderObject.lastSecondaryTapDownPosition,
383 );
384 }
385
386 /// {@template flutter.widgets.SelectionOverlay.context}
387 /// The context in which the selection UI should appear.
388 ///
389 /// This context must have an [Overlay] as an ancestor because this object
390 /// will display the text selection handles in that [Overlay].
391 /// {@endtemplate}
392 final BuildContext context;
393
394 // TODO(mpcomplete): what if the renderObject is removed or replaced, or
395 // moves? Not sure what cases I need to handle, or how to handle them.
396 /// The editable line in which the selected text is being displayed.
397 final RenderEditable renderObject;
398
399 /// {@macro flutter.widgets.SelectionOverlay.selectionControls}
400 final TextSelectionControls? selectionControls;
401
402 /// {@macro flutter.widgets.SelectionOverlay.selectionDelegate}
403 final TextSelectionDelegate selectionDelegate;
404
405 late final SelectionOverlay _selectionOverlay;
406
407 /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
408 ///
409 /// If not provided, no context menu will be built.
410 final WidgetBuilder? contextMenuBuilder;
411
412 /// Retrieve current value.
413 @visibleForTesting
414 TextEditingValue get value => _value;
415
416 TextEditingValue _value;
417
418 TextSelection get _selection => _value.selection;
419
420 final ValueNotifier<bool> _effectiveStartHandleVisibility = ValueNotifier<bool>(false);
421 final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
422 final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
423
424 void _updateTextSelectionOverlayVisibilities() {
425 _effectiveStartHandleVisibility.value =
426 _handlesVisible && renderObject.selectionStartInViewport.value;
427 _effectiveEndHandleVisibility.value =
428 _handlesVisible && renderObject.selectionEndInViewport.value;
429 _effectiveToolbarVisibility.value =
430 renderObject.selectionStartInViewport.value || renderObject.selectionEndInViewport.value;
431 }
432
433 /// Whether selection handles are visible.
434 ///
435 /// Set to false if you want to hide the handles. Use this property to show or
436 /// hide the handle without rebuilding them.
437 ///
438 /// Defaults to false.
439 bool get handlesVisible => _handlesVisible;
440 bool _handlesVisible = false;
441 set handlesVisible(bool visible) {
442 if (_handlesVisible == visible) {
443 return;
444 }
445 _handlesVisible = visible;
446 _updateTextSelectionOverlayVisibilities();
447 }
448
449 /// {@macro flutter.widgets.SelectionOverlay.showHandles}
450 void showHandles() {
451 _updateSelectionOverlay();
452 _selectionOverlay.showHandles();
453 }
454
455 /// {@macro flutter.widgets.SelectionOverlay.hideHandles}
456 void hideHandles() => _selectionOverlay.hideHandles();
457
458 /// {@macro flutter.widgets.SelectionOverlay.showToolbar}
459 void showToolbar() {
460 _updateSelectionOverlay();
461
462 if (selectionControls != null && selectionControls is! TextSelectionHandleControls) {
463 _selectionOverlay.showToolbar();
464 return;
465 }
466
467 if (contextMenuBuilder == null) {
468 return;
469 }
470
471 assert(context.mounted);
472 _selectionOverlay.showToolbar(context: context, contextMenuBuilder: contextMenuBuilder);
473 return;
474 }
475
476 /// Shows toolbar with spell check suggestions of misspelled words that are
477 /// available for click-and-replace.
478 void showSpellCheckSuggestionsToolbar(WidgetBuilder spellCheckSuggestionsToolbarBuilder) {
479 _updateSelectionOverlay();
480 assert(context.mounted);
481 _selectionOverlay.showSpellCheckSuggestionsToolbar(
482 context: context,
483 builder: spellCheckSuggestionsToolbarBuilder,
484 );
485 hideHandles();
486 }
487
488 /// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
489 void showMagnifier(Offset positionToShow) {
490 final TextPosition position = renderObject.getPositionForPoint(positionToShow);
491 _updateSelectionOverlay();
492 _selectionOverlay.showMagnifier(
493 _buildMagnifier(
494 currentTextPosition: position,
495 globalGesturePosition: positionToShow,
496 renderEditable: renderObject,
497 ),
498 );
499 }
500
501 /// {@macro flutter.widgets.SelectionOverlay.updateMagnifier}
502 void updateMagnifier(Offset positionToShow) {
503 final TextPosition position = renderObject.getPositionForPoint(positionToShow);
504 _updateSelectionOverlay();
505 _selectionOverlay.updateMagnifier(
506 _buildMagnifier(
507 currentTextPosition: position,
508 globalGesturePosition: positionToShow,
509 renderEditable: renderObject,
510 ),
511 );
512 }
513
514 /// {@macro flutter.widgets.SelectionOverlay.hideMagnifier}
515 void hideMagnifier() {
516 _selectionOverlay.hideMagnifier();
517 }
518
519 /// Updates the overlay after the selection has changed.
520 ///
521 /// If this method is called while the [SchedulerBinding.schedulerPhase] is
522 /// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
523 /// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
524 /// until the post-frame callbacks phase. Otherwise the update is done
525 /// synchronously. This means that it is safe to call during builds, but also
526 /// that if you do call this during a build, the UI will not update until the
527 /// next frame (i.e. many milliseconds later).
528 void update(TextEditingValue newValue) {
529 if (_value == newValue) {
530 return;
531 }
532 _value = newValue;
533 _updateSelectionOverlay();
534 // _updateSelectionOverlay may not rebuild the selection overlay if the
535 // text metrics and selection doesn't change even if the text has changed.
536 // This rebuild is needed for the toolbar to update based on the latest text
537 // value.
538 _selectionOverlay.markNeedsBuild();
539 }
540
541 void _updateSelectionOverlay() {
542 _selectionOverlay
543 // Update selection handle metrics.
544 ..startHandleType = _chooseType(
545 renderObject.textDirection,
546 TextSelectionHandleType.left,
547 TextSelectionHandleType.right,
548 )
549 ..lineHeightAtStart = _getStartGlyphHeight()
550 ..endHandleType = _chooseType(
551 renderObject.textDirection,
552 TextSelectionHandleType.right,
553 TextSelectionHandleType.left,
554 )
555 ..lineHeightAtEnd = _getEndGlyphHeight()
556 // Update selection toolbar metrics.
557 ..selectionEndpoints = renderObject.getEndpointsForSelection(_selection)
558 ..toolbarLocation = renderObject.lastSecondaryTapDownPosition;
559 }
560
561 /// Causes the overlay to update its rendering.
562 ///
563 /// This is intended to be called when the [renderObject] may have changed its
564 /// text metrics (e.g. because the text was scrolled).
565 void updateForScroll() {
566 _updateSelectionOverlay();
567 // This method may be called due to windows metrics changes. In that case,
568 // non of the properties in _selectionOverlay will change, but a rebuild is
569 // still needed.
570 _selectionOverlay.markNeedsBuild();
571 }
572
573 /// Whether the handles are currently visible.
574 bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
575
576 /// {@macro flutter.widgets.SelectionOverlay.toolbarIsVisible}
577 ///
578 /// See also:
579 ///
580 /// * [spellCheckToolbarIsVisible], which is only whether the spell check menu
581 /// specifically is visible.
582 bool get toolbarIsVisible => _selectionOverlay.toolbarIsVisible;
583
584 /// {@macro flutter.widgets.SelectionOverlay.magnifierIsVisible}
585 bool get magnifierIsVisible => _selectionOverlay.magnifierIsVisible;
586
587 /// {@macro flutter.widgets.SelectionOverlay.magnifierExists}
588 bool get magnifierExists => _selectionOverlay.magnifierExists;
589
590 /// Whether the spell check menu is currently visible.
591 ///
592 /// See also:
593 ///
594 /// * [toolbarIsVisible], which is whether any toolbar is visible.
595 bool get spellCheckToolbarIsVisible => _selectionOverlay._spellCheckToolbarController.isShown;
596
597 /// {@macro flutter.widgets.SelectionOverlay.hide}
598 void hide() => _selectionOverlay.hide();
599
600 /// {@macro flutter.widgets.SelectionOverlay.hideToolbar}
601 void hideToolbar() => _selectionOverlay.hideToolbar();
602
603 /// {@macro flutter.widgets.SelectionOverlay.dispose}
604 void dispose() {
605 assert(debugMaybeDispatchDisposed(this));
606 _selectionOverlay.dispose();
607 renderObject.selectionStartInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
608 renderObject.selectionEndInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
609 _effectiveToolbarVisibility.dispose();
610 _effectiveStartHandleVisibility.dispose();
611 _effectiveEndHandleVisibility.dispose();
612 hideToolbar();
613 }
614
615 double _getStartGlyphHeight() {
616 final String currText = selectionDelegate.textEditingValue.text;
617 final int firstSelectedGraphemeExtent;
618 Rect? startHandleRect;
619 // Only calculate handle rects if the text in the previous frame
620 // is the same as the text in the current frame. This is done because
621 // widget.renderObject contains the renderEditable from the previous frame.
622 // If the text changed between the current and previous frames then
623 // widget.renderObject.getRectForComposingRange might fail. In cases where
624 // the current frame is different from the previous we fall back to
625 // renderObject.preferredLineHeight.
626 if (renderObject.plainText == currText && _selection.isValid && !_selection.isCollapsed) {
627 final String selectedGraphemes = _selection.textInside(currText);
628 firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
629 startHandleRect = renderObject.getRectForComposingRange(
630 TextRange(start: _selection.start, end: _selection.start + firstSelectedGraphemeExtent),
631 );
632 }
633 return startHandleRect?.height ?? renderObject.preferredLineHeight;
634 }
635
636 double _getEndGlyphHeight() {
637 final String currText = selectionDelegate.textEditingValue.text;
638 final int lastSelectedGraphemeExtent;
639 Rect? endHandleRect;
640 // See the explanation in _getStartGlyphHeight.
641 if (renderObject.plainText == currText && _selection.isValid && !_selection.isCollapsed) {
642 final String selectedGraphemes = _selection.textInside(currText);
643 lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
644 endHandleRect = renderObject.getRectForComposingRange(
645 TextRange(start: _selection.end - lastSelectedGraphemeExtent, end: _selection.end),
646 );
647 }
648 return endHandleRect?.height ?? renderObject.preferredLineHeight;
649 }
650
651 MagnifierInfo _buildMagnifier({
652 required RenderEditable renderEditable,
653 required Offset globalGesturePosition,
654 required TextPosition currentTextPosition,
655 }) {
656 final TextSelection lineAtOffset = renderEditable.getLineAtOffset(currentTextPosition);
657 final TextPosition positionAtEndOfLine = TextPosition(
658 offset: lineAtOffset.extentOffset,
659 affinity: TextAffinity.upstream,
660 );
661
662 // Default affinity is downstream.
663 final TextPosition positionAtBeginningOfLine = TextPosition(offset: lineAtOffset.baseOffset);
664
665 final Rect localLineBoundaries = Rect.fromPoints(
666 renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter,
667 renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter,
668 );
669 final RenderBox? overlay =
670 Overlay.of(context, rootOverlay: true).context.findRenderObject() as RenderBox?;
671 final Matrix4 transformToOverlay = renderEditable.getTransformTo(overlay);
672 final Rect overlayLineBoundaries = MatrixUtils.transformRect(
673 transformToOverlay,
674 localLineBoundaries,
675 );
676
677 final Rect localCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition);
678 final Rect overlayCaretRect = MatrixUtils.transformRect(transformToOverlay, localCaretRect);
679
680 final Offset overlayGesturePosition =
681 overlay?.globalToLocal(globalGesturePosition) ?? globalGesturePosition;
682
683 return MagnifierInfo(
684 fieldBounds: MatrixUtils.transformRect(transformToOverlay, renderEditable.paintBounds),
685 globalGesturePosition: overlayGesturePosition,
686 caretRect: overlayCaretRect,
687 currentLineBoundaries: overlayLineBoundaries,
688 );
689 }
690
691 // The contact position of the gesture at the current end handle location, in
692 // global coordinates. Updated when the handle moves.
693 late double _endHandleDragPosition;
694
695 // The distance from _endHandleDragPosition to the center of the line that it
696 // corresponds to, in global coordinates.
697 late double _endHandleDragTarget;
698
699 // The initial selection when a selection handle drag has started.
700 //
701 // This is used on Apple platforms to:
702 //
703 // 1. Preserve a collapsed selection: if the selection was collapsed when the drag
704 // began, then it should remain collapsed throughout the entire drag.
705 // 2. Anchor the non-dragged end of a non-collapsed selection: On Apple platforms,
706 // the dragged handle always defines the selection's new extent. The drag start
707 // selection provides the original position for the selection's new base. This
708 // allows the selection handles to correctly swap their logical order (invert)
709 // during the drag.
710 TextSelection? _dragStartSelection;
711
712 void _handleSelectionEndHandleDragStart(DragStartDetails details) {
713 if (!renderObject.attached) {
714 return;
715 }
716
717 _endHandleDragPosition = details.globalPosition.dy;
718
719 // Use local coordinates when dealing with line height. because in case of a
720 // scale transformation, the line height will also be scaled.
721 final double centerOfLineLocal =
722 _selectionOverlay.selectionEndpoints.last.point.dy - renderObject.preferredLineHeight / 2;
723 final double centerOfLineGlobal = renderObject.localToGlobal(Offset(0.0, centerOfLineLocal)).dy;
724 _endHandleDragTarget = centerOfLineGlobal - details.globalPosition.dy;
725 // Instead of finding the TextPosition at the handle's location directly,
726 // use the vertical center of the line that it points to. This is because
727 // selection handles typically hang above or below the line that they point
728 // to.
729 final TextPosition position = renderObject.getPositionForPoint(
730 Offset(details.globalPosition.dx, centerOfLineGlobal),
731 );
732
733 // The drag start selection is only utilized on Apple platforms.
734 if (defaultTargetPlatform == TargetPlatform.iOS ||
735 defaultTargetPlatform == TargetPlatform.macOS) {
736 _dragStartSelection ??= _selection;
737 }
738
739 _selectionOverlay.showMagnifier(
740 _buildMagnifier(
741 currentTextPosition: position,
742 globalGesturePosition: details.globalPosition,
743 renderEditable: renderObject,
744 ),
745 );
746 }
747
748 /// Given a handle position and drag position, returns the position of handle
749 /// after the drag.
750 ///
751 /// The handle jumps instantly between lines when the drag reaches a full
752 /// line's height away from the original handle position. In other words, the
753 /// line jump happens when the contact point would be located at the same
754 /// place on the handle at the new line as when the gesture started, for both
755 /// directions.
756 ///
757 /// This is not the same as just maintaining an offset from the target and the
758 /// contact point. There is no point at which moving the drag up and down a
759 /// small sub-line-height distance will cause the cursor to jump up and down
760 /// between lines. The drag distance must be a full line height for the cursor
761 /// to change lines, for both directions.
762 ///
763 /// Both parameters must be in local coordinates because the untransformed
764 /// line height is used, and the return value is in local coordinates as well.
765 double _getHandleDy(double dragDy, double handleDy) {
766 final double distanceDragged = dragDy - handleDy;
767 final int dragDirection = distanceDragged < 0.0 ? -1 : 1;
768 final int linesDragged =
769 dragDirection * (distanceDragged.abs() / renderObject.preferredLineHeight).floor();
770 return handleDy + linesDragged * renderObject.preferredLineHeight;
771 }
772
773 void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
774 if (!renderObject.attached) {
775 return;
776 }
777
778 // This is NOT the same as details.localPosition. That is relative to the
779 // selection handle, whereas this is relative to the RenderEditable.
780 final Offset localPosition = renderObject.globalToLocal(details.globalPosition);
781
782 final double nextEndHandleDragPositionLocal = _getHandleDy(
783 localPosition.dy,
784 renderObject.globalToLocal(Offset(0.0, _endHandleDragPosition)).dy,
785 );
786 _endHandleDragPosition = renderObject
787 .localToGlobal(Offset(0.0, nextEndHandleDragPositionLocal))
788 .dy;
789
790 final Offset handleTargetGlobal = Offset(
791 details.globalPosition.dx,
792 _endHandleDragPosition + _endHandleDragTarget,
793 );
794
795 final TextPosition position = renderObject.getPositionForPoint(handleTargetGlobal);
796
797 final TextSelection newSelection;
798 switch (defaultTargetPlatform) {
799 // On Apple platforms, dragging the base handle makes it the extent.
800 case TargetPlatform.iOS:
801 case TargetPlatform.macOS:
802 assert(_dragStartSelection != null);
803 if (_dragStartSelection!.isCollapsed) {
804 _selectionOverlay.updateMagnifier(
805 _buildMagnifier(
806 currentTextPosition: position,
807 globalGesturePosition: details.globalPosition,
808 renderEditable: renderObject,
809 ),
810 );
811
812 final TextSelection currentSelection = TextSelection.fromPosition(position);
813 _handleSelectionHandleChanged(currentSelection);
814 return;
815 }
816 // Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized
817 // always returns true for a TextSelection.
818 final bool dragStartSelectionNormalized =
819 _dragStartSelection!.extentOffset >= _dragStartSelection!.baseOffset;
820 newSelection = TextSelection(
821 baseOffset: dragStartSelectionNormalized
822 ? _dragStartSelection!.baseOffset
823 : _dragStartSelection!.extentOffset,
824 extentOffset: position.offset,
825 );
826 case TargetPlatform.android:
827 case TargetPlatform.fuchsia:
828 case TargetPlatform.linux:
829 case TargetPlatform.windows:
830 if (_selection.isCollapsed) {
831 _selectionOverlay.updateMagnifier(
832 _buildMagnifier(
833 currentTextPosition: position,
834 globalGesturePosition: details.globalPosition,
835 renderEditable: renderObject,
836 ),
837 );
838
839 final TextSelection currentSelection = TextSelection.fromPosition(position);
840 _handleSelectionHandleChanged(currentSelection);
841 return;
842 }
843 newSelection = TextSelection(
844 baseOffset: _selection.baseOffset,
845 extentOffset: position.offset,
846 );
847 if (newSelection.baseOffset >= newSelection.extentOffset) {
848 return; // Don't allow order swapping.
849 }
850 }
851
852 _handleSelectionHandleChanged(newSelection);
853
854 _selectionOverlay.updateMagnifier(
855 _buildMagnifier(
856 currentTextPosition: newSelection.extent,
857 globalGesturePosition: details.globalPosition,
858 renderEditable: renderObject,
859 ),
860 );
861 }
862
863 // The contact position of the gesture at the current start handle location,
864 // in global coordinates. Updated when the handle moves.
865 late double _startHandleDragPosition;
866
867 // The distance from _startHandleDragPosition to the center of the line that
868 // it corresponds to, in global coordinates.
869 late double _startHandleDragTarget;
870
871 void _handleSelectionStartHandleDragStart(DragStartDetails details) {
872 if (!renderObject.attached) {
873 return;
874 }
875
876 _startHandleDragPosition = details.globalPosition.dy;
877
878 // Use local coordinates when dealing with line height. because in case of a
879 // scale transformation, the line height will also be scaled.
880 final double centerOfLineLocal =
881 _selectionOverlay.selectionEndpoints.first.point.dy - renderObject.preferredLineHeight / 2;
882 final double centerOfLineGlobal = renderObject.localToGlobal(Offset(0.0, centerOfLineLocal)).dy;
883 _startHandleDragTarget = centerOfLineGlobal - details.globalPosition.dy;
884 // Instead of finding the TextPosition at the handle's location directly,
885 // use the vertical center of the line that it points to. This is because
886 // selection handles typically hang above or below the line that they point
887 // to.
888 final TextPosition position = renderObject.getPositionForPoint(
889 Offset(details.globalPosition.dx, centerOfLineGlobal),
890 );
891
892 // The drag start selection is only utilized on Apple platforms.
893 if (defaultTargetPlatform == TargetPlatform.iOS ||
894 defaultTargetPlatform == TargetPlatform.macOS) {
895 _dragStartSelection ??= _selection;
896 }
897
898 _selectionOverlay.showMagnifier(
899 _buildMagnifier(
900 currentTextPosition: position,
901 globalGesturePosition: details.globalPosition,
902 renderEditable: renderObject,
903 ),
904 );
905 }
906
907 void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
908 if (!renderObject.attached) {
909 return;
910 }
911
912 // This is NOT the same as details.localPosition. That is relative to the
913 // selection handle, whereas this is relative to the RenderEditable.
914 final Offset localPosition = renderObject.globalToLocal(details.globalPosition);
915 final double nextStartHandleDragPositionLocal = _getHandleDy(
916 localPosition.dy,
917 renderObject.globalToLocal(Offset(0.0, _startHandleDragPosition)).dy,
918 );
919 _startHandleDragPosition = renderObject
920 .localToGlobal(Offset(0.0, nextStartHandleDragPositionLocal))
921 .dy;
922 final Offset handleTargetGlobal = Offset(
923 details.globalPosition.dx,
924 _startHandleDragPosition + _startHandleDragTarget,
925 );
926 final TextPosition position = renderObject.getPositionForPoint(handleTargetGlobal);
927
928 final TextSelection newSelection;
929 switch (defaultTargetPlatform) {
930 // On Apple platforms, dragging the base handle makes it the extent.
931 case TargetPlatform.iOS:
932 case TargetPlatform.macOS:
933 assert(_dragStartSelection != null);
934 if (_dragStartSelection!.isCollapsed) {
935 _selectionOverlay.updateMagnifier(
936 _buildMagnifier(
937 currentTextPosition: position,
938 globalGesturePosition: details.globalPosition,
939 renderEditable: renderObject,
940 ),
941 );
942
943 final TextSelection currentSelection = TextSelection.fromPosition(position);
944 _handleSelectionHandleChanged(currentSelection);
945 return;
946 }
947 // Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized
948 // always returns true for a TextSelection.
949 final bool dragStartSelectionNormalized =
950 _dragStartSelection!.extentOffset >= _dragStartSelection!.baseOffset;
951 newSelection = TextSelection(
952 baseOffset: dragStartSelectionNormalized
953 ? _dragStartSelection!.extentOffset
954 : _dragStartSelection!.baseOffset,
955 extentOffset: position.offset,
956 );
957 case TargetPlatform.android:
958 case TargetPlatform.fuchsia:
959 case TargetPlatform.linux:
960 case TargetPlatform.windows:
961 if (_selection.isCollapsed) {
962 _selectionOverlay.updateMagnifier(
963 _buildMagnifier(
964 currentTextPosition: position,
965 globalGesturePosition: details.globalPosition,
966 renderEditable: renderObject,
967 ),
968 );
969
970 final TextSelection currentSelection = TextSelection.fromPosition(position);
971 _handleSelectionHandleChanged(currentSelection);
972 return;
973 }
974 newSelection = TextSelection(
975 baseOffset: position.offset,
976 extentOffset: _selection.extentOffset,
977 );
978 if (newSelection.baseOffset >= newSelection.extentOffset) {
979 return; // Don't allow order swapping.
980 }
981 }
982
983 _selectionOverlay.updateMagnifier(
984 _buildMagnifier(
985 currentTextPosition: newSelection.extent.offset < newSelection.base.offset
986 ? newSelection.extent
987 : newSelection.base,
988 globalGesturePosition: details.globalPosition,
989 renderEditable: renderObject,
990 ),
991 );
992
993 _handleSelectionHandleChanged(newSelection);
994 }
995
996 void _handleAnyDragEnd(DragEndDetails details) {
997 if (!context.mounted) {
998 return;
999 }
1000 _dragStartSelection = null;
1001 final bool draggingHandles =
1002 _selectionOverlay.isDraggingStartHandle || _selectionOverlay.isDraggingEndHandle;
1003 if (selectionControls is! TextSelectionHandleControls) {
1004 if (!draggingHandles) {
1005 _selectionOverlay.hideMagnifier();
1006 if (!_selection.isCollapsed) {
1007 _selectionOverlay.showToolbar();
1008 }
1009 }
1010 return;
1011 }
1012 if (!draggingHandles) {
1013 _selectionOverlay.hideMagnifier();
1014 if (!_selection.isCollapsed) {
1015 _selectionOverlay.showToolbar(context: context, contextMenuBuilder: contextMenuBuilder);
1016 }
1017 }
1018 }
1019
1020 void _handleSelectionHandleChanged(TextSelection newSelection) {
1021 selectionDelegate.userUpdateTextEditingValue(
1022 _value.copyWith(selection: newSelection),
1023 SelectionChangedCause.drag,
1024 );
1025 }
1026
1027 TextSelectionHandleType _chooseType(
1028 TextDirection textDirection,
1029 TextSelectionHandleType ltrType,
1030 TextSelectionHandleType rtlType,
1031 ) {
1032 if (_selection.isCollapsed) {
1033 return TextSelectionHandleType.collapsed;
1034 }
1035
1036 return switch (textDirection) {
1037 TextDirection.ltr => ltrType,
1038 TextDirection.rtl => rtlType,
1039 };
1040 }
1041}
1042
1043/// An object that manages a pair of selection handles and a toolbar.
1044///
1045/// The selection handles are displayed in the [Overlay] that most closely
1046/// encloses the given [BuildContext].
1047class SelectionOverlay {
1048 /// Creates an object that manages overlay entries for selection handles.
1049 ///
1050 /// The [context] must have an [Overlay] as an ancestor.
1051 SelectionOverlay({
1052 required this.context,
1053 this.debugRequiredFor,
1054 required TextSelectionHandleType startHandleType,
1055 required double lineHeightAtStart,
1056 this.startHandlesVisible,
1057 this.onStartHandleDragStart,
1058 this.onStartHandleDragUpdate,
1059 this.onStartHandleDragEnd,
1060 required TextSelectionHandleType endHandleType,
1061 required double lineHeightAtEnd,
1062 this.endHandlesVisible,
1063 this.onEndHandleDragStart,
1064 this.onEndHandleDragUpdate,
1065 this.onEndHandleDragEnd,
1066 this.toolbarVisible,
1067 required List<TextSelectionPoint> selectionEndpoints,
1068 required this.selectionControls,
1069 @Deprecated(
1070 'Use `contextMenuBuilder` in `showToolbar` instead. '
1071 'This feature was deprecated after v3.3.0-0.5.pre.',
1072 )
1073 required this.selectionDelegate,
1074 required this.clipboardStatus,
1075 required this.startHandleLayerLink,
1076 required this.endHandleLayerLink,
1077 required this.toolbarLayerLink,
1078 this.dragStartBehavior = DragStartBehavior.start,
1079 this.onSelectionHandleTapped,
1080 @Deprecated(
1081 'Use `contextMenuBuilder` in `showToolbar` instead. '
1082 'This feature was deprecated after v3.3.0-0.5.pre.',
1083 )
1084 Offset? toolbarLocation,
1085 this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
1086 }) : _startHandleType = startHandleType,
1087 _lineHeightAtStart = lineHeightAtStart,
1088 _endHandleType = endHandleType,
1089 _lineHeightAtEnd = lineHeightAtEnd,
1090 _selectionEndpoints = selectionEndpoints,
1091 _toolbarLocation = toolbarLocation,
1092 assert(debugCheckHasOverlay(context)) {
1093 assert(debugMaybeDispatchCreated('widgets', 'SelectionOverlay', this));
1094 }
1095
1096 /// {@macro flutter.widgets.SelectionOverlay.context}
1097 final BuildContext context;
1098
1099 final ValueNotifier<MagnifierInfo> _magnifierInfo = ValueNotifier<MagnifierInfo>(
1100 MagnifierInfo.empty,
1101 );
1102
1103 // [MagnifierController.show] and [MagnifierController.hide] should not be
1104 // called directly, except from inside [showMagnifier] and [hideMagnifier]. If
1105 // it is desired to show or hide the magnifier, call [showMagnifier] or
1106 // [hideMagnifier]. This is because the magnifier needs to orchestrate with
1107 // other properties in [SelectionOverlay].
1108 final MagnifierController _magnifierController = MagnifierController();
1109
1110 /// The configuration for the magnifier.
1111 ///
1112 /// By default, [SelectionOverlay]'s [TextMagnifierConfiguration] is disabled.
1113 ///
1114 /// {@macro flutter.widgets.magnifier.intro}
1115 final TextMagnifierConfiguration magnifierConfiguration;
1116
1117 /// {@template flutter.widgets.SelectionOverlay.toolbarIsVisible}
1118 /// Whether the toolbar is currently visible.
1119 ///
1120 /// Includes both the text selection toolbar and the spell check menu.
1121 /// {@endtemplate}
1122 bool get toolbarIsVisible {
1123 return selectionControls is TextSelectionHandleControls
1124 ? _contextMenuController.isShown || _spellCheckToolbarController.isShown
1125 : _toolbar != null || _spellCheckToolbarController.isShown;
1126 }
1127
1128 /// {@template flutter.widgets.SelectionOverlay.magnifierIsVisible}
1129 /// Whether the magnifier is currently visible.
1130 /// {@endtemplate}
1131 bool get magnifierIsVisible => _magnifierController.shown;
1132
1133 /// {@template flutter.widgets.SelectionOverlay.magnifierExists}
1134 /// Whether the magnifier currently exists.
1135 ///
1136 /// This differs from [magnifierIsVisible] in that the magnifier may exist
1137 /// in the overlay, but not be shown.
1138 /// {@endtemplate}
1139 bool get magnifierExists => _magnifierController.overlayEntry != null;
1140
1141 /// {@template flutter.widgets.SelectionOverlay.showMagnifier}
1142 /// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier]
1143 /// was called. This is safe to call on platforms not mobile, since
1144 /// a magnifierBuilder will not be provided, or the magnifierBuilder will return null
1145 /// on platforms not mobile.
1146 ///
1147 /// This is NOT the source of truth for if the magnifier is up or not,
1148 /// since magnifiers may hide themselves. If this info is needed, check
1149 /// [MagnifierController.shown].
1150 /// {@endtemplate}
1151 void showMagnifier(MagnifierInfo initialMagnifierInfo) {
1152 // Do not show the magnifier if one already exists.
1153 if (_magnifierController.overlayEntry != null) {
1154 return;
1155 }
1156 if (toolbarIsVisible) {
1157 hideToolbar();
1158 }
1159
1160 // Start from empty, so we don't utilize any remnant values.
1161 _magnifierInfo.value = initialMagnifierInfo;
1162
1163 // Pre-build the magnifiers so we can tell if we've built something
1164 // or not. If we don't build a magnifiers, then we should not
1165 // insert anything in the overlay.
1166 final Widget? builtMagnifier = magnifierConfiguration.magnifierBuilder(
1167 context,
1168 _magnifierController,
1169 _magnifierInfo,
1170 );
1171
1172 if (builtMagnifier == null) {
1173 return;
1174 }
1175
1176 _magnifierController.show(
1177 context: context,
1178 below: magnifierConfiguration.shouldDisplayHandlesInMagnifier ? null : _handles?.start,
1179 builder: (_) => builtMagnifier,
1180 );
1181 }
1182
1183 /// {@template flutter.widgets.SelectionOverlay.hideMagnifier}
1184 /// Hide the current magnifier.
1185 ///
1186 /// This does nothing if there is no magnifier.
1187 /// {@endtemplate}
1188 void hideMagnifier() {
1189 // This cannot be a check on `MagnifierController.shown`, since
1190 // it's possible that the magnifier is still in the overlay, but
1191 // not shown in cases where the magnifier hides itself.
1192 if (_magnifierController.overlayEntry == null) {
1193 return;
1194 }
1195
1196 _magnifierController.hide();
1197 }
1198
1199 /// The type of start selection handle.
1200 ///
1201 /// Changing the value while the handles are visible causes them to rebuild.
1202 TextSelectionHandleType get startHandleType => _startHandleType;
1203 TextSelectionHandleType _startHandleType;
1204 set startHandleType(TextSelectionHandleType value) {
1205 if (_startHandleType == value) {
1206 return;
1207 }
1208 _startHandleType = value;
1209 markNeedsBuild();
1210 }
1211
1212 /// The line height at the selection start.
1213 ///
1214 /// This value is used for calculating the size of the start selection handle.
1215 ///
1216 /// Changing the value while the handles are visible causes them to rebuild.
1217 double get lineHeightAtStart => _lineHeightAtStart;
1218 double _lineHeightAtStart;
1219 set lineHeightAtStart(double value) {
1220 if (_lineHeightAtStart == value) {
1221 return;
1222 }
1223 _lineHeightAtStart = value;
1224 markNeedsBuild();
1225 }
1226
1227 // Whether a drag is in progress on the start handle. This differs from
1228 // `_isDraggingStartHandle` in that it is not blocked by `_canDragStartHandle`.
1229 bool _startHandleDragInProgress = false;
1230
1231 /// Whether the selection start handle is currently being dragged.
1232 bool get isDraggingStartHandle => _isDraggingStartHandle || _startHandleDragInProgress;
1233 bool _isDraggingStartHandle = false;
1234
1235 // Whether the start handle can be dragged.
1236 //
1237 // On Apple and web platforms only one selection handle can be dragged
1238 // at a time, so when the end handle is being dragged on these platforms
1239 // the the start handle cannot be dragged.
1240 bool get _canDragStartHandle =>
1241 !_isDraggingEndHandle ||
1242 (defaultTargetPlatform != TargetPlatform.iOS &&
1243 defaultTargetPlatform != TargetPlatform.macOS &&
1244 !kIsWeb);
1245
1246 /// Whether the start handle is visible.
1247 ///
1248 /// If the value changes, the start handle uses [FadeTransition] to transition
1249 /// itself on and off the screen.
1250 ///
1251 /// If this is null, the start selection handle will always be visible.
1252 final ValueListenable<bool>? startHandlesVisible;
1253
1254 /// Called when the users start dragging the start selection handles.
1255 final ValueChanged<DragStartDetails>? onStartHandleDragStart;
1256
1257 void _handleStartHandleDragStart(DragStartDetails details) {
1258 assert(!_isDraggingStartHandle);
1259 // Calling OverlayEntry.remove may not happen until the following frame, so
1260 // it's possible for the handles to receive a gesture after calling remove.
1261 if (_handles == null) {
1262 _isDraggingStartHandle = false;
1263 return;
1264 }
1265 _startHandleDragInProgress = true;
1266 if (!_canDragStartHandle) {
1267 return;
1268 }
1269 _isDraggingStartHandle = details.kind == PointerDeviceKind.touch;
1270 onStartHandleDragStart?.call(details);
1271 }
1272
1273 void _handleStartHandleDragUpdate(DragUpdateDetails details) {
1274 // Calling OverlayEntry.remove may not happen until the following frame, so
1275 // it's possible for the handles to receive a gesture after calling remove.
1276 if (_handles == null) {
1277 _isDraggingStartHandle = false;
1278 return;
1279 }
1280 if (!_canDragStartHandle) {
1281 return;
1282 }
1283 // The handle drag may have been blocked before on Apple platforms and the web
1284 // while the opposite handle was being dragged. Ensure that any logic that was
1285 // meant to be run in onStartHandleDragStart is still run.
1286 if (!_isDraggingStartHandle) {
1287 _isDraggingStartHandle = details.kind == PointerDeviceKind.touch;
1288 final DragStartDetails startDetails = DragStartDetails(
1289 globalPosition: details.globalPosition,
1290 localPosition: details.localPosition,
1291 sourceTimeStamp: details.sourceTimeStamp,
1292 kind: details.kind,
1293 );
1294 onStartHandleDragStart?.call(startDetails);
1295 }
1296 onStartHandleDragUpdate?.call(details);
1297 }
1298
1299 /// Called when the users drag the start selection handles to new locations.
1300 final ValueChanged<DragUpdateDetails>? onStartHandleDragUpdate;
1301
1302 /// Called when the users lift their fingers after dragging the start selection
1303 /// handles.
1304 final ValueChanged<DragEndDetails>? onStartHandleDragEnd;
1305
1306 void _handleStartHandleDragEnd(DragEndDetails details) {
1307 _isDraggingStartHandle = false;
1308 // Calling OverlayEntry.remove may not happen until the following frame, so
1309 // it's possible for the handles to receive a gesture after calling remove.
1310 if (_handles == null) {
1311 return;
1312 }
1313 _startHandleDragInProgress = false;
1314 if (!_canDragStartHandle) {
1315 return;
1316 }
1317 onStartHandleDragEnd?.call(details);
1318 }
1319
1320 /// The type of end selection handle.
1321 ///
1322 /// Changing the value while the handles are visible causes them to rebuild.
1323 TextSelectionHandleType get endHandleType => _endHandleType;
1324 TextSelectionHandleType _endHandleType;
1325 set endHandleType(TextSelectionHandleType value) {
1326 if (_endHandleType == value) {
1327 return;
1328 }
1329 _endHandleType = value;
1330 markNeedsBuild();
1331 }
1332
1333 /// The line height at the selection end.
1334 ///
1335 /// This value is used for calculating the size of the end selection handle.
1336 ///
1337 /// Changing the value while the handles are visible causes them to rebuild.
1338 double get lineHeightAtEnd => _lineHeightAtEnd;
1339 double _lineHeightAtEnd;
1340 set lineHeightAtEnd(double value) {
1341 if (_lineHeightAtEnd == value) {
1342 return;
1343 }
1344 _lineHeightAtEnd = value;
1345 markNeedsBuild();
1346 }
1347
1348 // Whether a drag is in progress on the start handle. This differs from
1349 // `_isDraggingEndHandle` in that it is not blocked by `_canDragEndHandle`.
1350 bool _endHandleDragInProgress = false;
1351
1352 /// Whether the selection end handle is currently being dragged.
1353 bool get isDraggingEndHandle => _isDraggingEndHandle || _endHandleDragInProgress;
1354 bool _isDraggingEndHandle = false;
1355
1356 // Whether the end handle can be dragged.
1357 //
1358 // On Apple and web platforms only one selection handle can be dragged
1359 // at a time, so when the start handle is being dragged on these platforms
1360 // the the end handle cannot be dragged.
1361 bool get _canDragEndHandle =>
1362 !_isDraggingStartHandle ||
1363 (defaultTargetPlatform != TargetPlatform.iOS &&
1364 defaultTargetPlatform != TargetPlatform.macOS &&
1365 !kIsWeb);
1366
1367 /// Whether the end handle is visible.
1368 ///
1369 /// If the value changes, the end handle uses [FadeTransition] to transition
1370 /// itself on and off the screen.
1371 ///
1372 /// If this is null, the end selection handle will always be visible.
1373 final ValueListenable<bool>? endHandlesVisible;
1374
1375 /// Called when the users start dragging the end selection handles.
1376 final ValueChanged<DragStartDetails>? onEndHandleDragStart;
1377
1378 void _handleEndHandleDragStart(DragStartDetails details) {
1379 assert(!_isDraggingEndHandle);
1380 // Calling OverlayEntry.remove may not happen until the following frame, so
1381 // it's possible for the handles to receive a gesture after calling remove.
1382 if (_handles == null) {
1383 _isDraggingEndHandle = false;
1384 return;
1385 }
1386 _endHandleDragInProgress = true;
1387 if (!_canDragEndHandle) {
1388 return;
1389 }
1390 _isDraggingEndHandle = details.kind == PointerDeviceKind.touch;
1391 onEndHandleDragStart?.call(details);
1392 }
1393
1394 void _handleEndHandleDragUpdate(DragUpdateDetails details) {
1395 // Calling OverlayEntry.remove may not happen until the following frame, so
1396 // it's possible for the handles to receive a gesture after calling remove.
1397 if (_handles == null) {
1398 _isDraggingEndHandle = false;
1399 return;
1400 }
1401 if (!_canDragEndHandle) {
1402 return;
1403 }
1404 // The handle drag may have been blocked before on Apple platforms and the web
1405 // while the opposite handle was being dragged. Ensure that any logic that was
1406 // meant to be run in onStartHandleDragStart is still run.
1407 if (!_isDraggingEndHandle) {
1408 _isDraggingEndHandle = details.kind == PointerDeviceKind.touch;
1409 final DragStartDetails startDetails = DragStartDetails(
1410 globalPosition: details.globalPosition,
1411 localPosition: details.localPosition,
1412 sourceTimeStamp: details.sourceTimeStamp,
1413 kind: details.kind,
1414 );
1415 onEndHandleDragStart?.call(startDetails);
1416 }
1417 onEndHandleDragUpdate?.call(details);
1418 }
1419
1420 /// Called when the users drag the end selection handles to new locations.
1421 final ValueChanged<DragUpdateDetails>? onEndHandleDragUpdate;
1422
1423 /// Called when the users lift their fingers after dragging the end selection
1424 /// handles.
1425 final ValueChanged<DragEndDetails>? onEndHandleDragEnd;
1426
1427 void _handleEndHandleDragEnd(DragEndDetails details) {
1428 _isDraggingEndHandle = false;
1429 // Calling OverlayEntry.remove may not happen until the following frame, so
1430 // it's possible for the handles to receive a gesture after calling remove.
1431 if (_handles == null) {
1432 return;
1433 }
1434 _endHandleDragInProgress = false;
1435 if (!_canDragEndHandle) {
1436 return;
1437 }
1438 onEndHandleDragEnd?.call(details);
1439 }
1440
1441 /// Whether the toolbar is visible.
1442 ///
1443 /// If the value changes, the toolbar uses [FadeTransition] to transition
1444 /// itself on and off the screen.
1445 ///
1446 /// If this is null the toolbar will always be visible.
1447 final ValueListenable<bool>? toolbarVisible;
1448
1449 /// The text selection positions of selection start and end.
1450 List<TextSelectionPoint> get selectionEndpoints => _selectionEndpoints;
1451 List<TextSelectionPoint> _selectionEndpoints;
1452 set selectionEndpoints(List<TextSelectionPoint> value) {
1453 if (!listEquals(_selectionEndpoints, value)) {
1454 markNeedsBuild();
1455 if (_isDraggingEndHandle || _isDraggingStartHandle) {
1456 switch (defaultTargetPlatform) {
1457 case TargetPlatform.android:
1458 HapticFeedback.selectionClick();
1459 case TargetPlatform.fuchsia:
1460 case TargetPlatform.iOS:
1461 case TargetPlatform.linux:
1462 case TargetPlatform.macOS:
1463 case TargetPlatform.windows:
1464 break;
1465 }
1466 }
1467 }
1468 _selectionEndpoints = value;
1469 }
1470
1471 /// Debugging information for explaining why the [Overlay] is required.
1472 final Widget? debugRequiredFor;
1473
1474 /// The object supplied to the [CompositedTransformTarget] that wraps the text
1475 /// field.
1476 final LayerLink toolbarLayerLink;
1477
1478 /// The objects supplied to the [CompositedTransformTarget] that wraps the
1479 /// location of start selection handle.
1480 final LayerLink startHandleLayerLink;
1481
1482 /// The objects supplied to the [CompositedTransformTarget] that wraps the
1483 /// location of end selection handle.
1484 final LayerLink endHandleLayerLink;
1485
1486 /// {@template flutter.widgets.SelectionOverlay.selectionControls}
1487 /// Builds text selection handles and toolbar.
1488 /// {@endtemplate}
1489 final TextSelectionControls? selectionControls;
1490
1491 /// {@template flutter.widgets.SelectionOverlay.selectionDelegate}
1492 /// The delegate for manipulating the current selection in the owning
1493 /// text field.
1494 /// {@endtemplate}
1495 @Deprecated(
1496 'Use `contextMenuBuilder` instead. '
1497 'This feature was deprecated after v3.3.0-0.5.pre.',
1498 )
1499 final TextSelectionDelegate? selectionDelegate;
1500
1501 /// Determines the way that drag start behavior is handled.
1502 ///
1503 /// If set to [DragStartBehavior.start], handle drag behavior will
1504 /// begin at the position where the drag gesture won the arena. If set to
1505 /// [DragStartBehavior.down] it will begin at the position where a down
1506 /// event is first detected.
1507 ///
1508 /// In general, setting this to [DragStartBehavior.start] will make drag
1509 /// animation smoother and setting it to [DragStartBehavior.down] will make
1510 /// drag behavior feel slightly more reactive.
1511 ///
1512 /// By default, the drag start behavior is [DragStartBehavior.start].
1513 ///
1514 /// See also:
1515 ///
1516 /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors.
1517 final DragStartBehavior dragStartBehavior;
1518
1519 /// {@template flutter.widgets.SelectionOverlay.onSelectionHandleTapped}
1520 /// A callback that's optionally invoked when a selection handle is tapped.
1521 ///
1522 /// The [TextSelectionControls.buildHandle] implementation the text field
1523 /// uses decides where the handle's tap "hotspot" is, or whether the
1524 /// selection handle supports tap gestures at all. For instance,
1525 /// [MaterialTextSelectionControls] calls [onSelectionHandleTapped] when the
1526 /// selection handle's "knob" is tapped, while
1527 /// [CupertinoTextSelectionControls] builds a handle that's not sufficiently
1528 /// large for tapping (as it's not meant to be tapped) so it does not call
1529 /// [onSelectionHandleTapped] even when tapped.
1530 /// {@endtemplate}
1531 // See https://github.com/flutter/flutter/issues/39376#issuecomment-848406415
1532 // for provenance.
1533 final VoidCallback? onSelectionHandleTapped;
1534
1535 /// Maintains the status of the clipboard for determining if its contents can
1536 /// be pasted or not.
1537 ///
1538 /// Useful because the actual value of the clipboard can only be checked
1539 /// asynchronously (see [Clipboard.getData]).
1540 final ClipboardStatusNotifier? clipboardStatus;
1541
1542 /// The location of where the toolbar should be drawn in relative to the
1543 /// location of [toolbarLayerLink].
1544 ///
1545 /// If this is null, the toolbar is drawn based on [selectionEndpoints] and
1546 /// the rect of render object of [context].
1547 ///
1548 /// This is useful for displaying toolbars at the mouse right-click locations
1549 /// in desktop devices.
1550 @Deprecated(
1551 'Use the `contextMenuBuilder` parameter in `showToolbar` instead. '
1552 'This feature was deprecated after v3.3.0-0.5.pre.',
1553 )
1554 Offset? get toolbarLocation => _toolbarLocation;
1555 Offset? _toolbarLocation;
1556 set toolbarLocation(Offset? value) {
1557 if (_toolbarLocation == value) {
1558 return;
1559 }
1560 _toolbarLocation = value;
1561 markNeedsBuild();
1562 }
1563
1564 /// Controls the fade-in and fade-out animations for the toolbar and handles.
1565 static const Duration fadeDuration = Duration(milliseconds: 150);
1566
1567 /// A pair of handles. If this is non-null, there are always 2, though the
1568 /// second is hidden when the selection is collapsed.
1569 ({OverlayEntry start, OverlayEntry end})? _handles;
1570
1571 /// A copy/paste toolbar.
1572 OverlayEntry? _toolbar;
1573
1574 // Manages the context menu. Not necessarily visible when non-null.
1575 final ContextMenuController _contextMenuController = ContextMenuController();
1576
1577 final ContextMenuController _spellCheckToolbarController = ContextMenuController();
1578
1579 /// {@template flutter.widgets.SelectionOverlay.showHandles}
1580 /// Builds the handles by inserting them into the [context]'s overlay.
1581 /// {@endtemplate}
1582 void showHandles() {
1583 if (_handles != null) {
1584 return;
1585 }
1586
1587 final OverlayState overlay = Overlay.of(
1588 context,
1589 rootOverlay: true,
1590 debugRequiredFor: debugRequiredFor,
1591 );
1592
1593 final CapturedThemes capturedThemes = InheritedTheme.capture(
1594 from: context,
1595 to: overlay.context,
1596 );
1597
1598 _handles = (
1599 start: OverlayEntry(
1600 builder: (BuildContext context) {
1601 return capturedThemes.wrap(_buildStartHandle(context));
1602 },
1603 ),
1604 end: OverlayEntry(
1605 builder: (BuildContext context) {
1606 return capturedThemes.wrap(_buildEndHandle(context));
1607 },
1608 ),
1609 );
1610 overlay.insertAll(<OverlayEntry>[_handles!.start, _handles!.end]);
1611 }
1612
1613 /// {@template flutter.widgets.SelectionOverlay.hideHandles}
1614 /// Destroys the handles by removing them from overlay.
1615 /// {@endtemplate}
1616 void hideHandles() {
1617 if (_handles != null) {
1618 _handles!.start.remove();
1619 _handles!.start.dispose();
1620 _handles!.end.remove();
1621 _handles!.end.dispose();
1622 _handles = null;
1623 }
1624 }
1625
1626 /// {@template flutter.widgets.SelectionOverlay.showToolbar}
1627 /// Shows the toolbar by inserting it into the [context]'s overlay.
1628 /// {@endtemplate}
1629 void showToolbar({BuildContext? context, WidgetBuilder? contextMenuBuilder}) {
1630 if (contextMenuBuilder == null) {
1631 if (_toolbar != null) {
1632 return;
1633 }
1634 _toolbar = OverlayEntry(builder: _buildToolbar);
1635 Overlay.of(
1636 this.context,
1637 rootOverlay: true,
1638 debugRequiredFor: debugRequiredFor,
1639 ).insert(_toolbar!);
1640 return;
1641 }
1642
1643 if (context == null) {
1644 return;
1645 }
1646
1647 final RenderBox renderBox = context.findRenderObject()! as RenderBox;
1648 _contextMenuController.show(
1649 context: context,
1650 contextMenuBuilder: (BuildContext context) {
1651 return _SelectionToolbarWrapper(
1652 visibility: toolbarVisible,
1653 layerLink: toolbarLayerLink,
1654 offset: -renderBox.localToGlobal(Offset.zero),
1655 child: contextMenuBuilder(context),
1656 );
1657 },
1658 );
1659 }
1660
1661 /// Shows toolbar with spell check suggestions of misspelled words that are
1662 /// available for click-and-replace.
1663 void showSpellCheckSuggestionsToolbar({BuildContext? context, required WidgetBuilder builder}) {
1664 if (context == null) {
1665 return;
1666 }
1667
1668 final RenderBox renderBox = context.findRenderObject()! as RenderBox;
1669 _spellCheckToolbarController.show(
1670 context: context,
1671 contextMenuBuilder: (BuildContext context) {
1672 return _SelectionToolbarWrapper(
1673 layerLink: toolbarLayerLink,
1674 offset: -renderBox.localToGlobal(Offset.zero),
1675 child: builder(context),
1676 );
1677 },
1678 );
1679 }
1680
1681 bool _buildScheduled = false;
1682
1683 /// Rebuilds the selection toolbar or handles if they are present.
1684 void markNeedsBuild() {
1685 if (_handles == null && _toolbar == null) {
1686 return;
1687 }
1688 // If we are in build state, it will be too late to update visibility.
1689 // We will need to schedule the build in next frame.
1690 if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
1691 if (_buildScheduled) {
1692 return;
1693 }
1694 _buildScheduled = true;
1695 SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
1696 _buildScheduled = false;
1697 _handles?.start.markNeedsBuild();
1698 _handles?.end.markNeedsBuild();
1699 _toolbar?.markNeedsBuild();
1700 if (_contextMenuController.isShown) {
1701 _contextMenuController.markNeedsBuild();
1702 } else if (_spellCheckToolbarController.isShown) {
1703 _spellCheckToolbarController.markNeedsBuild();
1704 }
1705 }, debugLabel: 'SelectionOverlay.markNeedsBuild');
1706 } else {
1707 if (_handles != null) {
1708 _handles!.start.markNeedsBuild();
1709 _handles!.end.markNeedsBuild();
1710 }
1711 _toolbar?.markNeedsBuild();
1712 if (_contextMenuController.isShown) {
1713 _contextMenuController.markNeedsBuild();
1714 } else if (_spellCheckToolbarController.isShown) {
1715 _spellCheckToolbarController.markNeedsBuild();
1716 }
1717 }
1718 }
1719
1720 /// {@template flutter.widgets.SelectionOverlay.hide}
1721 /// Hides the entire overlay including the toolbar and the handles.
1722 /// {@endtemplate}
1723 void hide() {
1724 _magnifierController.hide();
1725 hideHandles();
1726 if (_toolbar != null ||
1727 _contextMenuController.isShown ||
1728 _spellCheckToolbarController.isShown) {
1729 hideToolbar();
1730 }
1731 }
1732
1733 /// {@template flutter.widgets.SelectionOverlay.hideToolbar}
1734 /// Hides the toolbar part of the overlay.
1735 ///
1736 /// To hide the whole overlay, see [hide].
1737 /// {@endtemplate}
1738 void hideToolbar() {
1739 _contextMenuController.remove();
1740 _spellCheckToolbarController.remove();
1741 if (_toolbar == null) {
1742 return;
1743 }
1744 _toolbar?.remove();
1745 _toolbar?.dispose();
1746 _toolbar = null;
1747 }
1748
1749 /// {@template flutter.widgets.SelectionOverlay.dispose}
1750 /// Disposes this object and release resources.
1751 /// {@endtemplate}
1752 void dispose() {
1753 assert(debugMaybeDispatchDisposed(this));
1754 hide();
1755 _magnifierInfo.dispose();
1756 }
1757
1758 Widget _buildStartHandle(BuildContext context) {
1759 final Widget handle;
1760 final TextSelectionControls? selectionControls = this.selectionControls;
1761 if (selectionControls == null ||
1762 (_startHandleType == TextSelectionHandleType.collapsed && _isDraggingEndHandle)) {
1763 // Hide the start handle when dragging the end handle and collapsing
1764 // the selection.
1765 handle = const SizedBox.shrink();
1766 } else {
1767 handle = _SelectionHandleOverlay(
1768 type: _startHandleType,
1769 handleLayerLink: startHandleLayerLink,
1770 onSelectionHandleTapped: onSelectionHandleTapped,
1771 onSelectionHandleDragStart: _handleStartHandleDragStart,
1772 onSelectionHandleDragUpdate: _handleStartHandleDragUpdate,
1773 onSelectionHandleDragEnd: _handleStartHandleDragEnd,
1774 selectionControls: selectionControls,
1775 visibility: startHandlesVisible,
1776 preferredLineHeight: _lineHeightAtStart,
1777 dragStartBehavior: dragStartBehavior,
1778 );
1779 }
1780 return TextFieldTapRegion(child: ExcludeSemantics(child: handle));
1781 }
1782
1783 Widget _buildEndHandle(BuildContext context) {
1784 final Widget handle;
1785 final TextSelectionControls? selectionControls = this.selectionControls;
1786 if (selectionControls == null ||
1787 (_endHandleType == TextSelectionHandleType.collapsed && _isDraggingStartHandle) ||
1788 (_endHandleType == TextSelectionHandleType.collapsed &&
1789 !_isDraggingStartHandle &&
1790 !_isDraggingEndHandle)) {
1791 // Hide the end handle when dragging the start handle and collapsing the selection
1792 // or when the selection is collapsed and no handle is being dragged.
1793 handle = const SizedBox.shrink();
1794 } else {
1795 handle = _SelectionHandleOverlay(
1796 type: _endHandleType,
1797 handleLayerLink: endHandleLayerLink,
1798 onSelectionHandleTapped: onSelectionHandleTapped,
1799 onSelectionHandleDragStart: _handleEndHandleDragStart,
1800 onSelectionHandleDragUpdate: _handleEndHandleDragUpdate,
1801 onSelectionHandleDragEnd: _handleEndHandleDragEnd,
1802 selectionControls: selectionControls,
1803 visibility: endHandlesVisible,
1804 preferredLineHeight: _lineHeightAtEnd,
1805 dragStartBehavior: dragStartBehavior,
1806 );
1807 }
1808 return TextFieldTapRegion(child: ExcludeSemantics(child: handle));
1809 }
1810
1811 // Build the toolbar via TextSelectionControls.
1812 Widget _buildToolbar(BuildContext context) {
1813 if (selectionControls == null) {
1814 return const SizedBox.shrink();
1815 }
1816 assert(
1817 selectionDelegate != null,
1818 'If not using contextMenuBuilder, must pass selectionDelegate.',
1819 );
1820
1821 final RenderBox renderBox = this.context.findRenderObject()! as RenderBox;
1822
1823 final Rect editingRegion = Rect.fromPoints(
1824 renderBox.localToGlobal(Offset.zero),
1825 renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)),
1826 );
1827
1828 final bool isMultiline =
1829 selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy > lineHeightAtEnd / 2;
1830
1831 // If the selected text spans more than 1 line, horizontally center the toolbar.
1832 // Derived from both iOS and Android.
1833 final double midX = isMultiline
1834 ? editingRegion.width / 2
1835 : (selectionEndpoints.first.point.dx + selectionEndpoints.last.point.dx) / 2;
1836
1837 final Offset midpoint = Offset(
1838 midX,
1839 // The y-coordinate won't be made use of most likely.
1840 selectionEndpoints.first.point.dy - lineHeightAtStart,
1841 );
1842
1843 return _SelectionToolbarWrapper(
1844 visibility: toolbarVisible,
1845 layerLink: toolbarLayerLink,
1846 offset: -editingRegion.topLeft,
1847 child: Builder(
1848 builder: (BuildContext context) {
1849 return selectionControls!.buildToolbar(
1850 context,
1851 editingRegion,
1852 lineHeightAtStart,
1853 midpoint,
1854 selectionEndpoints,
1855 selectionDelegate!,
1856 clipboardStatus,
1857 toolbarLocation,
1858 );
1859 },
1860 ),
1861 );
1862 }
1863
1864 /// {@template flutter.widgets.SelectionOverlay.updateMagnifier}
1865 /// Update the current magnifier with new selection data, so the magnifier
1866 /// can respond accordingly.
1867 ///
1868 /// If the magnifier is not shown, this still updates the magnifier position
1869 /// because the magnifier may have hidden itself and is looking for a cue to reshow
1870 /// itself.
1871 ///
1872 /// If there is no magnifier in the overlay, this does nothing.
1873 /// {@endtemplate}
1874 void updateMagnifier(MagnifierInfo magnifierInfo) {
1875 if (_magnifierController.overlayEntry == null) {
1876 return;
1877 }
1878
1879 _magnifierInfo.value = magnifierInfo;
1880 }
1881}
1882
1883// TODO(justinmc): Currently this fades in but not out on all platforms. It
1884// should follow the correct fading behavior for the current platform, then be
1885// made public and de-duplicated with widgets/selectable_region.dart.
1886// https://github.com/flutter/flutter/issues/107732
1887// Wrap the given child in the widgets common to both contextMenuBuilder and
1888// TextSelectionControls.buildToolbar.
1889class _SelectionToolbarWrapper extends StatefulWidget {
1890 const _SelectionToolbarWrapper({
1891 this.visibility,
1892 required this.layerLink,
1893 required this.offset,
1894 required this.child,
1895 });
1896
1897 final Widget child;
1898 final Offset offset;
1899 final LayerLink layerLink;
1900 final ValueListenable<bool>? visibility;
1901
1902 @override
1903 State<_SelectionToolbarWrapper> createState() => _SelectionToolbarWrapperState();
1904}
1905
1906class _SelectionToolbarWrapperState extends State<_SelectionToolbarWrapper>
1907 with SingleTickerProviderStateMixin {
1908 late AnimationController _controller;
1909 Animation<double> get _opacity => _controller.view;
1910
1911 @override
1912 void initState() {
1913 super.initState();
1914
1915 _controller = AnimationController(duration: SelectionOverlay.fadeDuration, vsync: this);
1916
1917 _toolbarVisibilityChanged();
1918 widget.visibility?.addListener(_toolbarVisibilityChanged);
1919 }
1920
1921 @override
1922 void didUpdateWidget(_SelectionToolbarWrapper oldWidget) {
1923 super.didUpdateWidget(oldWidget);
1924 if (oldWidget.visibility == widget.visibility) {
1925 return;
1926 }
1927 oldWidget.visibility?.removeListener(_toolbarVisibilityChanged);
1928 _toolbarVisibilityChanged();
1929 widget.visibility?.addListener(_toolbarVisibilityChanged);
1930 }
1931
1932 @override
1933 void dispose() {
1934 widget.visibility?.removeListener(_toolbarVisibilityChanged);
1935 _controller.dispose();
1936 super.dispose();
1937 }
1938
1939 void _toolbarVisibilityChanged() {
1940 if (widget.visibility?.value ?? true) {
1941 _controller.forward();
1942 } else {
1943 _controller.reverse();
1944 }
1945 }
1946
1947 @override
1948 Widget build(BuildContext context) {
1949 return TextFieldTapRegion(
1950 child: Directionality(
1951 textDirection: Directionality.of(this.context),
1952 child: FadeTransition(
1953 opacity: _opacity,
1954 child: CompositedTransformFollower(
1955 link: widget.layerLink,
1956 showWhenUnlinked: false,
1957 offset: widget.offset,
1958 child: widget.child,
1959 ),
1960 ),
1961 ),
1962 );
1963 }
1964}
1965
1966/// This widget represents a single draggable selection handle.
1967class _SelectionHandleOverlay extends StatefulWidget {
1968 /// Create selection overlay.
1969 const _SelectionHandleOverlay({
1970 required this.type,
1971 required this.handleLayerLink,
1972 this.onSelectionHandleTapped,
1973 this.onSelectionHandleDragStart,
1974 this.onSelectionHandleDragUpdate,
1975 this.onSelectionHandleDragEnd,
1976 required this.selectionControls,
1977 this.visibility,
1978 required this.preferredLineHeight,
1979 this.dragStartBehavior = DragStartBehavior.start,
1980 });
1981
1982 final LayerLink handleLayerLink;
1983 final VoidCallback? onSelectionHandleTapped;
1984 final ValueChanged<DragStartDetails>? onSelectionHandleDragStart;
1985 final ValueChanged<DragUpdateDetails>? onSelectionHandleDragUpdate;
1986 final ValueChanged<DragEndDetails>? onSelectionHandleDragEnd;
1987 final TextSelectionControls selectionControls;
1988 final ValueListenable<bool>? visibility;
1989 final double preferredLineHeight;
1990 final TextSelectionHandleType type;
1991 final DragStartBehavior dragStartBehavior;
1992
1993 @override
1994 State<_SelectionHandleOverlay> createState() => _SelectionHandleOverlayState();
1995}
1996
1997class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay>
1998 with SingleTickerProviderStateMixin {
1999 late AnimationController _controller;
2000 Animation<double> get _opacity => _controller.view;
2001
2002 @override
2003 void initState() {
2004 super.initState();
2005
2006 _controller = AnimationController(duration: SelectionOverlay.fadeDuration, vsync: this);
2007
2008 _handleVisibilityChanged();
2009 widget.visibility?.addListener(_handleVisibilityChanged);
2010 }
2011
2012 void _handleVisibilityChanged() {
2013 if (widget.visibility?.value ?? true) {
2014 _controller.forward();
2015 } else {
2016 _controller.reverse();
2017 }
2018 }
2019
2020 /// Returns the bounding [Rect] of the text selection handle in local
2021 /// coordinates.
2022 ///
2023 /// When interacting with a text selection handle through a touch event, the
2024 /// interactive area should be at least [kMinInteractiveDimension] square,
2025 /// which this method does not consider.
2026 Rect _getHandleRect(TextSelectionHandleType type, double preferredLineHeight) {
2027 final Size handleSize = widget.selectionControls.getHandleSize(preferredLineHeight);
2028 return Rect.fromLTWH(0.0, 0.0, handleSize.width, handleSize.height);
2029 }
2030
2031 @override
2032 void didUpdateWidget(_SelectionHandleOverlay oldWidget) {
2033 super.didUpdateWidget(oldWidget);
2034 oldWidget.visibility?.removeListener(_handleVisibilityChanged);
2035 _handleVisibilityChanged();
2036 widget.visibility?.addListener(_handleVisibilityChanged);
2037 }
2038
2039 @override
2040 void dispose() {
2041 widget.visibility?.removeListener(_handleVisibilityChanged);
2042 _controller.dispose();
2043 super.dispose();
2044 }
2045
2046 @override
2047 Widget build(BuildContext context) {
2048 final Rect handleRect = _getHandleRect(widget.type, widget.preferredLineHeight);
2049
2050 // Make sure the GestureDetector is big enough to be easily interactive.
2051 final Rect interactiveRect = handleRect.expandToInclude(
2052 Rect.fromCircle(center: handleRect.center, radius: kMinInteractiveDimension / 2),
2053 );
2054 final RelativeRect padding = RelativeRect.fromLTRB(
2055 math.max((interactiveRect.width - handleRect.width) / 2, 0),
2056 math.max((interactiveRect.height - handleRect.height) / 2, 0),
2057 math.max((interactiveRect.width - handleRect.width) / 2, 0),
2058 math.max((interactiveRect.height - handleRect.height) / 2, 0),
2059 );
2060
2061 final Offset handleAnchor = widget.selectionControls.getHandleAnchor(
2062 widget.type,
2063 widget.preferredLineHeight,
2064 );
2065
2066 // Make sure a drag is eagerly accepted. This is used on iOS to match the
2067 // behavior where a drag directly on a collapse handle will always win against
2068 // other drag gestures.
2069 final bool eagerlyAcceptDragWhenCollapsed =
2070 widget.type == TextSelectionHandleType.collapsed &&
2071 defaultTargetPlatform == TargetPlatform.iOS;
2072
2073 return CompositedTransformFollower(
2074 link: widget.handleLayerLink,
2075 // Put the handle's anchor point on the leader's anchor point.
2076 offset: -handleAnchor - Offset(padding.left, padding.top),
2077 showWhenUnlinked: false,
2078 child: FadeTransition(
2079 opacity: _opacity,
2080 child: SizedBox(
2081 width: interactiveRect.width,
2082 height: interactiveRect.height,
2083 child: Align(
2084 alignment: Alignment.topLeft,
2085 child: RawGestureDetector(
2086 behavior: HitTestBehavior.translucent,
2087 gestures: <Type, GestureRecognizerFactory>{
2088 PanGestureRecognizer: GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
2089 () => PanGestureRecognizer(
2090 debugOwner: this,
2091 // Mouse events select the text and do not drag the cursor.
2092 supportedDevices: <PointerDeviceKind>{
2093 PointerDeviceKind.touch,
2094 PointerDeviceKind.stylus,
2095 PointerDeviceKind.unknown,
2096 },
2097 ),
2098 (PanGestureRecognizer instance) {
2099 instance
2100 ..dragStartBehavior = widget.dragStartBehavior
2101 ..gestureSettings = eagerlyAcceptDragWhenCollapsed
2102 ? const DeviceGestureSettings(touchSlop: 1.0)
2103 : null
2104 ..onStart = widget.onSelectionHandleDragStart
2105 ..onUpdate = widget.onSelectionHandleDragUpdate
2106 ..onEnd = widget.onSelectionHandleDragEnd;
2107 },
2108 ),
2109 },
2110 child: Padding(
2111 padding: EdgeInsets.only(
2112 left: padding.left,
2113 top: padding.top,
2114 right: padding.right,
2115 bottom: padding.bottom,
2116 ),
2117 child: widget.selectionControls.buildHandle(
2118 context,
2119 widget.type,
2120 widget.preferredLineHeight,
2121 widget.onSelectionHandleTapped,
2122 ),
2123 ),
2124 ),
2125 ),
2126 ),
2127 ),
2128 );
2129 }
2130}
2131
2132/// Delegate interface for the [TextSelectionGestureDetectorBuilder].
2133///
2134/// The interface is usually implemented by the [State] of text field
2135/// implementations wrapping [EditableText], so that they can use a
2136/// [TextSelectionGestureDetectorBuilder] to build a
2137/// [TextSelectionGestureDetector] for their [EditableText]. The delegate
2138/// provides the builder with information about the current state of the text
2139/// field. Based on that information, the builder adds the correct gesture
2140/// handlers to the gesture detector.
2141///
2142/// See also:
2143///
2144/// * [TextField], which implements this delegate for the Material text field.
2145/// * [CupertinoTextField], which implements this delegate for the Cupertino
2146/// text field.
2147abstract class TextSelectionGestureDetectorBuilderDelegate {
2148 /// [GlobalKey] to the [EditableText] for which the
2149 /// [TextSelectionGestureDetectorBuilder] will build a [TextSelectionGestureDetector].
2150 GlobalKey<EditableTextState> get editableTextKey;
2151
2152 /// Whether the text field should respond to force presses.
2153 bool get forcePressEnabled;
2154
2155 /// Whether the user may select text in the text field.
2156 bool get selectionEnabled;
2157}
2158
2159/// Builds a [TextSelectionGestureDetector] to wrap an [EditableText].
2160///
2161/// The class implements sensible defaults for many user interactions
2162/// with an [EditableText] (see the documentation of the various gesture handler
2163/// methods, e.g. [onTapDown], [onForcePressStart], etc.). Subclasses of
2164/// [TextSelectionGestureDetectorBuilder] can change the behavior performed in
2165/// responds to these gesture events by overriding the corresponding handler
2166/// methods of this class.
2167///
2168/// The resulting [TextSelectionGestureDetector] to wrap an [EditableText] is
2169/// obtained by calling [buildGestureDetector].
2170///
2171/// A [TextSelectionGestureDetectorBuilder] must be provided a
2172/// [TextSelectionGestureDetectorBuilderDelegate], from which information about
2173/// the [EditableText] may be obtained. Typically, the [State] of the widget
2174/// that builds the [EditableText] implements this interface, and then passes
2175/// itself as the [delegate].
2176///
2177/// See also:
2178///
2179/// * [TextField], which uses a subclass to implement the Material-specific
2180/// gesture logic of an [EditableText].
2181/// * [CupertinoTextField], which uses a subclass to implement the
2182/// Cupertino-specific gesture logic of an [EditableText].
2183class TextSelectionGestureDetectorBuilder {
2184 /// Creates a [TextSelectionGestureDetectorBuilder].
2185 TextSelectionGestureDetectorBuilder({required this.delegate});
2186
2187 /// The delegate for this [TextSelectionGestureDetectorBuilder].
2188 ///
2189 /// The delegate provides the builder with information about what actions can
2190 /// currently be performed on the text field. Based on this, the builder adds
2191 /// the correct gesture handlers to the gesture detector.
2192 ///
2193 /// Typically implemented by a [State] of a widget that builds an
2194 /// [EditableText].
2195 @protected
2196 final TextSelectionGestureDetectorBuilderDelegate delegate;
2197
2198 // Shows the magnifier on supported platforms at the given offset, currently
2199 // only Android and iOS.
2200 void _showMagnifierIfSupportedByPlatform(Offset positionToShow) {
2201 switch (defaultTargetPlatform) {
2202 case TargetPlatform.android:
2203 case TargetPlatform.iOS:
2204 editableText.showMagnifier(positionToShow);
2205 case TargetPlatform.fuchsia:
2206 case TargetPlatform.linux:
2207 case TargetPlatform.macOS:
2208 case TargetPlatform.windows:
2209 }
2210 }
2211
2212 // Hides the magnifier on supported platforms, currently only Android and iOS.
2213 void _hideMagnifierIfSupportedByPlatform() {
2214 if (!_isEditableTextMounted) {
2215 return;
2216 }
2217
2218 switch (defaultTargetPlatform) {
2219 case TargetPlatform.android:
2220 case TargetPlatform.iOS:
2221 editableText.hideMagnifier();
2222 case TargetPlatform.fuchsia:
2223 case TargetPlatform.linux:
2224 case TargetPlatform.macOS:
2225 case TargetPlatform.windows:
2226 }
2227 }
2228
2229 /// Returns true if lastSecondaryTapDownPosition was on selection.
2230 bool get _lastSecondaryTapWasOnSelection {
2231 assert(renderEditable.lastSecondaryTapDownPosition != null);
2232 if (renderEditable.selection == null) {
2233 return false;
2234 }
2235
2236 final TextPosition textPosition = renderEditable.getPositionForPoint(
2237 renderEditable.lastSecondaryTapDownPosition!,
2238 );
2239
2240 return renderEditable.selection!.start <= textPosition.offset &&
2241 renderEditable.selection!.end >= textPosition.offset;
2242 }
2243
2244 bool _positionWasOnSelectionExclusive(TextPosition textPosition) {
2245 final TextSelection? selection = renderEditable.selection;
2246 if (selection == null) {
2247 return false;
2248 }
2249
2250 return selection.start < textPosition.offset && selection.end > textPosition.offset;
2251 }
2252
2253 bool _positionWasOnSelectionInclusive(TextPosition textPosition) {
2254 final TextSelection? selection = renderEditable.selection;
2255 if (selection == null) {
2256 return false;
2257 }
2258
2259 return selection.start <= textPosition.offset && selection.end >= textPosition.offset;
2260 }
2261
2262 // Expand the selection to the given global position.
2263 //
2264 // Either base or extent will be moved to the last tapped position, whichever
2265 // is closest. The selection will never shrink or pivot, only grow.
2266 //
2267 // If fromSelection is given, will expand from that selection instead of the
2268 // current selection in renderEditable.
2269 //
2270 // See also:
2271 //
2272 // * [_extendSelection], which is similar but pivots the selection around
2273 // the base.
2274 void _expandSelection(
2275 Offset offset,
2276 SelectionChangedCause cause, [
2277 TextSelection? fromSelection,
2278 ]) {
2279 assert(renderEditable.selection?.baseOffset != null);
2280
2281 final TextPosition tappedPosition = renderEditable.getPositionForPoint(offset);
2282 final TextSelection selection = fromSelection ?? renderEditable.selection!;
2283 final bool baseIsCloser =
2284 (tappedPosition.offset - selection.baseOffset).abs() <
2285 (tappedPosition.offset - selection.extentOffset).abs();
2286 final TextSelection nextSelection = selection.copyWith(
2287 baseOffset: baseIsCloser ? selection.extentOffset : selection.baseOffset,
2288 extentOffset: tappedPosition.offset,
2289 );
2290
2291 editableText.userUpdateTextEditingValue(
2292 editableText.textEditingValue.copyWith(selection: nextSelection),
2293 cause,
2294 );
2295 }
2296
2297 // Extend the selection to the given global position.
2298 //
2299 // Holds the base in place and moves the extent.
2300 //
2301 // See also:
2302 //
2303 // * [_expandSelection], which is similar but always increases the size of
2304 // the selection.
2305 void _extendSelection(Offset offset, SelectionChangedCause cause) {
2306 assert(renderEditable.selection?.baseOffset != null);
2307
2308 final TextPosition tappedPosition = renderEditable.getPositionForPoint(offset);
2309 final TextSelection selection = renderEditable.selection!;
2310 final TextSelection nextSelection = selection.copyWith(extentOffset: tappedPosition.offset);
2311
2312 editableText.userUpdateTextEditingValue(
2313 editableText.textEditingValue.copyWith(selection: nextSelection),
2314 cause,
2315 );
2316 }
2317
2318 /// Whether to show the selection toolbar.
2319 ///
2320 /// It is based on the signal source when [onTapDown], [onSecondaryTapDown],
2321 /// [onDragSelectionStart], or [onForcePressStart] is called. This getter
2322 /// will return true if the current [onTapDown], or [onDragSelectionStart] event
2323 /// is triggered by a touch or a stylus. It will always return true for the
2324 /// current [onSecondaryTapDown] or [onForcePressStart] event.
2325 bool get shouldShowSelectionToolbar => _shouldShowSelectionToolbar;
2326 bool _shouldShowSelectionToolbar = true;
2327
2328 /// Whether to show the selection handles.
2329 ///
2330 /// It is based on the signal source when [onTapDown], [onSecondaryTapDown],
2331 /// [onDragSelectionStart], is called. This getter will return true if the
2332 /// current [onTapDown], [onSecondaryTapDown], or [onDragSelectionStart] event
2333 /// is triggered by a touch or a stylus.
2334 bool get shouldShowSelectionHandles => _shouldShowSelectionHandles;
2335 bool _shouldShowSelectionHandles = true;
2336
2337 /// The [State] of the [EditableText] for which the builder will provide a
2338 /// [TextSelectionGestureDetector].
2339 @protected
2340 EditableTextState get editableText => delegate.editableTextKey.currentState!;
2341
2342 /// The [RenderObject] of the [EditableText] for which the builder will
2343 /// provide a [TextSelectionGestureDetector].
2344 @protected
2345 RenderEditable get renderEditable => editableText.renderEditable;
2346
2347 /// Returns `true` if a widget with the global key [delegate.editableTextKey]
2348 /// is in the tree and the widget is mounted.
2349 ///
2350 /// Otherwise returns `false`.
2351 bool get _isEditableTextMounted => delegate.editableTextKey.currentContext?.mounted ?? false;
2352
2353 /// Whether the Shift key was pressed when the most recent [PointerDownEvent]
2354 /// was tracked by the [BaseTapAndDragGestureRecognizer].
2355 bool _isShiftPressed = false;
2356
2357 /// The viewport offset pixels of any [Scrollable] containing the
2358 /// [RenderEditable] at the last drag start.
2359 double _dragStartScrollOffset = 0.0;
2360
2361 /// The viewport offset pixels of the [RenderEditable] at the last drag start.
2362 double _dragStartViewportOffset = 0.0;
2363
2364 double get _scrollPosition {
2365 final ScrollableState? scrollableState = delegate.editableTextKey.currentContext == null
2366 ? null
2367 : Scrollable.maybeOf(delegate.editableTextKey.currentContext!);
2368 return scrollableState == null ? 0.0 : scrollableState.position.pixels;
2369 }
2370
2371 AxisDirection? get _scrollDirection {
2372 final ScrollableState? scrollableState = delegate.editableTextKey.currentContext == null
2373 ? null
2374 : Scrollable.maybeOf(delegate.editableTextKey.currentContext!);
2375 return scrollableState?.axisDirection;
2376 }
2377
2378 // For a shift + tap + drag gesture, the TextSelection at the point of the
2379 // tap. Mac uses this value to reset to the original selection when an
2380 // inversion of the base and offset happens.
2381 TextSelection? _dragStartSelection;
2382
2383 // For iOS long press behavior when the field is not focused. iOS uses this value
2384 // to determine if a long press began on a field that was not focused.
2385 //
2386 // If the field was not focused when the long press began, a long press will select
2387 // the word and a long press move will select word-by-word. If the field was
2388 // focused, the cursor moves to the long press position.
2389 bool _longPressStartedWithoutFocus = false;
2390
2391 /// Handler for [TextSelectionGestureDetector.onTapTrackStart].
2392 ///
2393 /// See also:
2394 ///
2395 /// * [TextSelectionGestureDetector.onTapTrackStart], which triggers this
2396 /// callback.
2397 @protected
2398 void onTapTrackStart() {
2399 _isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed.intersection(
2400 <LogicalKeyboardKey>{LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftRight},
2401 ).isNotEmpty;
2402 }
2403
2404 /// Handler for [TextSelectionGestureDetector.onTapTrackReset].
2405 ///
2406 /// See also:
2407 ///
2408 /// * [TextSelectionGestureDetector.onTapTrackReset], which triggers this
2409 /// callback.
2410 @protected
2411 void onTapTrackReset() {
2412 _isShiftPressed = false;
2413 }
2414
2415 /// Handler for [TextSelectionGestureDetector.onTapDown].
2416 ///
2417 /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets
2418 /// [shouldShowSelectionToolbar] to true if the tap was initiated by a finger or stylus.
2419 ///
2420 /// See also:
2421 ///
2422 /// * [TextSelectionGestureDetector.onTapDown], which triggers this callback.
2423 @protected
2424 void onTapDown(TapDragDownDetails details) {
2425 if (!delegate.selectionEnabled) {
2426 return;
2427 }
2428
2429 // TODO(Renzo-Olivares): Migrate text selection gestures away from saving state
2430 // in renderEditable. The gesture callbacks can use the details objects directly
2431 // in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap]
2432 // vs [TapGestureRecognizer.onSecondaryTapUp] instead of having to track state in
2433 // renderEditable. When this migration is complete we should remove this hack.
2434 // See https://github.com/flutter/flutter/issues/115130.
2435 renderEditable.handleTapDown(TapDownDetails(globalPosition: details.globalPosition));
2436 // The selection overlay should only be shown when the user is interacting
2437 // through a touch screen (via either a finger or a stylus). A mouse shouldn't
2438 // trigger the selection overlay.
2439 // For backwards-compatibility, we treat a null kind the same as touch.
2440 final PointerDeviceKind? kind = details.kind;
2441 // TODO(justinmc): Should a desktop platform show its selection toolbar when
2442 // receiving a tap event? Say a Windows device with a touchscreen.
2443 // https://github.com/flutter/flutter/issues/106586
2444 _shouldShowSelectionToolbar =
2445 kind == null || kind == PointerDeviceKind.touch || kind == PointerDeviceKind.stylus;
2446 _shouldShowSelectionHandles = _shouldShowSelectionToolbar;
2447
2448 // It is impossible to extend the selection when the shift key is pressed, if the
2449 // renderEditable.selection is invalid.
2450 final bool isShiftPressedValid =
2451 _isShiftPressed && renderEditable.selection?.baseOffset != null;
2452 switch (defaultTargetPlatform) {
2453 case TargetPlatform.android:
2454 if (editableText.widget.stylusHandwritingEnabled) {
2455 final bool stylusEnabled = switch (kind) {
2456 PointerDeviceKind.stylus ||
2457 PointerDeviceKind.invertedStylus => editableText.widget.stylusHandwritingEnabled,
2458 _ => false,
2459 };
2460 if (stylusEnabled) {
2461 Scribe.isFeatureAvailable().then((bool isAvailable) {
2462 if (isAvailable) {
2463 renderEditable.selectPosition(cause: SelectionChangedCause.stylusHandwriting);
2464 Scribe.startStylusHandwriting();
2465 }
2466 });
2467 }
2468 }
2469 case TargetPlatform.fuchsia:
2470 case TargetPlatform.iOS:
2471 // On mobile platforms the selection is set on tap up.
2472 break;
2473 case TargetPlatform.macOS:
2474 editableText.hideToolbar();
2475 // On macOS, a shift-tapped unfocused field expands from 0, not from the
2476 // previous selection.
2477 if (isShiftPressedValid) {
2478 final TextSelection? fromSelection = renderEditable.hasFocus
2479 ? null
2480 : const TextSelection.collapsed(offset: 0);
2481 _expandSelection(details.globalPosition, SelectionChangedCause.tap, fromSelection);
2482 return;
2483 }
2484 // On macOS, a tap/click places the selection in a precise position.
2485 // This differs from iOS/iPadOS, where if the gesture is done by a touch
2486 // then the selection moves to the closest word edge, instead of a
2487 // precise position.
2488 renderEditable.selectPosition(cause: SelectionChangedCause.tap);
2489 case TargetPlatform.linux:
2490 case TargetPlatform.windows:
2491 editableText.hideToolbar();
2492 if (isShiftPressedValid) {
2493 _extendSelection(details.globalPosition, SelectionChangedCause.tap);
2494 return;
2495 }
2496 renderEditable.selectPosition(cause: SelectionChangedCause.tap);
2497 }
2498 }
2499
2500 /// Handler for [TextSelectionGestureDetector.onForcePressStart].
2501 ///
2502 /// By default, it selects the word at the position of the force press,
2503 /// if selection is enabled.
2504 ///
2505 /// This callback is only applicable when force press is enabled.
2506 ///
2507 /// See also:
2508 ///
2509 /// * [TextSelectionGestureDetector.onForcePressStart], which triggers this
2510 /// callback.
2511 @protected
2512 void onForcePressStart(ForcePressDetails details) {
2513 assert(delegate.forcePressEnabled);
2514 _shouldShowSelectionToolbar = true;
2515 if (!delegate.selectionEnabled) {
2516 return;
2517 }
2518 renderEditable.selectWordsInRange(
2519 from: details.globalPosition,
2520 cause: SelectionChangedCause.forcePress,
2521 );
2522 editableText.showToolbar();
2523 }
2524
2525 /// Handler for [TextSelectionGestureDetector.onForcePressEnd].
2526 ///
2527 /// By default, it selects words in the range specified in [details] and shows
2528 /// toolbar if it is necessary.
2529 ///
2530 /// This callback is only applicable when force press is enabled.
2531 ///
2532 /// See also:
2533 ///
2534 /// * [TextSelectionGestureDetector.onForcePressEnd], which triggers this
2535 /// callback.
2536 @protected
2537 void onForcePressEnd(ForcePressDetails details) {
2538 assert(delegate.forcePressEnabled);
2539 renderEditable.selectWordsInRange(
2540 from: details.globalPosition,
2541 cause: SelectionChangedCause.forcePress,
2542 );
2543 if (shouldShowSelectionToolbar) {
2544 editableText.showToolbar();
2545 }
2546 }
2547
2548 /// Whether the provided [onUserTap] callback should be dispatched on every
2549 /// tap or only non-consecutive taps.
2550 ///
2551 /// Defaults to false.
2552 @protected
2553 bool get onUserTapAlwaysCalled => false;
2554
2555 /// Handler for [TextSelectionGestureDetector.onUserTap].
2556 ///
2557 /// By default, it serves as placeholder to enable subclass override.
2558 ///
2559 /// See also:
2560 ///
2561 /// * [TextSelectionGestureDetector.onUserTap], which triggers this
2562 /// callback.
2563 /// * [TextSelectionGestureDetector.onUserTapAlwaysCalled], which controls
2564 /// whether this callback is called only on the first tap in a series
2565 /// of taps.
2566 @protected
2567 void onUserTap() {
2568 /* Subclass should override this method if needed. */
2569 }
2570
2571 /// Handler for [TextSelectionGestureDetector.onSingleTapUp].
2572 ///
2573 /// By default, it selects word edge if selection is enabled.
2574 ///
2575 /// See also:
2576 ///
2577 /// * [TextSelectionGestureDetector.onSingleTapUp], which triggers
2578 /// this callback.
2579 @protected
2580 void onSingleTapUp(TapDragUpDetails details) {
2581 if (!delegate.selectionEnabled) {
2582 editableText.requestKeyboard();
2583 return;
2584 }
2585 // It is impossible to extend the selection when the shift key is pressed, if the
2586 // renderEditable.selection is invalid.
2587 final bool isShiftPressedValid =
2588 _isShiftPressed && renderEditable.selection?.baseOffset != null;
2589 switch (defaultTargetPlatform) {
2590 case TargetPlatform.linux:
2591 case TargetPlatform.macOS:
2592 case TargetPlatform.windows:
2593 break;
2594 // On desktop platforms the selection is set on tap down.
2595 case TargetPlatform.android:
2596 editableText.hideToolbar(false);
2597 if (isShiftPressedValid) {
2598 _extendSelection(details.globalPosition, SelectionChangedCause.tap);
2599 return;
2600 }
2601 renderEditable.selectPosition(cause: SelectionChangedCause.tap);
2602 editableText.showSpellCheckSuggestionsToolbar();
2603 case TargetPlatform.fuchsia:
2604 editableText.hideToolbar(false);
2605 if (isShiftPressedValid) {
2606 _extendSelection(details.globalPosition, SelectionChangedCause.tap);
2607 return;
2608 }
2609 renderEditable.selectPosition(cause: SelectionChangedCause.tap);
2610 case TargetPlatform.iOS:
2611 if (isShiftPressedValid) {
2612 // On iOS, a shift-tapped unfocused field expands from 0, not from
2613 // the previous selection.
2614 final TextSelection? fromSelection = renderEditable.hasFocus
2615 ? null
2616 : const TextSelection.collapsed(offset: 0);
2617 _expandSelection(details.globalPosition, SelectionChangedCause.tap, fromSelection);
2618 return;
2619 }
2620 switch (details.kind) {
2621 case PointerDeviceKind.mouse:
2622 case PointerDeviceKind.trackpad:
2623 case PointerDeviceKind.stylus:
2624 case PointerDeviceKind.invertedStylus:
2625 // TODO(camsim99): Determine spell check toolbar behavior in these cases:
2626 // https://github.com/flutter/flutter/issues/119573.
2627 // Precise devices should place the cursor at a precise position if the
2628 // word at the text position is not misspelled.
2629 renderEditable.selectPosition(cause: SelectionChangedCause.tap);
2630 editableText.hideToolbar();
2631 case PointerDeviceKind.touch:
2632 case PointerDeviceKind.unknown:
2633 // If the word that was tapped is misspelled, select the word and show the spell check suggestions
2634 // toolbar once. If additional taps are made on a misspelled word, toggle the toolbar. If the word
2635 // is not misspelled, default to the following behavior:
2636 //
2637 // Toggle the toolbar when the tap is exclusively within the bounds of a non-collapsed `previousSelection`,
2638 // and the editable is focused.
2639 //
2640 // Toggle the toolbar if the `previousSelection` is collapsed, the tap is on the selection, the
2641 // TextAffinity remains the same, the editable field is not read only, and the editable is focused.
2642 // The TextAffinity is important when the cursor is on the boundary of a line wrap, if the affinity
2643 // is different (i.e. it is downstream), the selection should move to the following line and not toggle
2644 // the toolbar.
2645 //
2646 // Selects the word edge closest to the tap when the editable is not focused, or if the tap was neither exclusively
2647 // or inclusively on `previousSelection`. If the selection remains the same after selecting the word edge, then we
2648 // toggle the toolbar, if the editable field is not read only. If the selection changes then we hide the toolbar.
2649 final TextSelection previousSelection =
2650 renderEditable.selection ?? editableText.textEditingValue.selection;
2651 final TextPosition textPosition = renderEditable.getPositionForPoint(
2652 details.globalPosition,
2653 );
2654 final bool isAffinityTheSame = textPosition.affinity == previousSelection.affinity;
2655 final bool wordAtCursorIndexIsMisspelled =
2656 editableText.findSuggestionSpanAtCursorIndex(textPosition.offset) != null;
2657
2658 if (wordAtCursorIndexIsMisspelled) {
2659 renderEditable.selectWord(cause: SelectionChangedCause.tap);
2660 if (previousSelection != editableText.textEditingValue.selection) {
2661 editableText.showSpellCheckSuggestionsToolbar();
2662 } else {
2663 editableText.toggleToolbar(false);
2664 }
2665 } else if (((_positionWasOnSelectionExclusive(textPosition) &&
2666 !previousSelection.isCollapsed) ||
2667 (_positionWasOnSelectionInclusive(textPosition) &&
2668 previousSelection.isCollapsed &&
2669 isAffinityTheSame &&
2670 !renderEditable.readOnly)) &&
2671 renderEditable.hasFocus) {
2672 editableText.toggleToolbar(false);
2673 } else {
2674 renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
2675 if (previousSelection == editableText.textEditingValue.selection &&
2676 renderEditable.hasFocus &&
2677 !renderEditable.readOnly) {
2678 editableText.toggleToolbar(false);
2679 } else {
2680 editableText.hideToolbar(false);
2681 }
2682 }
2683 }
2684 }
2685 editableText.requestKeyboard();
2686 }
2687
2688 /// Handler for [TextSelectionGestureDetector.onSingleTapCancel].
2689 ///
2690 /// By default, it serves as placeholder to enable subclass override.
2691 ///
2692 /// See also:
2693 ///
2694 /// * [TextSelectionGestureDetector.onSingleTapCancel], which triggers
2695 /// this callback.
2696 @protected
2697 void onSingleTapCancel() {
2698 /* Subclass should override this method if needed. */
2699 }
2700
2701 /// Handler for [TextSelectionGestureDetector.onSingleLongTapStart].
2702 ///
2703 /// By default, it selects text position specified in [details] if selection
2704 /// is enabled.
2705 ///
2706 /// See also:
2707 ///
2708 /// * [TextSelectionGestureDetector.onSingleLongTapStart], which triggers
2709 /// this callback.
2710 @protected
2711 void onSingleLongTapStart(LongPressStartDetails details) {
2712 if (!delegate.selectionEnabled) {
2713 return;
2714 }
2715 switch (defaultTargetPlatform) {
2716 case TargetPlatform.iOS:
2717 case TargetPlatform.macOS:
2718 if (!renderEditable.hasFocus) {
2719 _longPressStartedWithoutFocus = true;
2720 renderEditable.selectWord(cause: SelectionChangedCause.longPress);
2721 } else if (renderEditable.readOnly) {
2722 renderEditable.selectWord(cause: SelectionChangedCause.longPress);
2723 if (editableText.context.mounted) {
2724 Feedback.forLongPress(editableText.context);
2725 }
2726 } else {
2727 renderEditable.selectPositionAt(
2728 from: details.globalPosition,
2729 cause: SelectionChangedCause.longPress,
2730 );
2731 // Show the floating cursor.
2732 final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint(
2733 state: FloatingCursorDragState.Start,
2734 startLocation: (
2735 renderEditable.globalToLocal(details.globalPosition),
2736 TextPosition(
2737 offset: editableText.textEditingValue.selection.baseOffset,
2738 affinity: editableText.textEditingValue.selection.affinity,
2739 ),
2740 ),
2741 offset: Offset.zero,
2742 );
2743 editableText.updateFloatingCursor(cursorPoint);
2744 }
2745 case TargetPlatform.android:
2746 case TargetPlatform.fuchsia:
2747 case TargetPlatform.linux:
2748 case TargetPlatform.windows:
2749 renderEditable.selectWord(cause: SelectionChangedCause.longPress);
2750 if (editableText.context.mounted) {
2751 Feedback.forLongPress(editableText.context);
2752 }
2753 }
2754
2755 _showMagnifierIfSupportedByPlatform(details.globalPosition);
2756
2757 _dragStartViewportOffset = renderEditable.offset.pixels;
2758 _dragStartScrollOffset = _scrollPosition;
2759 }
2760
2761 /// Handler for [TextSelectionGestureDetector.onSingleLongTapMoveUpdate].
2762 ///
2763 /// By default, it updates the selection location specified in [details] if
2764 /// selection is enabled.
2765 ///
2766 /// See also:
2767 ///
2768 /// * [TextSelectionGestureDetector.onSingleLongTapMoveUpdate], which
2769 /// triggers this callback.
2770 @protected
2771 void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
2772 if (!delegate.selectionEnabled) {
2773 return;
2774 }
2775 // Adjust the drag start offset for possible viewport offset changes.
2776 final Offset editableOffset = renderEditable.maxLines == 1
2777 ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0)
2778 : Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset);
2779 final Offset scrollableOffset = switch (axisDirectionToAxis(
2780 _scrollDirection ?? AxisDirection.left,
2781 )) {
2782 Axis.horizontal => Offset(_scrollPosition - _dragStartScrollOffset, 0.0),
2783 Axis.vertical => Offset(0.0, _scrollPosition - _dragStartScrollOffset),
2784 };
2785 switch (defaultTargetPlatform) {
2786 case TargetPlatform.iOS:
2787 case TargetPlatform.macOS:
2788 if (_longPressStartedWithoutFocus || renderEditable.readOnly) {
2789 renderEditable.selectWordsInRange(
2790 from:
2791 details.globalPosition -
2792 details.offsetFromOrigin -
2793 editableOffset -
2794 scrollableOffset,
2795 to: details.globalPosition,
2796 cause: SelectionChangedCause.longPress,
2797 );
2798 } else {
2799 renderEditable.selectPositionAt(
2800 from: details.globalPosition,
2801 cause: SelectionChangedCause.longPress,
2802 );
2803 // Update the floating cursor.
2804 final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint(
2805 state: FloatingCursorDragState.Update,
2806 offset: details.offsetFromOrigin,
2807 );
2808 editableText.updateFloatingCursor(cursorPoint);
2809 }
2810 case TargetPlatform.android:
2811 case TargetPlatform.fuchsia:
2812 case TargetPlatform.linux:
2813 case TargetPlatform.windows:
2814 renderEditable.selectWordsInRange(
2815 from:
2816 details.globalPosition - details.offsetFromOrigin - editableOffset - scrollableOffset,
2817 to: details.globalPosition,
2818 cause: SelectionChangedCause.longPress,
2819 );
2820 }
2821
2822 _showMagnifierIfSupportedByPlatform(details.globalPosition);
2823 }
2824
2825 /// Handler for [TextSelectionGestureDetector.onSingleLongTapEnd].
2826 ///
2827 /// By default, it shows toolbar if necessary.
2828 ///
2829 /// See also:
2830 ///
2831 /// * [TextSelectionGestureDetector.onSingleLongTapEnd], which triggers this
2832 /// callback.
2833 @protected
2834 void onSingleLongTapEnd(LongPressEndDetails details) {
2835 _onSingleLongTapEndOrCancel();
2836 if (shouldShowSelectionToolbar) {
2837 editableText.showToolbar();
2838 }
2839 }
2840
2841 /// Handler for [TextSelectionGestureDetector.onSingleLongTapCancel].
2842 ///
2843 /// By default, it hides the magnifier and the floating cursor if necessary.
2844 ///
2845 /// See also:
2846 ///
2847 /// * [TextSelectionGestureDetector.onSingleLongTapCancel], which triggers
2848 /// this callback.
2849 @protected
2850 void onSingleLongTapCancel() {
2851 _onSingleLongTapEndOrCancel();
2852 }
2853
2854 /// Handler for [TextSelectionGestureDetector.onSecondaryTap].
2855 ///
2856 /// By default, selects the word if possible and shows the toolbar.
2857 @protected
2858 void onSecondaryTap() {
2859 if (!delegate.selectionEnabled) {
2860 return;
2861 }
2862 switch (defaultTargetPlatform) {
2863 case TargetPlatform.iOS:
2864 case TargetPlatform.macOS:
2865 if (!_lastSecondaryTapWasOnSelection || !renderEditable.hasFocus) {
2866 renderEditable.selectWord(cause: SelectionChangedCause.tap);
2867 }
2868 if (shouldShowSelectionToolbar) {
2869 editableText.hideToolbar();
2870 editableText.showToolbar();
2871 }
2872 case TargetPlatform.android:
2873 case TargetPlatform.fuchsia:
2874 case TargetPlatform.linux:
2875 case TargetPlatform.windows:
2876 if (!renderEditable.hasFocus) {
2877 renderEditable.selectPosition(cause: SelectionChangedCause.tap);
2878 }
2879 editableText.toggleToolbar();
2880 }
2881 }
2882
2883 /// Handler for [TextSelectionGestureDetector.onSecondaryTapDown].
2884 ///
2885 /// See also:
2886 ///
2887 /// * [TextSelectionGestureDetector.onSecondaryTapDown], which triggers this
2888 /// callback.
2889 /// * [onSecondaryTap], which is typically called after this.
2890 @protected
2891 void onSecondaryTapDown(TapDownDetails details) {
2892 // TODO(Renzo-Olivares): Migrate text selection gestures away from saving state
2893 // in renderEditable. The gesture callbacks can use the details objects directly
2894 // in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap]
2895 // vs [TapGestureRecognizer.onSecondaryTapUp] instead of having to track state in
2896 // renderEditable. When this migration is complete we should remove this hack.
2897 // See https://github.com/flutter/flutter/issues/115130.
2898 renderEditable.handleSecondaryTapDown(TapDownDetails(globalPosition: details.globalPosition));
2899 _shouldShowSelectionToolbar = true;
2900 _shouldShowSelectionHandles =
2901 details.kind == null ||
2902 details.kind == PointerDeviceKind.touch ||
2903 details.kind == PointerDeviceKind.stylus;
2904 }
2905
2906 /// Handler for [TextSelectionGestureDetector.onDoubleTapDown].
2907 ///
2908 /// By default, it selects a word through [RenderEditable.selectWord] if
2909 /// selectionEnabled and shows toolbar if necessary.
2910 ///
2911 /// See also:
2912 ///
2913 /// * [TextSelectionGestureDetector.onDoubleTapDown], which triggers this
2914 /// callback.
2915 @protected
2916 void onDoubleTapDown(TapDragDownDetails details) {
2917 if (delegate.selectionEnabled) {
2918 renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
2919 if (shouldShowSelectionToolbar) {
2920 editableText.showToolbar();
2921 }
2922 }
2923 }
2924
2925 void _onSingleLongTapEndOrCancel() {
2926 _hideMagnifierIfSupportedByPlatform();
2927 _longPressStartedWithoutFocus = false;
2928 _dragStartViewportOffset = 0.0;
2929 _dragStartScrollOffset = 0.0;
2930 if (_isEditableTextMounted &&
2931 defaultTargetPlatform == TargetPlatform.iOS &&
2932 delegate.selectionEnabled &&
2933 editableText.textEditingValue.selection.isCollapsed) {
2934 // Update the floating cursor.
2935 final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint(
2936 state: FloatingCursorDragState.End,
2937 );
2938 editableText.updateFloatingCursor(cursorPoint);
2939 }
2940 }
2941
2942 // Selects the set of paragraphs in a document that intersect a given range of
2943 // global positions.
2944 void _selectParagraphsInRange({required Offset from, Offset? to, SelectionChangedCause? cause}) {
2945 final TextBoundary paragraphBoundary = ParagraphBoundary(editableText.textEditingValue.text);
2946 _selectTextBoundariesInRange(boundary: paragraphBoundary, from: from, to: to, cause: cause);
2947 }
2948
2949 // Selects the set of lines in a document that intersect a given range of
2950 // global positions.
2951 void _selectLinesInRange({required Offset from, Offset? to, SelectionChangedCause? cause}) {
2952 final TextBoundary lineBoundary = LineBoundary(renderEditable);
2953 _selectTextBoundariesInRange(boundary: lineBoundary, from: from, to: to, cause: cause);
2954 }
2955
2956 // Returns the location of a text boundary at `extent`. When `extent` is at
2957 // the end of the text, returns the previous text boundary's location.
2958 TextRange _moveToTextBoundary(TextPosition extent, TextBoundary textBoundary) {
2959 assert(extent.offset >= 0);
2960 // Use extent.offset - 1 when `extent` is at the end of the text to retrieve
2961 // the previous text boundary's location.
2962 final int start =
2963 textBoundary.getLeadingTextBoundaryAt(
2964 extent.offset == editableText.textEditingValue.text.length
2965 ? extent.offset - 1
2966 : extent.offset,
2967 ) ??
2968 0;
2969 final int end =
2970 textBoundary.getTrailingTextBoundaryAt(extent.offset) ??
2971 editableText.textEditingValue.text.length;
2972 return TextRange(start: start, end: end);
2973 }
2974
2975 // Selects the set of text boundaries in a document that intersect a given
2976 // range of global positions.
2977 //
2978 // The set of text boundaries selected are not strictly bounded by the range
2979 // of global positions.
2980 //
2981 // The first and last endpoints of the selection will always be at the
2982 // beginning and end of a text boundary respectively.
2983 void _selectTextBoundariesInRange({
2984 required TextBoundary boundary,
2985 required Offset from,
2986 Offset? to,
2987 SelectionChangedCause? cause,
2988 }) {
2989 final TextPosition fromPosition = renderEditable.getPositionForPoint(from);
2990 final TextRange fromRange = _moveToTextBoundary(fromPosition, boundary);
2991 final TextPosition toPosition = to == null
2992 ? fromPosition
2993 : renderEditable.getPositionForPoint(to);
2994 final TextRange toRange = toPosition == fromPosition
2995 ? fromRange
2996 : _moveToTextBoundary(toPosition, boundary);
2997 final bool isFromBoundaryBeforeToBoundary = fromRange.start < toRange.end;
2998
2999 final TextSelection newSelection = isFromBoundaryBeforeToBoundary
3000 ? TextSelection(baseOffset: fromRange.start, extentOffset: toRange.end)
3001 : TextSelection(baseOffset: fromRange.end, extentOffset: toRange.start);
3002
3003 editableText.userUpdateTextEditingValue(
3004 editableText.textEditingValue.copyWith(selection: newSelection),
3005 cause,
3006 );
3007 }
3008
3009 /// Handler for [TextSelectionGestureDetector.onTripleTapDown].
3010 ///
3011 /// By default, it selects a paragraph if
3012 /// [TextSelectionGestureDetectorBuilderDelegate.selectionEnabled] is true
3013 /// and shows the toolbar if necessary.
3014 ///
3015 /// See also:
3016 ///
3017 /// * [TextSelectionGestureDetector.onTripleTapDown], which triggers this
3018 /// callback.
3019 @protected
3020 void onTripleTapDown(TapDragDownDetails details) {
3021 if (!delegate.selectionEnabled) {
3022 return;
3023 }
3024 if (renderEditable.maxLines == 1) {
3025 editableText.selectAll(SelectionChangedCause.tap);
3026 } else {
3027 switch (defaultTargetPlatform) {
3028 case TargetPlatform.android:
3029 case TargetPlatform.fuchsia:
3030 case TargetPlatform.iOS:
3031 case TargetPlatform.macOS:
3032 case TargetPlatform.windows:
3033 _selectParagraphsInRange(from: details.globalPosition, cause: SelectionChangedCause.tap);
3034 case TargetPlatform.linux:
3035 _selectLinesInRange(from: details.globalPosition, cause: SelectionChangedCause.tap);
3036 }
3037 }
3038 if (shouldShowSelectionToolbar) {
3039 editableText.showToolbar();
3040 }
3041 }
3042
3043 /// Handler for [TextSelectionGestureDetector.onDragSelectionStart].
3044 ///
3045 /// By default, it selects a text position specified in [details].
3046 ///
3047 /// See also:
3048 ///
3049 /// * [TextSelectionGestureDetector.onDragSelectionStart], which triggers
3050 /// this callback.
3051 @protected
3052 void onDragSelectionStart(TapDragStartDetails details) {
3053 if (!delegate.selectionEnabled) {
3054 return;
3055 }
3056 final PointerDeviceKind? kind = details.kind;
3057 _shouldShowSelectionToolbar =
3058 kind == null || kind == PointerDeviceKind.touch || kind == PointerDeviceKind.stylus;
3059 _shouldShowSelectionHandles = _shouldShowSelectionToolbar;
3060
3061 _dragStartSelection = renderEditable.selection;
3062 _dragStartScrollOffset = _scrollPosition;
3063 _dragStartViewportOffset = renderEditable.offset.pixels;
3064
3065 if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount(
3066 details.consecutiveTapCount,
3067 ) >
3068 1) {
3069 // Do not set the selection on a consecutive tap and drag.
3070 return;
3071 }
3072
3073 if (_isShiftPressed && renderEditable.selection != null && renderEditable.selection!.isValid) {
3074 switch (defaultTargetPlatform) {
3075 case TargetPlatform.iOS:
3076 case TargetPlatform.macOS:
3077 _expandSelection(details.globalPosition, SelectionChangedCause.drag);
3078 case TargetPlatform.android:
3079 case TargetPlatform.fuchsia:
3080 case TargetPlatform.linux:
3081 case TargetPlatform.windows:
3082 _extendSelection(details.globalPosition, SelectionChangedCause.drag);
3083 }
3084 } else {
3085 switch (defaultTargetPlatform) {
3086 case TargetPlatform.iOS:
3087 switch (details.kind) {
3088 case PointerDeviceKind.mouse:
3089 case PointerDeviceKind.trackpad:
3090 renderEditable.selectPositionAt(
3091 from: details.globalPosition,
3092 cause: SelectionChangedCause.drag,
3093 );
3094 case PointerDeviceKind.stylus:
3095 case PointerDeviceKind.invertedStylus:
3096 case PointerDeviceKind.touch:
3097 case PointerDeviceKind.unknown:
3098 case null:
3099 }
3100 case TargetPlatform.android:
3101 case TargetPlatform.fuchsia:
3102 switch (details.kind) {
3103 case PointerDeviceKind.mouse:
3104 case PointerDeviceKind.trackpad:
3105 renderEditable.selectPositionAt(
3106 from: details.globalPosition,
3107 cause: SelectionChangedCause.drag,
3108 );
3109 case PointerDeviceKind.stylus:
3110 case PointerDeviceKind.invertedStylus:
3111 case PointerDeviceKind.touch:
3112 case PointerDeviceKind.unknown:
3113 // For Android, Fuchsia, and iOS platforms, a touch drag
3114 // does not initiate unless the editable has focus.
3115 if (renderEditable.hasFocus) {
3116 renderEditable.selectPositionAt(
3117 from: details.globalPosition,
3118 cause: SelectionChangedCause.drag,
3119 );
3120 _showMagnifierIfSupportedByPlatform(details.globalPosition);
3121 }
3122 case null:
3123 }
3124 case TargetPlatform.linux:
3125 case TargetPlatform.macOS:
3126 case TargetPlatform.windows:
3127 renderEditable.selectPositionAt(
3128 from: details.globalPosition,
3129 cause: SelectionChangedCause.drag,
3130 );
3131 }
3132 }
3133 }
3134
3135 /// Handler for [TextSelectionGestureDetector.onDragSelectionUpdate].
3136 ///
3137 /// By default, it updates the selection location specified in the provided
3138 /// details objects.
3139 ///
3140 /// See also:
3141 ///
3142 /// * [TextSelectionGestureDetector.onDragSelectionUpdate], which triggers
3143 /// this callback./lib/src/material/text_field.dart
3144 @protected
3145 void onDragSelectionUpdate(TapDragUpdateDetails details) {
3146 if (!delegate.selectionEnabled) {
3147 return;
3148 }
3149
3150 if (!_isShiftPressed) {
3151 // Adjust the drag start offset for possible viewport offset changes.
3152 final Offset editableOffset = renderEditable.maxLines == 1
3153 ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0)
3154 : Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset);
3155 final Offset scrollableOffset = switch (axisDirectionToAxis(
3156 _scrollDirection ?? AxisDirection.left,
3157 )) {
3158 Axis.horizontal => Offset(_scrollPosition - _dragStartScrollOffset, 0.0),
3159 Axis.vertical => Offset(0.0, _scrollPosition - _dragStartScrollOffset),
3160 };
3161 final Offset dragStartGlobalPosition = details.globalPosition - details.offsetFromOrigin;
3162
3163 // Select word by word.
3164 if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount(
3165 details.consecutiveTapCount,
3166 ) ==
3167 2) {
3168 renderEditable.selectWordsInRange(
3169 from: dragStartGlobalPosition - editableOffset - scrollableOffset,
3170 to: details.globalPosition,
3171 cause: SelectionChangedCause.drag,
3172 );
3173
3174 switch (details.kind) {
3175 case PointerDeviceKind.stylus:
3176 case PointerDeviceKind.invertedStylus:
3177 case PointerDeviceKind.touch:
3178 case PointerDeviceKind.unknown:
3179 return _showMagnifierIfSupportedByPlatform(details.globalPosition);
3180 case PointerDeviceKind.mouse:
3181 case PointerDeviceKind.trackpad:
3182 case null:
3183 return;
3184 }
3185 }
3186
3187 // Select paragraph-by-paragraph.
3188 if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount(
3189 details.consecutiveTapCount,
3190 ) ==
3191 3) {
3192 switch (defaultTargetPlatform) {
3193 case TargetPlatform.android:
3194 case TargetPlatform.fuchsia:
3195 case TargetPlatform.iOS:
3196 switch (details.kind) {
3197 case PointerDeviceKind.mouse:
3198 case PointerDeviceKind.trackpad:
3199 return _selectParagraphsInRange(
3200 from: dragStartGlobalPosition - editableOffset - scrollableOffset,
3201 to: details.globalPosition,
3202 cause: SelectionChangedCause.drag,
3203 );
3204 case PointerDeviceKind.stylus:
3205 case PointerDeviceKind.invertedStylus:
3206 case PointerDeviceKind.touch:
3207 case PointerDeviceKind.unknown:
3208 case null:
3209 // Triple tap to drag is not present on these platforms when using
3210 // non-precise pointer devices at the moment.
3211 break;
3212 }
3213 return;
3214 case TargetPlatform.linux:
3215 return _selectLinesInRange(
3216 from: dragStartGlobalPosition - editableOffset - scrollableOffset,
3217 to: details.globalPosition,
3218 cause: SelectionChangedCause.drag,
3219 );
3220 case TargetPlatform.windows:
3221 case TargetPlatform.macOS:
3222 return _selectParagraphsInRange(
3223 from: dragStartGlobalPosition - editableOffset - scrollableOffset,
3224 to: details.globalPosition,
3225 cause: SelectionChangedCause.drag,
3226 );
3227 }
3228 }
3229
3230 switch (defaultTargetPlatform) {
3231 case TargetPlatform.iOS:
3232 // With a mouse device, a drag should select the range from the origin of the drag
3233 // to the current position of the drag.
3234 //
3235 // With a touch device, nothing should happen.
3236 switch (details.kind) {
3237 case PointerDeviceKind.mouse:
3238 case PointerDeviceKind.trackpad:
3239 return renderEditable.selectPositionAt(
3240 from: dragStartGlobalPosition - editableOffset - scrollableOffset,
3241 to: details.globalPosition,
3242 cause: SelectionChangedCause.drag,
3243 );
3244 case PointerDeviceKind.stylus:
3245 case PointerDeviceKind.invertedStylus:
3246 case PointerDeviceKind.touch:
3247 case PointerDeviceKind.unknown:
3248 case null:
3249 break;
3250 }
3251 return;
3252 case TargetPlatform.android:
3253 case TargetPlatform.fuchsia:
3254 // With a precise pointer device, such as a mouse, trackpad, or stylus,
3255 // the drag will select the text spanning the origin of the drag to the end of the drag.
3256 // With a touch device, the cursor should move with the drag.
3257 switch (details.kind) {
3258 case PointerDeviceKind.mouse:
3259 case PointerDeviceKind.trackpad:
3260 case PointerDeviceKind.stylus:
3261 case PointerDeviceKind.invertedStylus:
3262 return renderEditable.selectPositionAt(
3263 from: dragStartGlobalPosition - editableOffset - scrollableOffset,
3264 to: details.globalPosition,
3265 cause: SelectionChangedCause.drag,
3266 );
3267 case PointerDeviceKind.touch:
3268 case PointerDeviceKind.unknown:
3269 if (renderEditable.hasFocus) {
3270 renderEditable.selectPositionAt(
3271 from: details.globalPosition,
3272 cause: SelectionChangedCause.drag,
3273 );
3274 return _showMagnifierIfSupportedByPlatform(details.globalPosition);
3275 }
3276 case null:
3277 break;
3278 }
3279 return;
3280 case TargetPlatform.macOS:
3281 case TargetPlatform.linux:
3282 case TargetPlatform.windows:
3283 return renderEditable.selectPositionAt(
3284 from: dragStartGlobalPosition - editableOffset - scrollableOffset,
3285 to: details.globalPosition,
3286 cause: SelectionChangedCause.drag,
3287 );
3288 }
3289 }
3290
3291 if (_dragStartSelection!.isCollapsed ||
3292 (defaultTargetPlatform != TargetPlatform.iOS &&
3293 defaultTargetPlatform != TargetPlatform.macOS)) {
3294 return _extendSelection(details.globalPosition, SelectionChangedCause.drag);
3295 }
3296
3297 // If the drag inverts the selection, Mac and iOS revert to the initial
3298 // selection.
3299 final TextSelection selection = editableText.textEditingValue.selection;
3300 final TextPosition nextExtent = renderEditable.getPositionForPoint(details.globalPosition);
3301 final bool isShiftTapDragSelectionForward =
3302 _dragStartSelection!.baseOffset < _dragStartSelection!.extentOffset;
3303 final bool isInverted = isShiftTapDragSelectionForward
3304 ? nextExtent.offset < _dragStartSelection!.baseOffset
3305 : nextExtent.offset > _dragStartSelection!.baseOffset;
3306 if (isInverted && selection.baseOffset == _dragStartSelection!.baseOffset) {
3307 editableText.userUpdateTextEditingValue(
3308 editableText.textEditingValue.copyWith(
3309 selection: TextSelection(
3310 baseOffset: _dragStartSelection!.extentOffset,
3311 extentOffset: nextExtent.offset,
3312 ),
3313 ),
3314 SelectionChangedCause.drag,
3315 );
3316 } else if (!isInverted &&
3317 nextExtent.offset != _dragStartSelection!.baseOffset &&
3318 selection.baseOffset != _dragStartSelection!.baseOffset) {
3319 editableText.userUpdateTextEditingValue(
3320 editableText.textEditingValue.copyWith(
3321 selection: TextSelection(
3322 baseOffset: _dragStartSelection!.baseOffset,
3323 extentOffset: nextExtent.offset,
3324 ),
3325 ),
3326 SelectionChangedCause.drag,
3327 );
3328 } else {
3329 _extendSelection(details.globalPosition, SelectionChangedCause.drag);
3330 }
3331 }
3332
3333 /// Handler for [TextSelectionGestureDetector.onDragSelectionEnd].
3334 ///
3335 /// By default, it cleans up the state used for handling certain
3336 /// built-in behaviors.
3337 ///
3338 /// See also:
3339 ///
3340 /// * [TextSelectionGestureDetector.onDragSelectionEnd], which triggers this
3341 /// callback.
3342 @protected
3343 void onDragSelectionEnd(TapDragEndDetails details) {
3344 if (_shouldShowSelectionToolbar &&
3345 _TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount(
3346 details.consecutiveTapCount,
3347 ) ==
3348 2) {
3349 editableText.showToolbar();
3350 }
3351
3352 if (_isShiftPressed) {
3353 _dragStartSelection = null;
3354 }
3355
3356 _hideMagnifierIfSupportedByPlatform();
3357 }
3358
3359 /// Returns a [TextSelectionGestureDetector] configured with the handlers
3360 /// provided by this builder.
3361 ///
3362 /// The [child] or its subtree should contain an [EditableText] whose key is
3363 /// the [GlobalKey] provided by the [delegate]'s
3364 /// [TextSelectionGestureDetectorBuilderDelegate.editableTextKey].
3365 Widget buildGestureDetector({Key? key, HitTestBehavior? behavior, required Widget child}) {
3366 return TextSelectionGestureDetector(
3367 key: key,
3368 onTapTrackStart: onTapTrackStart,
3369 onTapTrackReset: onTapTrackReset,
3370 onTapDown: onTapDown,
3371 onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null,
3372 onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null,
3373 onSecondaryTap: onSecondaryTap,
3374 onSecondaryTapDown: onSecondaryTapDown,
3375 onSingleTapUp: onSingleTapUp,
3376 onSingleTapCancel: onSingleTapCancel,
3377 onUserTap: onUserTap,
3378 onSingleLongTapStart: onSingleLongTapStart,
3379 onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate,
3380 onSingleLongTapEnd: onSingleLongTapEnd,
3381 onSingleLongTapCancel: onSingleLongTapCancel,
3382 onDoubleTapDown: onDoubleTapDown,
3383 onTripleTapDown: onTripleTapDown,
3384 onDragSelectionStart: onDragSelectionStart,
3385 onDragSelectionUpdate: onDragSelectionUpdate,
3386 onDragSelectionEnd: onDragSelectionEnd,
3387 onUserTapAlwaysCalled: onUserTapAlwaysCalled,
3388 behavior: behavior,
3389 child: child,
3390 );
3391 }
3392}
3393
3394/// A gesture detector to respond to non-exclusive event chains for a text field.
3395///
3396/// An ordinary [GestureDetector] configured to handle events like tap and
3397/// double tap will only recognize one or the other. This widget detects both:
3398/// the first tap and then any subsequent taps that occurs within a time limit
3399/// after the first.
3400///
3401/// See also:
3402///
3403/// * [TextField], a Material text field which uses this gesture detector.
3404/// * [CupertinoTextField], a Cupertino text field which uses this gesture
3405/// detector.
3406class TextSelectionGestureDetector extends StatefulWidget {
3407 /// Create a [TextSelectionGestureDetector].
3408 ///
3409 /// Multiple callbacks can be called for one sequence of input gesture.
3410 const TextSelectionGestureDetector({
3411 super.key,
3412 this.onTapTrackStart,
3413 this.onTapTrackReset,
3414 this.onTapDown,
3415 this.onForcePressStart,
3416 this.onForcePressEnd,
3417 this.onSecondaryTap,
3418 this.onSecondaryTapDown,
3419 this.onSingleTapUp,
3420 this.onSingleTapCancel,
3421 this.onUserTap,
3422 this.onSingleLongTapStart,
3423 this.onSingleLongTapMoveUpdate,
3424 this.onSingleLongTapEnd,
3425 this.onSingleLongTapCancel,
3426 this.onDoubleTapDown,
3427 this.onTripleTapDown,
3428 this.onDragSelectionStart,
3429 this.onDragSelectionUpdate,
3430 this.onDragSelectionEnd,
3431 this.onUserTapAlwaysCalled = false,
3432 this.behavior,
3433 required this.child,
3434 });
3435
3436 /// {@template flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackStart}
3437 /// Callback used to indicate that a tap tracking has started upon
3438 /// a [PointerDownEvent].
3439 /// {@endtemplate}
3440 final VoidCallback? onTapTrackStart;
3441
3442 /// {@template flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackReset}
3443 /// Callback used to indicate that a tap tracking has been reset which
3444 /// happens on the next [PointerDownEvent] after the timer between two taps
3445 /// elapses, the recognizer loses the arena, the gesture is cancelled or
3446 /// the recognizer is disposed of.
3447 /// {@endtemplate}
3448 final VoidCallback? onTapTrackReset;
3449
3450 /// Called for every tap down including every tap down that's part of a
3451 /// double click or a long press, except touches that include enough movement
3452 /// to not qualify as taps (e.g. pans and flings).
3453 final GestureTapDragDownCallback? onTapDown;
3454
3455 /// Called when a pointer has tapped down and the force of the pointer has
3456 /// just become greater than [ForcePressGestureRecognizer.startPressure].
3457 final GestureForcePressStartCallback? onForcePressStart;
3458
3459 /// Called when a pointer that had previously triggered [onForcePressStart] is
3460 /// lifted off the screen.
3461 final GestureForcePressEndCallback? onForcePressEnd;
3462
3463 /// Called for a tap event with the secondary mouse button.
3464 final GestureTapCallback? onSecondaryTap;
3465
3466 /// Called for a tap down event with the secondary mouse button.
3467 final GestureTapDownCallback? onSecondaryTapDown;
3468
3469 /// Called for the first tap in a series of taps, consecutive taps do not call
3470 /// this method.
3471 ///
3472 /// For example, if the detector was configured with [onTapDown] and
3473 /// [onDoubleTapDown], three quick taps would be recognized as a single tap
3474 /// down, followed by a tap up, then a double tap down, followed by a single tap down.
3475 final GestureTapDragUpCallback? onSingleTapUp;
3476
3477 /// Called for each touch that becomes recognized as a gesture that is not a
3478 /// short tap, such as a long tap or drag. It is called at the moment when
3479 /// another gesture from the touch is recognized.
3480 final GestureCancelCallback? onSingleTapCancel;
3481
3482 /// Called for the first tap in a series of taps when [onUserTapAlwaysCalled] is
3483 /// disabled, which is the default behavior.
3484 ///
3485 /// When [onUserTapAlwaysCalled] is enabled, this is called for every tap,
3486 /// including consecutive taps.
3487 final GestureTapCallback? onUserTap;
3488
3489 /// Called for a single long tap that's sustained for longer than
3490 /// [kLongPressTimeout] but not necessarily lifted. Not called for a
3491 /// double-tap-hold, which calls [onDoubleTapDown] instead.
3492 final GestureLongPressStartCallback? onSingleLongTapStart;
3493
3494 /// Called after [onSingleLongTapStart] when the pointer is dragged.
3495 final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate;
3496
3497 /// Called after [onSingleLongTapStart] when the pointer is lifted.
3498 final GestureLongPressEndCallback? onSingleLongTapEnd;
3499
3500 /// Called after [onSingleLongTapStart] when the pointer is canceled.
3501 final GestureLongPressCancelCallback? onSingleLongTapCancel;
3502
3503 /// Called after a momentary hold or a short tap that is close in space and
3504 /// time (within [kDoubleTapTimeout]) to a previous short tap.
3505 final GestureTapDragDownCallback? onDoubleTapDown;
3506
3507 /// Called after a momentary hold or a short tap that is close in space and
3508 /// time (within [kDoubleTapTimeout]) to a previous double-tap.
3509 final GestureTapDragDownCallback? onTripleTapDown;
3510
3511 /// Called when a mouse starts dragging to select text.
3512 final GestureTapDragStartCallback? onDragSelectionStart;
3513
3514 /// Called repeatedly as a mouse moves while dragging.
3515 final GestureTapDragUpdateCallback? onDragSelectionUpdate;
3516
3517 /// Called when a mouse that was previously dragging is released.
3518 final GestureTapDragEndCallback? onDragSelectionEnd;
3519
3520 /// Whether [onUserTap] will be called for all taps including consecutive taps.
3521 ///
3522 /// Defaults to false, so [onUserTap] is only called for each distinct tap.
3523 final bool onUserTapAlwaysCalled;
3524
3525 /// How this gesture detector should behave during hit testing.
3526 ///
3527 /// This defaults to [HitTestBehavior.deferToChild].
3528 final HitTestBehavior? behavior;
3529
3530 /// Child below this widget.
3531 final Widget child;
3532
3533 @override
3534 State<StatefulWidget> createState() => _TextSelectionGestureDetectorState();
3535}
3536
3537class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetector> {
3538 // Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
3539 // which can grow to be infinitely large, to a value between 1 and 3. The value
3540 // that the raw count is converted to is based on the default observed behavior
3541 // on the native platforms.
3542 //
3543 // This method should be used in all instances when details.consecutiveTapCount
3544 // would be used.
3545 static int _getEffectiveConsecutiveTapCount(int rawCount) {
3546 switch (defaultTargetPlatform) {
3547 case TargetPlatform.android:
3548 case TargetPlatform.fuchsia:
3549 case TargetPlatform.linux:
3550 // From observation, these platform's reset their tap count to 0 when
3551 // the number of consecutive taps exceeds 3. For example on Debian Linux
3552 // with GTK, when going past a triple click, on the fourth click the
3553 // selection is moved to the precise click position, on the fifth click
3554 // the word at the position is selected, and on the sixth click the
3555 // paragraph at the position is selected.
3556 return rawCount <= 3 ? rawCount : (rawCount % 3 == 0 ? 3 : rawCount % 3);
3557 case TargetPlatform.iOS:
3558 case TargetPlatform.macOS:
3559 // From observation, these platform's either hold their tap count at 3.
3560 // For example on macOS, when going past a triple click, the selection
3561 // should be retained at the paragraph that was first selected on triple
3562 // click.
3563 return math.min(rawCount, 3);
3564 case TargetPlatform.windows:
3565 // From observation, this platform's consecutive tap actions alternate
3566 // between double click and triple click actions. For example, after a
3567 // triple click has selected a paragraph, on the next click the word at
3568 // the clicked position will be selected, and on the next click the
3569 // paragraph at the position is selected.
3570 return rawCount < 2 ? rawCount : 2 + rawCount % 2;
3571 }
3572 }
3573
3574 void _handleTapTrackStart() {
3575 widget.onTapTrackStart?.call();
3576 }
3577
3578 void _handleTapTrackReset() {
3579 widget.onTapTrackReset?.call();
3580 }
3581
3582 // The down handler is force-run on success of a single tap and optimistically
3583 // run before a long press success.
3584 void _handleTapDown(TapDragDownDetails details) {
3585 widget.onTapDown?.call(details);
3586 // This isn't detected as a double tap gesture in the gesture recognizer
3587 // because it's 2 single taps, each of which may do different things depending
3588 // on whether it's a single tap, the first tap of a double tap, the second
3589 // tap held down, a clean double tap etc.
3590 if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 2) {
3591 return widget.onDoubleTapDown?.call(details);
3592 }
3593
3594 if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 3) {
3595 return widget.onTripleTapDown?.call(details);
3596 }
3597 }
3598
3599 void _handleTapUp(TapDragUpDetails details) {
3600 if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 1) {
3601 widget.onSingleTapUp?.call(details);
3602 widget.onUserTap?.call();
3603 } else if (widget.onUserTapAlwaysCalled) {
3604 widget.onUserTap?.call();
3605 }
3606 }
3607
3608 void _handleTapCancel() {
3609 widget.onSingleTapCancel?.call();
3610 }
3611
3612 void _handleDragStart(TapDragStartDetails details) {
3613 widget.onDragSelectionStart?.call(details);
3614 }
3615
3616 void _handleDragUpdate(TapDragUpdateDetails details) {
3617 widget.onDragSelectionUpdate?.call(details);
3618 }
3619
3620 void _handleDragEnd(TapDragEndDetails details) {
3621 widget.onDragSelectionEnd?.call(details);
3622 }
3623
3624 void _forcePressStarted(ForcePressDetails details) {
3625 widget.onForcePressStart?.call(details);
3626 }
3627
3628 void _forcePressEnded(ForcePressDetails details) {
3629 widget.onForcePressEnd?.call(details);
3630 }
3631
3632 void _handleLongPressStart(LongPressStartDetails details) {
3633 widget.onSingleLongTapStart?.call(details);
3634 }
3635
3636 void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
3637 widget.onSingleLongTapMoveUpdate?.call(details);
3638 }
3639
3640 void _handleLongPressEnd(LongPressEndDetails details) {
3641 widget.onSingleLongTapEnd?.call(details);
3642 }
3643
3644 void _handleLongPressCancel() {
3645 widget.onSingleLongTapCancel?.call();
3646 }
3647
3648 @override
3649 Widget build(BuildContext context) {
3650 final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
3651
3652 gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
3653 () => TapGestureRecognizer(debugOwner: this),
3654 (TapGestureRecognizer instance) {
3655 instance
3656 ..onSecondaryTap = widget.onSecondaryTap
3657 ..onSecondaryTapDown = widget.onSecondaryTapDown;
3658 },
3659 );
3660
3661 if (widget.onSingleLongTapStart != null ||
3662 widget.onSingleLongTapMoveUpdate != null ||
3663 widget.onSingleLongTapEnd != null ||
3664 widget.onSingleLongTapCancel != null) {
3665 gestures[LongPressGestureRecognizer] =
3666 GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
3667 () => LongPressGestureRecognizer(
3668 debugOwner: this,
3669 supportedDevices: <PointerDeviceKind>{PointerDeviceKind.touch},
3670 ),
3671 (LongPressGestureRecognizer instance) {
3672 instance
3673 ..onLongPressStart = _handleLongPressStart
3674 ..onLongPressMoveUpdate = _handleLongPressMoveUpdate
3675 ..onLongPressEnd = _handleLongPressEnd
3676 ..onLongPressCancel = _handleLongPressCancel;
3677 },
3678 );
3679 }
3680
3681 if (widget.onDragSelectionStart != null ||
3682 widget.onDragSelectionUpdate != null ||
3683 widget.onDragSelectionEnd != null) {
3684 switch (defaultTargetPlatform) {
3685 case TargetPlatform.android:
3686 case TargetPlatform.fuchsia:
3687 case TargetPlatform.iOS:
3688 gestures[TapAndHorizontalDragGestureRecognizer] =
3689 GestureRecognizerFactoryWithHandlers<TapAndHorizontalDragGestureRecognizer>(
3690 () => TapAndHorizontalDragGestureRecognizer(debugOwner: this),
3691 (TapAndHorizontalDragGestureRecognizer instance) {
3692 instance
3693 // Text selection should start from the position of the first pointer
3694 // down event.
3695 ..dragStartBehavior = DragStartBehavior.down
3696 ..eagerVictoryOnDrag = defaultTargetPlatform != TargetPlatform.iOS
3697 ..onTapTrackStart = _handleTapTrackStart
3698 ..onTapTrackReset = _handleTapTrackReset
3699 ..onTapDown = _handleTapDown
3700 ..onDragStart = _handleDragStart
3701 ..onDragUpdate = _handleDragUpdate
3702 ..onDragEnd = _handleDragEnd
3703 ..onTapUp = _handleTapUp
3704 ..onCancel = _handleTapCancel;
3705 },
3706 );
3707 case TargetPlatform.linux:
3708 case TargetPlatform.macOS:
3709 case TargetPlatform.windows:
3710 gestures[TapAndPanGestureRecognizer] =
3711 GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
3712 () => TapAndPanGestureRecognizer(debugOwner: this),
3713 (TapAndPanGestureRecognizer instance) {
3714 instance
3715 // Text selection should start from the position of the first pointer
3716 // down event.
3717 ..dragStartBehavior = DragStartBehavior.down
3718 ..onTapTrackStart = _handleTapTrackStart
3719 ..onTapTrackReset = _handleTapTrackReset
3720 ..onTapDown = _handleTapDown
3721 ..onDragStart = _handleDragStart
3722 ..onDragUpdate = _handleDragUpdate
3723 ..onDragEnd = _handleDragEnd
3724 ..onTapUp = _handleTapUp
3725 ..onCancel = _handleTapCancel;
3726 },
3727 );
3728 }
3729 }
3730
3731 if (widget.onForcePressStart != null || widget.onForcePressEnd != null) {
3732 gestures[ForcePressGestureRecognizer] =
3733 GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>(
3734 () => ForcePressGestureRecognizer(debugOwner: this),
3735 (ForcePressGestureRecognizer instance) {
3736 instance
3737 ..onStart = widget.onForcePressStart != null ? _forcePressStarted : null
3738 ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null;
3739 },
3740 );
3741 }
3742
3743 return RawGestureDetector(
3744 gestures: gestures,
3745 excludeFromSemantics: true,
3746 behavior: widget.behavior,
3747 child: widget.child,
3748 );
3749 }
3750}
3751
3752/// A [ValueNotifier] whose [value] indicates whether the current contents of
3753/// the clipboard can be pasted.
3754///
3755/// The contents of the clipboard can only be read asynchronously, via
3756/// [Clipboard.getData], so this maintains a value that can be used
3757/// synchronously. Call [update] to asynchronously update value if needed.
3758class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with WidgetsBindingObserver {
3759 /// Create a new ClipboardStatusNotifier.
3760 ClipboardStatusNotifier({ClipboardStatus value = ClipboardStatus.unknown}) : super(value);
3761
3762 bool _disposed = false;
3763
3764 /// Check the [Clipboard] and update [value] if needed.
3765 Future<void> update() async {
3766 if (_disposed) {
3767 return;
3768 }
3769
3770 final bool hasStrings;
3771 try {
3772 hasStrings = await Clipboard.hasStrings();
3773 } catch (exception, stack) {
3774 FlutterError.reportError(
3775 FlutterErrorDetails(
3776 exception: exception,
3777 stack: stack,
3778 library: 'widget library',
3779 context: ErrorDescription('while checking if the clipboard has strings'),
3780 ),
3781 );
3782 // In the case of an error from the Clipboard API, set the value to
3783 // unknown so that it will try to update again later.
3784 if (_disposed) {
3785 return;
3786 }
3787 value = ClipboardStatus.unknown;
3788 return;
3789 }
3790 final ClipboardStatus nextStatus = hasStrings
3791 ? ClipboardStatus.pasteable
3792 : ClipboardStatus.notPasteable;
3793
3794 if (_disposed) {
3795 return;
3796 }
3797 value = nextStatus;
3798 }
3799
3800 @override
3801 void addListener(VoidCallback listener) {
3802 if (!hasListeners) {
3803 WidgetsBinding.instance.addObserver(this);
3804 }
3805 if (value == ClipboardStatus.unknown) {
3806 update();
3807 }
3808 super.addListener(listener);
3809 }
3810
3811 @override
3812 void removeListener(VoidCallback listener) {
3813 super.removeListener(listener);
3814 if (!_disposed && !hasListeners) {
3815 WidgetsBinding.instance.removeObserver(this);
3816 }
3817 }
3818
3819 @override
3820 void didChangeAppLifecycleState(AppLifecycleState state) {
3821 switch (state) {
3822 case AppLifecycleState.resumed:
3823 update();
3824 case AppLifecycleState.detached:
3825 case AppLifecycleState.inactive:
3826 case AppLifecycleState.hidden:
3827 case AppLifecycleState.paused:
3828 // Nothing to do.
3829 break;
3830 }
3831 }
3832
3833 @override
3834 void dispose() {
3835 WidgetsBinding.instance.removeObserver(this);
3836 _disposed = true;
3837 super.dispose();
3838 }
3839}
3840
3841/// An enumeration of the status of the content on the user's clipboard.
3842enum ClipboardStatus {
3843 /// The clipboard content can be pasted, such as a String of nonzero length.
3844 pasteable,
3845
3846 /// The status of the clipboard is unknown. Since getting clipboard data is
3847 /// asynchronous (see [Clipboard.getData]), this status often exists while
3848 /// waiting to receive the clipboard contents for the first time.
3849 unknown,
3850
3851 /// The content on the clipboard is not pasteable, such as when it is empty.
3852 notPasteable,
3853}
3854
3855/// A [ValueNotifier] whose [value] indicates whether the current device supports the Live Text
3856/// (OCR) function.
3857///
3858/// See also:
3859/// * [LiveText], where the availability of Live Text input can be obtained.
3860/// * [LiveTextInputStatus], an enumeration that indicates whether the current device is available
3861/// for Live Text input.
3862///
3863/// Call [update] to asynchronously update [value] if needed.
3864class LiveTextInputStatusNotifier extends ValueNotifier<LiveTextInputStatus>
3865 with WidgetsBindingObserver {
3866 /// Create a new LiveTextStatusNotifier.
3867 LiveTextInputStatusNotifier({LiveTextInputStatus value = LiveTextInputStatus.unknown})
3868 : super(value);
3869
3870 bool _disposed = false;
3871
3872 /// Check the [LiveTextInputStatus] and update [value] if needed.
3873 Future<void> update() async {
3874 if (_disposed) {
3875 return;
3876 }
3877
3878 final bool isLiveTextInputEnabled;
3879 try {
3880 isLiveTextInputEnabled = await LiveText.isLiveTextInputAvailable();
3881 } catch (exception, stack) {
3882 FlutterError.reportError(
3883 FlutterErrorDetails(
3884 exception: exception,
3885 stack: stack,
3886 library: 'widget library',
3887 context: ErrorDescription('while checking the availability of Live Text input'),
3888 ),
3889 );
3890 // In the case of an error from the Live Text API, set the value to
3891 // unknown so that it will try to update again later.
3892 if (_disposed || value == LiveTextInputStatus.unknown) {
3893 return;
3894 }
3895 value = LiveTextInputStatus.unknown;
3896 return;
3897 }
3898
3899 final LiveTextInputStatus nextStatus = isLiveTextInputEnabled
3900 ? LiveTextInputStatus.enabled
3901 : LiveTextInputStatus.disabled;
3902
3903 if (_disposed || nextStatus == value) {
3904 return;
3905 }
3906 value = nextStatus;
3907 }
3908
3909 @override
3910 void addListener(VoidCallback listener) {
3911 if (!hasListeners) {
3912 WidgetsBinding.instance.addObserver(this);
3913 }
3914 if (value == LiveTextInputStatus.unknown) {
3915 update();
3916 }
3917 super.addListener(listener);
3918 }
3919
3920 @override
3921 void removeListener(VoidCallback listener) {
3922 super.removeListener(listener);
3923 if (!_disposed && !hasListeners) {
3924 WidgetsBinding.instance.removeObserver(this);
3925 }
3926 }
3927
3928 @override
3929 void didChangeAppLifecycleState(AppLifecycleState state) {
3930 switch (state) {
3931 case AppLifecycleState.resumed:
3932 update();
3933 case AppLifecycleState.detached:
3934 case AppLifecycleState.inactive:
3935 case AppLifecycleState.paused:
3936 case AppLifecycleState.hidden:
3937 // Nothing to do.
3938 }
3939 }
3940
3941 @override
3942 void dispose() {
3943 WidgetsBinding.instance.removeObserver(this);
3944 _disposed = true;
3945 super.dispose();
3946 }
3947}
3948
3949/// An enumeration that indicates whether the current device is available for Live Text input.
3950///
3951/// See also:
3952/// * [LiveText], where the availability of Live Text input can be obtained.
3953enum LiveTextInputStatus {
3954 /// This device supports Live Text input currently.
3955 enabled,
3956
3957 /// The status of the Live Text input is unknown. Since getting the Live Text input availability
3958 /// is asynchronous (see [LiveText.isLiveTextInputAvailable]), this status often exists while
3959 /// waiting to receive the status value for the first time.
3960 unknown,
3961
3962 /// The current device doesn't support Live Text input.
3963 disabled,
3964}
3965
3966// TODO(justinmc): Deprecate this after TextSelectionControls.buildToolbar is
3967// deleted, when users should migrate back to TextSelectionControls.buildHandle.
3968// See https://github.com/flutter/flutter/pull/124262
3969/// [TextSelectionControls] that specifically do not manage the toolbar in order
3970/// to leave that to [EditableText.contextMenuBuilder].
3971mixin TextSelectionHandleControls on TextSelectionControls {
3972 @override
3973 Widget buildToolbar(
3974 BuildContext context,
3975 Rect globalEditableRegion,
3976 double textLineHeight,
3977 Offset selectionMidpoint,
3978 List<TextSelectionPoint> endpoints,
3979 TextSelectionDelegate delegate,
3980 ValueListenable<ClipboardStatus>? clipboardStatus,
3981 Offset? lastSecondaryTapDownPosition,
3982 ) => const SizedBox.shrink();
3983
3984 @override
3985 bool canCut(TextSelectionDelegate delegate) => false;
3986
3987 @override
3988 bool canCopy(TextSelectionDelegate delegate) => false;
3989
3990 @override
3991 bool canPaste(TextSelectionDelegate delegate) => false;
3992
3993 @override
3994 bool canSelectAll(TextSelectionDelegate delegate) => false;
3995
3996 @override
3997 void handleCut(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {}
3998
3999 @override
4000 void handleCopy(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {}
4001
4002 @override
4003 Future<void> handlePaste(TextSelectionDelegate delegate) async {}
4004
4005 @override
4006 void handleSelectAll(TextSelectionDelegate delegate) {}
4007}
4008