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