From efb9368573f605fdf63b6143d7a352b400805a72 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Tue, 24 May 2022 13:53:55 -0700 Subject: [PATCH] Supports global selection for all devices (#95226) * Support global selection * addressing comments * add new test * Addressing review comments * update * addressing comments * addressing comments * Addressing comments * fix build --- .../selection_area/custom_container.dart | 131 ++ .../selection_area/custom_selectable.dart | 263 +++ .../disable_partial_selection.dart | 37 + .../selection_area/selection_area.dart | 37 + packages/flutter/lib/material.dart | 1 + packages/flutter/lib/rendering.dart | 1 + .../lib/src/material/selection_area.dart | 97 + .../lib/src/painting/text_painter.dart | 1 + .../flutter/lib/src/rendering/object.dart | 14 +- .../flutter/lib/src/rendering/paragraph.dart | 459 +++++ .../flutter/lib/src/rendering/selection.dart | 608 +++++++ .../rendering/sliver_multi_box_adaptor.dart | 1 + .../lib/src/widgets/automatic_keep_alive.dart | 2 + packages/flutter/lib/src/widgets/basic.dart | 45 +- .../lib/src/widgets/reorderable_list.dart | 146 +- .../flutter/lib/src/widgets/scrollable.dart | 485 ++++- .../lib/src/widgets/selectable_region.dart | 1606 +++++++++++++++++ .../lib/src/widgets/selection_container.dart | 303 ++++ packages/flutter/lib/src/widgets/sliver.dart | 121 +- packages/flutter/lib/src/widgets/text.dart | 32 + .../lib/src/widgets/text_selection.dart | 32 - packages/flutter/lib/widgets.dart | 3 + .../test/material/selection_area_test.dart | 36 + .../test/rendering/paragraph_test.dart | 132 +- .../test/rendering/selection_test.dart | 84 + .../widgets/scrollable_selection_test.dart | 652 +++++++ .../test/widgets/selectable_region_test.dart | 1135 ++++++++++++ .../widgets/selection_container_test.dart | 146 ++ 28 files changed, 6424 insertions(+), 186 deletions(-) create mode 100644 examples/api/lib/material/selection_area/custom_container.dart create mode 100644 examples/api/lib/material/selection_area/custom_selectable.dart create mode 100644 examples/api/lib/material/selection_area/disable_partial_selection.dart create mode 100644 examples/api/lib/material/selection_area/selection_area.dart create mode 100644 packages/flutter/lib/src/material/selection_area.dart create mode 100644 packages/flutter/lib/src/rendering/selection.dart create mode 100644 packages/flutter/lib/src/widgets/selectable_region.dart create mode 100644 packages/flutter/lib/src/widgets/selection_container.dart create mode 100644 packages/flutter/test/material/selection_area_test.dart create mode 100644 packages/flutter/test/rendering/selection_test.dart create mode 100644 packages/flutter/test/widgets/scrollable_selection_test.dart create mode 100644 packages/flutter/test/widgets/selectable_region_test.dart create mode 100644 packages/flutter/test/widgets/selection_container_test.dart diff --git a/examples/api/lib/material/selection_area/custom_container.dart b/examples/api/lib/material/selection_area/custom_container.dart new file mode 100644 index 000000000000..264cbfd93ea1 --- /dev/null +++ b/examples/api/lib/material/selection_area/custom_container.dart @@ -0,0 +1,131 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This sample demonstrates how to create a [SelectionContainer] that only +// allows selecting everything or nothing with no partial selection. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + static const String _title = 'Flutter Code Sample'; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: _title, + home: SelectionArea( + child: Scaffold( + appBar: AppBar(title: const Text(_title)), + body: Center( + child: SelectionAllOrNoneContainer( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Text('Row 1'), + Text('Row 2'), + Text('Row 3'), + ], + ), + ), + ), + ), + ), + ); + } +} + +class SelectionAllOrNoneContainer extends StatefulWidget { + const SelectionAllOrNoneContainer({ + super.key, + required this.child + }); + + final Widget child; + + @override + State createState() => _SelectionAllOrNoneContainerState(); +} + +class _SelectionAllOrNoneContainerState extends State { + final SelectAllOrNoneContainerDelegate delegate = SelectAllOrNoneContainerDelegate(); + + @override + void dispose() { + delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionContainer( + delegate: delegate, + child: widget.child, + ); + } +} + +class SelectAllOrNoneContainerDelegate extends MultiSelectableSelectionContainerDelegate { + Offset? _adjustedStartEdge; + Offset? _adjustedEndEdge; + bool _isSelected = false; + + // This method is called when newly added selectable is in the current + // selected range. + @override + void ensureChildUpdated(Selectable selectable) { + if (_isSelected) { + dispatchSelectionEventToChild(selectable, const SelectAllSelectionEvent()); + } + } + + @override + SelectionResult handleSelectWord(SelectWordSelectionEvent event) { + // Treat select word as select all. + return handleSelectAll(const SelectAllSelectionEvent()); + } + + @override + SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + final Rect containerRect = Rect.fromLTWH(0, 0, containerSize.width, containerSize.height); + final Matrix4 globalToLocal = getTransformTo(null)..invert(); + final Offset localOffset = MatrixUtils.transformPoint(globalToLocal, event.globalPosition); + final Offset adjustOffset = SelectionUtils.adjustDragOffset(containerRect, localOffset); + if (event.type == SelectionEventType.startEdgeUpdate) { + _adjustedStartEdge = adjustOffset; + } else { + _adjustedEndEdge = adjustOffset; + } + // Select all content if the selection rect intercepts with the rect. + if (_adjustedStartEdge != null && _adjustedEndEdge != null) { + final Rect selectionRect = Rect.fromPoints(_adjustedStartEdge!, _adjustedEndEdge!); + if (!selectionRect.intersect(containerRect).isEmpty) { + handleSelectAll(const SelectAllSelectionEvent()); + } else { + super.handleClearSelection(const ClearSelectionEvent()); + } + } else { + super.handleClearSelection(const ClearSelectionEvent()); + } + return SelectionUtils.getResultBasedOnRect(containerRect, localOffset); + } + + @override + SelectionResult handleClearSelection(ClearSelectionEvent event) { + _adjustedStartEdge = null; + _adjustedEndEdge = null; + _isSelected = false; + return super.handleClearSelection(event); + } + + @override + SelectionResult handleSelectAll(SelectAllSelectionEvent event) { + _isSelected = true; + return super.handleSelectAll(event); + } +} diff --git a/examples/api/lib/material/selection_area/custom_selectable.dart b/examples/api/lib/material/selection_area/custom_selectable.dart new file mode 100644 index 000000000000..3fc6ee27d28f --- /dev/null +++ b/examples/api/lib/material/selection_area/custom_selectable.dart @@ -0,0 +1,263 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This sample demonstrates how to create an adapter widget that makes any child +// widget selectable. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + static const String _title = 'Flutter Code Sample'; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: _title, + home: SelectionArea( + child: Scaffold( + appBar: AppBar(title: const Text(_title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Text('Select this icon', style: TextStyle(fontSize: 30)), + SizedBox(height: 10), + MySelectableAdapter(child: Icon(Icons.key, size: 30)), + ], + ), + ), + ), + ), + ); + } +} + +class MySelectableAdapter extends StatelessWidget { + const MySelectableAdapter({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); + if (registrar == null) { + return child; + } + return MouseRegion( + cursor: SystemMouseCursors.text, + child: _SelectableAdapter( + registrar: registrar, + child: child, + ), + ); + } +} + +class _SelectableAdapter extends SingleChildRenderObjectWidget { + const _SelectableAdapter({ + required this.registrar, + required Widget child, + }) : super(child: child); + + final SelectionRegistrar registrar; + + @override + _RenderSelectableAdapter createRenderObject(BuildContext context) { + return _RenderSelectableAdapter( + DefaultSelectionStyle.of(context).selectionColor!, + registrar, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSelectableAdapter renderObject) { + renderObject + ..selectionColor = DefaultSelectionStyle.of(context).selectionColor! + ..registrar = registrar; + } +} + +class _RenderSelectableAdapter extends RenderProxyBox with Selectable, SelectionRegistrant { + _RenderSelectableAdapter( + Color selectionColor, + SelectionRegistrar registrar, + ) : _selectionColor = selectionColor, + _geometry = ValueNotifier(_noSelection) { + this.registrar = registrar; + _geometry.addListener(markNeedsPaint); + } + + static const SelectionGeometry _noSelection = SelectionGeometry(status: SelectionStatus.none, hasContent: true); + final ValueNotifier _geometry; + + Color get selectionColor => _selectionColor; + late Color _selectionColor; + set selectionColor(Color value) { + if (_selectionColor == value) { + return; + } + _selectionColor = value; + markNeedsPaint(); + } + + // ValueListenable APIs + + @override + void addListener(VoidCallback listener) => _geometry.addListener(listener); + + @override + void removeListener(VoidCallback listener) => _geometry.removeListener(listener); + + @override + SelectionGeometry get value => _geometry.value; + + // Selectable APIs. + + // Adjust this value to enlarge or shrink the selection highlight. + static const double _padding = 10.0; + Rect _getSelectionHighlightRect() { + return Rect.fromLTWH( + 0 - _padding, + 0 - _padding, + size.width + _padding * 2, + size.height + _padding * 2 + ); + } + + Offset? _start; + Offset? _end; + void _updateGeometry() { + if (_start == null || _end == null) { + _geometry.value = _noSelection; + return; + } + final Rect renderObjectRect = Rect.fromLTWH(0, 0, size.width, size.height); + final Rect selectionRect = Rect.fromPoints(_start!, _end!); + if (renderObjectRect.intersect(selectionRect).isEmpty) { + _geometry.value = _noSelection; + } else { + final Rect selectionRect = _getSelectionHighlightRect(); + final SelectionPoint firstSelectionPoint = SelectionPoint( + localPosition: selectionRect.bottomLeft, + lineHeight: selectionRect.size.height, + handleType: TextSelectionHandleType.left, + ); + final SelectionPoint secondSelectionPoint = SelectionPoint( + localPosition: selectionRect.bottomRight, + lineHeight: selectionRect.size.height, + handleType: TextSelectionHandleType.right, + ); + final bool isReversed; + if (_start!.dy > _end!.dy) { + isReversed = true; + } else if (_start!.dy < _end!.dy) { + isReversed = false; + } else { + isReversed = _start!.dx > _end!.dx; + } + _geometry.value = SelectionGeometry( + status: SelectionStatus.uncollapsed, + hasContent: true, + startSelectionPoint: isReversed ? secondSelectionPoint : firstSelectionPoint, + endSelectionPoint: isReversed ? firstSelectionPoint : secondSelectionPoint, + ); + } + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + SelectionResult result = SelectionResult.none; + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + case SelectionEventType.endEdgeUpdate: + final Rect renderObjectRect = Rect.fromLTWH(0, 0, size.width, size.height); + // Normalize offset in case it is out side of the rect. + final Offset point = globalToLocal((event as SelectionEdgeUpdateEvent).globalPosition); + final Offset adjustedPoint = SelectionUtils.adjustDragOffset(renderObjectRect, point); + if (event.type == SelectionEventType.startEdgeUpdate) { + _start = adjustedPoint; + } else { + _end = adjustedPoint; + } + result = SelectionUtils.getResultBasedOnRect(renderObjectRect, point); + break; + case SelectionEventType.clear: + _start = _end = null; + break; + case SelectionEventType.selectAll: + case SelectionEventType.selectWord: + _start = Offset.zero; + _end = Offset.infinite; + break; + } + _updateGeometry(); + return result; + } + + // This method is called when users want to copy selected content in this + // widget into clipboard. + @override + SelectedContent? getSelectedContent() { + return value.hasSelection ? const SelectedContent(plainText: 'Custom Text') : null; + } + + LayerLink? _startHandle; + LayerLink? _endHandle; + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { + if (_startHandle == startHandle && _endHandle == endHandle) { + return; + } + _startHandle = startHandle; + _endHandle = endHandle; + markNeedsPaint(); + } + + @override + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset); + if (!_geometry.value.hasSelection) { + return; + } + // Draw the selection highlight. + final Paint selectionPaint = Paint() + ..style = PaintingStyle.fill + ..color = _selectionColor; + context.canvas.drawRect(_getSelectionHighlightRect().shift(offset), selectionPaint); + + // Push the layer links if any. + if (_startHandle != null) { + context.pushLayer( + LeaderLayer( + link: _startHandle!, + offset: offset + value.startSelectionPoint!.localPosition, + ), + (PaintingContext context, Offset offset) { }, + Offset.zero, + ); + } + if (_endHandle != null) { + context.pushLayer( + LeaderLayer( + link: _endHandle!, + offset: offset + value.endSelectionPoint!.localPosition, + ), + (PaintingContext context, Offset offset) { }, + Offset.zero, + ); + } + } + + @override + void dispose() { + _geometry.dispose(); + super.dispose(); + } +} diff --git a/examples/api/lib/material/selection_area/disable_partial_selection.dart b/examples/api/lib/material/selection_area/disable_partial_selection.dart new file mode 100644 index 000000000000..1e977865cbd7 --- /dev/null +++ b/examples/api/lib/material/selection_area/disable_partial_selection.dart @@ -0,0 +1,37 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This example excludes a Text widget from the SelectionArea. + +import 'package:flutter/material.dart'; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + static const String _title = 'Flutter Code Sample'; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: _title, + home: Scaffold( + appBar: AppBar(title: const Text(_title)), + body: Center( + child: SelectionArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Text('Selectable text'), + SelectionContainer.disabled(child: Text('Non-selectable text')), + Text('Selectable text'), + ], + ), + ), + ), + ), + ); + } +} diff --git a/examples/api/lib/material/selection_area/selection_area.dart b/examples/api/lib/material/selection_area/selection_area.dart new file mode 100644 index 000000000000..3577b459b1b3 --- /dev/null +++ b/examples/api/lib/material/selection_area/selection_area.dart @@ -0,0 +1,37 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This example shows how to make a screen selectable.. + +import 'package:flutter/material.dart'; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + static const String _title = 'Flutter Code Sample'; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: _title, + home: SelectionArea( + child: Scaffold( + appBar: AppBar(title: const Text(_title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Text('Row 1'), + Text('Row 2'), + Text('Row 3'), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 7df8daf39346..0cc406bec6b6 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -129,6 +129,7 @@ export 'src/material/scrollbar.dart'; export 'src/material/scrollbar_theme.dart'; export 'src/material/search.dart'; export 'src/material/selectable_text.dart'; +export 'src/material/selection_area.dart'; export 'src/material/shadows.dart'; export 'src/material/slider.dart'; export 'src/material/slider_theme.dart'; diff --git a/packages/flutter/lib/rendering.dart b/packages/flutter/lib/rendering.dart index faad56b779df..2bda350eba1b 100644 --- a/packages/flutter/lib/rendering.dart +++ b/packages/flutter/lib/rendering.dart @@ -54,6 +54,7 @@ export 'src/rendering/platform_view.dart'; export 'src/rendering/proxy_box.dart'; export 'src/rendering/proxy_sliver.dart'; export 'src/rendering/rotated_box.dart'; +export 'src/rendering/selection.dart'; export 'src/rendering/shifted_box.dart'; export 'src/rendering/sliver.dart'; export 'src/rendering/sliver_fill.dart'; diff --git a/packages/flutter/lib/src/material/selection_area.dart b/packages/flutter/lib/src/material/selection_area.dart new file mode 100644 index 000000000000..c75c1f824f69 --- /dev/null +++ b/packages/flutter/lib/src/material/selection_area.dart @@ -0,0 +1,97 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; + +import 'desktop_text_selection.dart'; +import 'text_selection.dart'; +import 'theme.dart'; + +/// A widget that introduces an area for user selections with adaptive selection +/// controls. +/// +/// This widget creates a [SelectableRegion] with platform-adaptive selection +/// controls. +/// +/// Flutter widgets are not selectable by default. To enable selection for +/// a specific screen, consider wrapping the body of the [Route] with a +/// [SelectionArea]. +/// +/// {@tool dartpad} +/// This example shows how to make a screen selectable. +/// +/// ** See code in examples/api/lib/material/selection_area/selection_area.dart ** +/// {@end-tool} +/// +/// See also: +/// * [SelectableRegion], which provides an overview of the selection system. +class SelectionArea extends StatefulWidget { + /// Creates a [SelectionArea]. + /// + /// If [selectionControls] is null, a platform specific one is used. + const SelectionArea({ + super.key, + this.focusNode, + this.selectionControls, + required this.child, + }); + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The delegate to build the selection handles and toolbar. + /// + /// If it is null, the platform specific selection control is used. + final TextSelectionControls? selectionControls; + + /// The child widget this selection area applies to. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + State createState() => _SelectionAreaState(); +} + +class _SelectionAreaState extends State { + FocusNode get _effectiveFocusNode { + if (widget.focusNode != null) + return widget.focusNode!; + _internalNode ??= FocusNode(); + return _internalNode!; + } + FocusNode? _internalNode; + + @override + void dispose() { + _internalNode?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + TextSelectionControls? controls = widget.selectionControls; + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + controls ??= materialTextSelectionControls; + break; + case TargetPlatform.iOS: + controls ??= cupertinoTextSelectionControls; + break; + case TargetPlatform.linux: + case TargetPlatform.windows: + controls ??= desktopTextSelectionControls; + break; + case TargetPlatform.macOS: + controls ??= cupertinoDesktopTextSelectionControls; + break; + } + return SelectableRegion( + focusNode: _effectiveFocusNode, + selectionControls: controls, + child: widget.child, + ); + } +} diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index 2b37f23b7b75..1f1370ccc729 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -487,6 +487,7 @@ class TextPainter { return builder.build() ..layout(const ui.ParagraphConstraints(width: double.infinity)); } + /// The height of a space in [text] in logical pixels. /// /// Not every line of text in [text] will have this height, but this height diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 6f8b1b1db09d..9e3466572bbb 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -2727,6 +2727,7 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im return true; } + /// {@template flutter.rendering.RenderObject.getTransformTo} /// Applies the paint transform up the tree to `ancestor`. /// /// Returns a matrix that maps the local paint coordinate system to the @@ -2734,11 +2735,14 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im /// /// If `ancestor` is null, this method returns a matrix that maps from the /// local paint coordinate system to the coordinate system of the - /// [PipelineOwner.rootNode]. For the render tree owner by the - /// [RendererBinding] (i.e. for the main render tree displayed on the device) - /// this means that this method maps to the global coordinate system in - /// logical pixels. To get physical pixels, use [applyPaintTransform] from the - /// [RenderView] to further transform the coordinate. + /// [PipelineOwner.rootNode]. + /// {@endtemplate} + /// + /// For the render tree owned by the [RendererBinding] (i.e. for the main + /// render tree displayed on the device) this means that this method maps to + /// the global coordinate system in logical pixels. To get physical pixels, + /// use [applyPaintTransform] from the [RenderView] to further transform the + /// coordinate. Matrix4 getTransformTo(RenderObject? ancestor) { final bool ancestorSpecified = ancestor != null; assert(attached); diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 6dfebb256e9e..71204abc0ed7 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -14,7 +14,9 @@ import 'package:vector_math/vector_math_64.dart'; import 'box.dart'; import 'debug.dart'; +import 'layer.dart'; import 'object.dart'; +import 'selection.dart'; const String _kEllipsis = '\u2026'; @@ -84,6 +86,8 @@ class RenderParagraph extends RenderBox TextWidthBasis textWidthBasis = TextWidthBasis.parent, ui.TextHeightBehavior? textHeightBehavior, List? children, + Color? selectionColor, + SelectionRegistrar? registrar, }) : assert(text != null), assert(text.debugAssertIsValid()), assert(textAlign != null), @@ -95,6 +99,7 @@ class RenderParagraph extends RenderBox assert(textWidthBasis != null), _softWrap = softWrap, _overflow = overflow, + _selectionColor = selectionColor, _textPainter = TextPainter( text: text, textAlign: textAlign, @@ -109,6 +114,7 @@ class RenderParagraph extends RenderBox ) { addAll(children); _extractPlaceholderSpans(text); + this.registrar = registrar; } @override @@ -117,6 +123,7 @@ class RenderParagraph extends RenderBox child.parentData = TextParentData(); } + static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit); final TextPainter _textPainter; AttributedString? _cachedAttributedLabel; List? _cachedCombinedSemanticsInfos; @@ -146,6 +153,106 @@ class RenderParagraph extends RenderBox markNeedsLayout(); break; } + _removeSelectionRegistrarSubscription(); + _disposeSelectableFragments(); + _updateSelectionRegistrarSubscription(); + } + + /// The ongoing selections in this paragraph. + /// + /// The selection does not include selections in [PlaceholderSpan] if there + /// are any. + @visibleForTesting + List get selections { + if (_lastSelectableFragments == null) + return const []; + final List results = []; + for (final _SelectableFragment fragment in _lastSelectableFragments!) { + if (fragment._textSelectionStart != null && + fragment._textSelectionEnd != null && + fragment._textSelectionStart!.offset != fragment._textSelectionEnd!.offset) { + results.add( + TextSelection( + baseOffset: fragment._textSelectionStart!.offset, + extentOffset: fragment._textSelectionEnd!.offset + ) + ); + } + } + return results; + } + + // Should be null if selection is not enabled, i.e. _registrar = null. The + // paragraph splits on [PlaceholderSpan.placeholderCodeUnit], and stores each + // fragment in this list. + List<_SelectableFragment>? _lastSelectableFragments; + + /// The [SelectionRegistrar] this paragraph will be, or is, registered to. + SelectionRegistrar? get registrar => _registrar; + SelectionRegistrar? _registrar; + set registrar(SelectionRegistrar? value) { + if (value == _registrar) + return; + _removeSelectionRegistrarSubscription(); + _disposeSelectableFragments(); + _registrar = value; + _updateSelectionRegistrarSubscription(); + } + + void _updateSelectionRegistrarSubscription() { + if (_registrar == null) { + return; + } + _lastSelectableFragments ??= _getSelectableFragments(); + _lastSelectableFragments!.forEach(_registrar!.add); + } + + void _removeSelectionRegistrarSubscription() { + if (_registrar == null || _lastSelectableFragments == null) { + return; + } + _lastSelectableFragments!.forEach(_registrar!.remove); + } + + List<_SelectableFragment> _getSelectableFragments() { + final String plainText = text.toPlainText(includeSemanticsLabels: false); + final List<_SelectableFragment> result = <_SelectableFragment>[]; + int start = 0; + while (start < plainText.length) { + int end = plainText.indexOf(_placeholderCharacter, start); + if (start != end) { + if (end == -1) + end = plainText.length; + result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end))); + start = end; + } + start += 1; + } + return result; + } + + void _disposeSelectableFragments() { + if (_lastSelectableFragments == null) + return; + for (final _SelectableFragment fragment in _lastSelectableFragments!) { + fragment.dispose(); + } + _lastSelectableFragments = null; + } + + @override + void markNeedsLayout() { + _lastSelectableFragments?.forEach((_SelectableFragment element) => element.didChangeParagraphLayout()); + super.markNeedsLayout(); + } + + @override + void dispose() { + _removeSelectionRegistrarSubscription(); + // _lastSelectableFragments may hold references to this RenderParagraph. + // Release them manually to avoid retain cycles. + _lastSelectableFragments = null; + super.dispose(); } late List _placeholderSpans; @@ -298,6 +405,22 @@ class RenderParagraph extends RenderBox markNeedsLayout(); } + /// The color to use when painting the selection. + Color? get selectionColor => _selectionColor; + Color? _selectionColor; + set selectionColor(Color? value) { + if (_selectionColor == value) + return; + _selectionColor = value; + if (_lastSelectableFragments?.any((_SelectableFragment fragment) => fragment.value.hasSelection) ?? false) { + markNeedsPaint(); + } + } + + Offset _getOffsetForPosition(TextPosition position) { + return getOffsetForCaret(position, Rect.zero) + Offset(0, getFullHeightForCaret(position) ?? 0.0); + } + @override double computeMinIntrinsicWidth(double height) { if (!_canComputeIntrinsics()) { @@ -775,6 +898,12 @@ class RenderParagraph extends RenderBox } context.canvas.restore(); } + if (_lastSelectableFragments != null) { + for (final _SelectableFragment fragment in _lastSelectableFragments!) { + fragment.paint(context, offset); + } + } + super.paint(context, offset); } /// Returns the offset at which to paint the caret. @@ -1088,3 +1217,333 @@ class RenderParagraph extends RenderBox properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited')); } } + +/// A continuous, selectable piece of paragraph. +/// +/// Since the selections in [PlaceHolderSpan] are handled independently in its +/// subtree, a selection in [RenderParagraph] can't continue across a +/// [PlaceHolderSpan]. The [RenderParagraph] splits itself on [PlaceHolderSpan] +/// to create multiple `_SelectableFragment`s so that they can be selected +/// separately. +class _SelectableFragment with Selectable, ChangeNotifier { + _SelectableFragment({ + required this.paragraph, + required this.range, + }) : assert(range.isValid && !range.isCollapsed && range.isNormalized) { + _selectionGeometry = _getSelectionGeometry(); + } + + final TextRange range; + final RenderParagraph paragraph; + + TextPosition? _textSelectionStart; + TextPosition? _textSelectionEnd; + + LayerLink? _startHandleLayerLink; + LayerLink? _endHandleLayerLink; + + @override + SelectionGeometry get value => _selectionGeometry; + late SelectionGeometry _selectionGeometry; + void _updateSelectionGeometry() { + final SelectionGeometry newValue = _getSelectionGeometry(); + if (_selectionGeometry == newValue) + return; + _selectionGeometry = newValue; + notifyListeners(); + } + + SelectionGeometry _getSelectionGeometry() { + if (_textSelectionStart == null || _textSelectionEnd == null) { + return const SelectionGeometry( + status: SelectionStatus.none, + hasContent: true, + ); + } + + final int selectionStart = _textSelectionStart!.offset; + final int selectionEnd = _textSelectionEnd!.offset; + final bool isReversed = selectionStart > selectionEnd; + final Offset startOffsetInParagraphCoordinates = paragraph._getOffsetForPosition(TextPosition(offset: selectionStart)); + final Offset endOffsetInParagraphCoordinates = selectionStart == selectionEnd + ? startOffsetInParagraphCoordinates + : paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd)); + final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection); + final Matrix4 paragraphToFragmentTransform = getTransformToParagraph()..invert(); + return SelectionGeometry( + startSelectionPoint: SelectionPoint( + localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, startOffsetInParagraphCoordinates), + lineHeight: paragraph._textPainter.preferredLineHeight, + handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left + ), + endSelectionPoint: SelectionPoint( + localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, endOffsetInParagraphCoordinates), + lineHeight: paragraph._textPainter.preferredLineHeight, + handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right, + ), + status: _textSelectionStart!.offset == _textSelectionEnd!.offset + ? SelectionStatus.collapsed + : SelectionStatus.uncollapsed, + hasContent: true, + ); + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + late final SelectionResult result; + final TextPosition? existingSelectionStart = _textSelectionStart; + final TextPosition? existingSelectionEnd = _textSelectionEnd; + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + case SelectionEventType.endEdgeUpdate: + final SelectionEdgeUpdateEvent edgeUpdate = event as SelectionEdgeUpdateEvent; + result = _updateSelectionEdge(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate); + break; + case SelectionEventType.clear: + result = _handleClearSelection(); + break; + case SelectionEventType.selectAll: + result = _handleSelectAll(); + break; + case SelectionEventType.selectWord: + final SelectWordSelectionEvent selectWord = event as SelectWordSelectionEvent; + result = _handleSelectWord(selectWord.globalPosition); + break; + } + + if (existingSelectionStart != _textSelectionStart || + existingSelectionEnd != _textSelectionEnd) { + _didChangeSelection(); + } + return result; + } + + @override + SelectedContent? getSelectedContent() { + if (_textSelectionStart == null || _textSelectionEnd == null) { + return null; + } + final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset); + final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset); + return SelectedContent( + plainText: paragraph.text.toPlainText(includeSemanticsLabels: false).substring(start, end), + ); + } + + void _didChangeSelection() { + paragraph.markNeedsPaint(); + _updateSelectionGeometry(); + } + + SelectionResult _updateSelectionEdge(Offset globalPosition, {required bool isEnd}) { + _setSelectionPosition(null, isEnd: isEnd); + final Matrix4 transform = paragraph.getTransformTo(null); + transform.invert(); + final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); + if (_rect.isEmpty) { + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + final Offset adjustedOffset = SelectionUtils.adjustDragOffset( + _rect, + localPosition, + direction: paragraph.textDirection, + ); + + final TextPosition position = _clampTextPosition(paragraph.getPositionForOffset(adjustedOffset)); + _setSelectionPosition(position, isEnd: isEnd); + if (position.offset == range.end) { + return SelectionResult.next; + } + if (position.offset == range.start) { + return SelectionResult.previous; + } + // TODO(chunhtai): The geometry information should not be used to determine + // selection result. This is a workaround to RenderParagraph, where it does + // not have a way to get accurate text length if its text is truncated due to + // layout constraint. + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + + TextPosition _clampTextPosition(TextPosition position) { + // Affinity of range.end is upstream. + if (position.offset > range.end || + (position.offset == range.end && position.affinity == TextAffinity.downstream)) { + return TextPosition(offset: range.end, affinity: TextAffinity.upstream); + } + if (position.offset < range.start) { + return TextPosition(offset: range.start); + } + return position; + } + + void _setSelectionPosition(TextPosition? position, {required bool isEnd}) { + if (isEnd) + _textSelectionEnd = position; + else + _textSelectionStart = position; + } + + SelectionResult _handleClearSelection() { + _textSelectionStart = null; + _textSelectionEnd = null; + return SelectionResult.none; + } + + SelectionResult _handleSelectAll() { + _textSelectionStart = TextPosition(offset: range.start); + _textSelectionEnd = TextPosition(offset: range.end, affinity: TextAffinity.upstream); + return SelectionResult.none; + } + + SelectionResult _handleSelectWord(Offset globalPosition) { + final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition)); + if (_positionIsWithinCurrentSelection(position)) { + return SelectionResult.end; + } + final TextRange word = paragraph.getWordBoundary(position); + assert(word.isNormalized); + // Fragments are separated by placeholder span, the word boundary shouldn't + // expand across fragments. + assert(word.start >= range.start && word.end <= range.end); + late TextPosition start; + late TextPosition end; + if (position.offset >= word.end) { + start = end = TextPosition(offset: position.offset); + } else { + start = TextPosition(offset: word.start); + end = TextPosition(offset: word.end, affinity: TextAffinity.upstream); + } + _textSelectionStart = start; + _textSelectionEnd = end; + return SelectionResult.end; + } + + /// Whether the given text position is contained in current selection + /// range. + /// + /// The parameter `start` must be smaller than `end`. + bool _positionIsWithinCurrentSelection(TextPosition position) { + if (_textSelectionStart == null || _textSelectionEnd == null) + return false; + // Normalize current selection. + late TextPosition currentStart; + late TextPosition currentEnd; + if (_compareTextPositions(_textSelectionStart!, _textSelectionEnd!) > 0) { + currentStart = _textSelectionStart!; + currentEnd = _textSelectionEnd!; + } else { + currentStart = _textSelectionEnd!; + currentEnd = _textSelectionStart!; + } + return _compareTextPositions(currentStart, position) >= 0 && _compareTextPositions(currentEnd, position) <= 0; + } + + /// Compares two text positions. + /// + /// Returns 1 if `position` < `otherPosition`, -1 if `position` > `otherPosition`, + /// or 0 if they are equal. + static int _compareTextPositions(TextPosition position, TextPosition otherPosition) { + if (position.offset < otherPosition.offset) { + return 1; + } else if (position.offset > otherPosition.offset) { + return -1; + } else if (position.affinity == otherPosition.affinity){ + return 0; + } else { + return position.affinity == TextAffinity.upstream ? 1 : -1; + } + } + + Matrix4 getTransformToParagraph() { + return Matrix4.translationValues(_rect.left, _rect.top, 0.0); + } + + @override + Matrix4 getTransformTo(RenderObject? ancestor) { + return getTransformToParagraph()..multiply(paragraph.getTransformTo(ancestor)); + } + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { + if (!paragraph.attached) { + assert(startHandle == null && endHandle == null, 'Only clean up can be called.'); + return; + } + if (_startHandleLayerLink != startHandle) { + _startHandleLayerLink = startHandle; + paragraph.markNeedsPaint(); + } + if (_endHandleLayerLink != endHandle) { + _endHandleLayerLink = endHandle; + paragraph.markNeedsPaint(); + } + } + + Rect get _rect { + if (_cachedRect == null) { + final List boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + ); + if (boxes.isNotEmpty) { + Rect result = boxes.first.toRect(); + for (int index = 1; index < boxes.length; index += 1) { + result = result.expandToInclude(boxes[index].toRect()); + } + _cachedRect = result; + } else { + final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start)); + _cachedRect = Rect.fromPoints(offset, offset.translate(0, - paragraph._textPainter.preferredLineHeight)); + } + } + return _cachedRect!; + } + Rect? _cachedRect; + + void didChangeParagraphLayout() { + _cachedRect = null; + } + + @override + Size get size { + return _rect.size; + } + + void paint(PaintingContext context, Offset offset) { + if (_textSelectionStart == null || _textSelectionEnd == null) + return; + if (paragraph.selectionColor != null) { + final TextSelection selection = TextSelection( + baseOffset: _textSelectionStart!.offset, + extentOffset: _textSelectionEnd!.offset, + ); + final Paint selectionPaint = Paint() + ..style = PaintingStyle.fill + ..color = paragraph.selectionColor!; + for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { + context.canvas.drawRect( + textBox.toRect().shift(offset), selectionPaint); + } + } + final Matrix4 transform = getTransformToParagraph(); + if (_startHandleLayerLink != null && value.startSelectionPoint != null) { + context.pushLayer( + LeaderLayer( + link: _startHandleLayerLink!, + offset: offset + MatrixUtils.transformPoint(transform, value.startSelectionPoint!.localPosition), + ), + (PaintingContext context, Offset offset) { }, + Offset.zero, + ); + } + if (_endHandleLayerLink != null && value.endSelectionPoint != null) { + context.pushLayer( + LeaderLayer( + link: _endHandleLayerLink!, + offset: offset + MatrixUtils.transformPoint(transform, value.endSelectionPoint!.localPosition), + ), + (PaintingContext context, Offset offset) { }, + Offset.zero, + ); + } + } +} diff --git a/packages/flutter/lib/src/rendering/selection.dart b/packages/flutter/lib/src/rendering/selection.dart new file mode 100644 index 000000000000..96ebb19a4c5c --- /dev/null +++ b/packages/flutter/lib/src/rendering/selection.dart @@ -0,0 +1,608 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:vector_math/vector_math_64.dart'; + +import 'layer.dart'; +import 'object.dart'; + +/// The result after handling a [SelectionEvent]. +/// +/// [SelectionEvent]s are sent from [SelectionRegistrar] to be handled by +/// [SelectionHandler.dispatchSelectionEvent]. The subclasses of +/// [SelectionHandler] or [Selectable] must return appropriate +/// [SelectionResult]s after handling the events. +/// +/// This is used by the [SelectionContainer] to determine how a selection +/// expands across its [Selectable] children. +enum SelectionResult { + /// There is nothing left to select forward in this [Selectable], and further + /// selection should extend to the next [Selectable] in screen order. + /// + /// {@template flutter.rendering.selection.SelectionResult.footNote} + /// This is used after subclasses [SelectionHandler] or [Selectable] handled + /// [SelectionEdgeUpdateEvent]. + /// {@endtemplate} + next, + /// Selection does not reach this [Selectable] and is located before it in + /// screen order. + /// + /// {@macro flutter.rendering.selection.SelectionResult.footNote} + previous, + /// Selection ends in this [Selectable]. + /// + /// Part of the [Selectable] may or may not be selected, but there is still + /// content to select forward or backward. + /// + /// {@macro flutter.rendering.selection.SelectionResult.footNote} + end, + /// The result can't be determined in this frame. + /// + /// This is typically used when the subtree is scrolling to reveal more + /// content. + /// + /// {@macro flutter.rendering.selection.SelectionResult.footNote} + // See `_SelectableRegionState._triggerSelectionEndEdgeUpdate` for how this + // result affects the selection. + pending, + /// There is no result for the selection event. + /// + /// This is used when a selection result is not applicable, e.g. + /// [SelectAllSelectionEvent], [ClearSelectionEvent], and + /// [SelectWordSelectionEvent]. + none, +} + +/// The abstract interface to handle [SelectionEvent]s. +/// +/// This interface is extended by [Selectable] and [SelectionContainerDelegate] +/// and is typically not use directly. +/// +/// {@template flutter.rendering.SelectionHandler} +/// This class returns a [SelectionGeometry] as its [value], and is responsible +/// to notify its listener when its selection geometry has changed as the result +/// of receiving selection events. +/// {@endtemplate} +abstract class SelectionHandler implements ValueListenable { + /// Marks this handler to be responsible for pushing [LeaderLayer]s for the + /// selection handles. + /// + /// This handler is responsible for pushing the leader layers with the + /// given layer links if they are not null. It is possible that only one layer + /// is non-null if this handler is only responsible for pushing one layer + /// link. + /// + /// The `startHandle` needs to be placed at the visual location of selection + /// start, the `endHandle` needs to be placed at the visual location of selection + /// end. Typically, the visual locations should be the same as + /// [SelectionGeometry.startSelectionPoint] and + /// [SelectionGeometry.endSelectionPoint]. + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle); + + /// Gets the selected content in this object. + /// + /// Return `null` if nothing is selected. + SelectedContent? getSelectedContent(); + + /// Handles the [SelectionEvent] sent to this object. + /// + /// The subclasses need to update their selections or delegate the + /// [SelectionEvent]s to their subtrees. + /// + /// The `event`s are subclasses of [SelectionEvent]. Check + /// [SelectionEvent.type] to determine what kinds of event are dispatched to + /// this handler and handle them accordingly. + /// + /// See also: + /// * [SelectionEventType], which contains all of the possible types. + SelectionResult dispatchSelectionEvent(SelectionEvent event); +} + +/// The selected content in a [Selectable] or [SelectionHandler]. +// TODO(chunhtai): Add more support for rich content. +// https://github.com/flutter/flutter/issues/104206. +class SelectedContent { + /// Creates a selected content object. + /// + /// Only supports plain text. + const SelectedContent({required this.plainText}); + + /// The selected content in plain text format. + final String plainText; +} + +/// A mixin that can be selected by users when under a [SelectionArea] widget. +/// +/// This object receives selection events and the [value] must reflect the +/// current selection in this [Selectable]. The object must also notify its +/// listener if the [value] ever changes. +/// +/// This object is responsible for drawing the selection highlight. +/// +/// In order to receive the selection event, the mixer needs to register +/// itself to [SelectionRegistrar]s. Use +/// [SelectionContainer.maybeOf] to get the selection registrar, and +/// mix the [SelectionRegistrant] to subscribe to the [SelectionRegistrar] +/// automatically. +/// +/// This mixin is typically mixed by [RenderObject]s. The [RenderObject.paint] +/// methods are responsible to push the [LayerLink]s provided to +/// [pushHandleLayers]. +/// +/// {@macro flutter.rendering.SelectionHandler} +/// +/// See also: +/// * [SelectionArea], which provides an overview of selection system. +mixin Selectable implements SelectionHandler { + /// {@macro flutter.rendering.RenderObject.getTransformTo} + Matrix4 getTransformTo(RenderObject? ancestor); + + /// The size of this [Selectable]. + Size get size; + + /// Disposes resources held by the mixer. + void dispose(); +} + +/// A mixin to auto-register the mixer to the [registrar]. +/// +/// To use this mixin, the mixer needs to set the [registrar] to the +/// [SelectionRegistrar] it wants to register to. +/// +/// This mixin only registers the mixer with the [registrar] if the +/// [SelectionGeometry.hasContent] returned by the mixer is true. +mixin SelectionRegistrant on Selectable { + /// The [SelectionRegistrar] the mixer will be or is registered to. + /// + /// This [Selectable] only registers the mixer if the + /// [SelectionGeometry.hasContent] returned by the [Selectable] is true. + SelectionRegistrar? get registrar => _registrar; + SelectionRegistrar? _registrar; + set registrar(SelectionRegistrar? value) { + if (value == _registrar) + return; + if (value == null) { + // When registrar goes from non-null to null; + removeListener(_updateSelectionRegistrarSubscription); + } else if (_registrar == null) { + // When registrar goes from null to non-null; + addListener(_updateSelectionRegistrarSubscription); + } + _removeSelectionRegistrarSubscription(); + _registrar = value; + _updateSelectionRegistrarSubscription(); + } + + @override + void dispose() { + _removeSelectionRegistrarSubscription(); + super.dispose(); + } + + bool _subscribedToSelectionRegistrar = false; + void _updateSelectionRegistrarSubscription() { + if (_registrar == null) { + _subscribedToSelectionRegistrar = false; + return; + } + if (_subscribedToSelectionRegistrar && !value.hasContent) { + _registrar!.remove(this); + _subscribedToSelectionRegistrar = false; + } else if (!_subscribedToSelectionRegistrar && value.hasContent) { + _registrar!.add(this); + _subscribedToSelectionRegistrar = true; + } + } + + void _removeSelectionRegistrarSubscription() { + if (_subscribedToSelectionRegistrar) { + _registrar!.remove(this); + _subscribedToSelectionRegistrar = false; + } + } +} + +/// A utility class that provides useful methods for handling selection events. +class SelectionUtils { + SelectionUtils._(); + + /// Determines [SelectionResult] purely based on the target rectangle. + /// + /// This method returns [SelectionResult.end] if the `point` is inside the + /// `targetRect`. Returns [SelectionResult.previous] if the `point` is + /// considered to be lower than `targetRect` in screen order. Returns + /// [SelectionResult.next] if the point is considered to be higher than + /// `targetRect` in screen order. + static SelectionResult getResultBasedOnRect(Rect targetRect, Offset point) { + if (targetRect.contains(point)) { + return SelectionResult.end; + } + if (point.dy < targetRect.top) + return SelectionResult.previous; + if (point.dy > targetRect.bottom) + return SelectionResult.next; + return point.dx >= targetRect.right + ? SelectionResult.next + : SelectionResult.previous; + } + + /// Adjusts the dragging offset based on the target rect. + /// + /// This method moves the offsets to be within the target rect in case they are + /// outside the rect. + /// + /// This is used in the case where a drag happens outside of the rectangle + /// of a [Selectable]. + /// + /// The logic works as the following: + /// ![](https://flutter.github.io/assets-for-api-docs/assets/rendering/adjust_drag_offset.png) + /// + /// For points inside the rect: + /// Their effective locations are unchanged. + /// + /// For points in Area 1: + /// Move them to top-left of the rect if text direction is ltr, or top-right + /// if rtl. + /// + /// For points in Area 2: + /// Move them to bottom-right of the rect if text direction is ltr, or + /// bottom-left if rtl. + static Offset adjustDragOffset(Rect targetRect, Offset point, {TextDirection direction = TextDirection.ltr}) { + if (targetRect.contains(point)) { + return point; + } + if (point.dy <= targetRect.top || + point.dy <= targetRect.bottom && point.dx <= targetRect.left) { + // Area 1 + return direction == TextDirection.ltr ? targetRect.topLeft : targetRect.topRight; + } else { + // Area 2 + return direction == TextDirection.ltr ? targetRect.bottomRight : targetRect.bottomLeft; + } + } +} + +/// The type of a [SelectionEvent]. +/// +/// Used by [SelectionEvent.type] to distinguish different types of events. +enum SelectionEventType { + /// An event to update the selection start edge. + /// + /// Used by [SelectionEdgeUpdateEvent]. + startEdgeUpdate, + + /// An event to update the selection end edge. + /// + /// Used by [SelectionEdgeUpdateEvent]. + endEdgeUpdate, + + /// An event to clear the current selection. + /// + /// Used by [ClearSelectionEvent]. + clear, + + /// An event to select all the available content. + /// + /// Used by [SelectAllSelectionEvent]. + selectAll, + + /// An event to select a word at the location + /// [SelectWordSelectionEvent.globalPosition]. + /// + /// Used by [SelectWordSelectionEvent]. + selectWord, +} + +/// An abstract base class for selection events. +/// +/// This should not be directly used. To handle a selection event, it should +/// be downcast to a specific subclass. One can use [type] to look up which +/// subclasses to downcast to. +/// +/// See also: +/// * [SelectAllSelectionEvent], for events to select all contents. +/// * [ClearSelectionEvent], for events to clear selections. +/// * [SelectWordSelectionEvent], for events to select words at the locations. +/// * [SelectionEdgeUpdateEvent], for events to update selection edges. +/// * [SelectionEventType], for determining the subclass types. +abstract class SelectionEvent { + const SelectionEvent._(this.type); + + /// The type of this selection event. + final SelectionEventType type; +} + +/// Selects all selectable contents. +/// +/// This event can be sent as the result of keyboard select-all, i.e. +/// ctrl + A, or cmd + A in macOS. +class SelectAllSelectionEvent extends SelectionEvent { + /// Creates a select all selection event. + const SelectAllSelectionEvent(): super._(SelectionEventType.selectAll); +} + +/// Clears the selection from the [Selectable] and removes any existing +/// highlight as if there is no selection at all. +class ClearSelectionEvent extends SelectionEvent { + /// Create a clear selection event. + const ClearSelectionEvent(): super._(SelectionEventType.clear); +} + +/// Selects the whole word at the location. +/// +/// This event can be sent as the result of mobile long press selection. +class SelectWordSelectionEvent extends SelectionEvent { + /// Creates a select word event at the [globalPosition]. + const SelectWordSelectionEvent({required this.globalPosition}): super._(SelectionEventType.selectWord); + + /// The position in global coordinates to select word at. + final Offset globalPosition; +} + +/// Updates a selection edge. +/// +/// An active selection contains two edges, start and end. Use the [type] to +/// determine which edge this event applies to. If the [type] is +/// [SelectionEventType.startEdgeUpdate], the event updates start edge. If the +/// [type] is [SelectionEventType.endEdgeUpdate], the event updates end edge. +/// +/// The [globalPosition] contains the new offset of the edge. +/// +/// This event is dispatched when the framework detects [DragStartDetails] in +/// [SelectionArea]'s gesture recognizers for mouse devices, or the selection +/// handles have been dragged to new locations. +class SelectionEdgeUpdateEvent extends SelectionEvent { + /// Creates a selection start edge update event. + /// + /// The [globalPosition] contains the location of the selection start edge. + const SelectionEdgeUpdateEvent.forStart({ + required this.globalPosition + }) : super._(SelectionEventType.startEdgeUpdate); + + /// Creates a selection end edge update event. + /// + /// The [globalPosition] contains the new location of the selection end edge. + const SelectionEdgeUpdateEvent.forEnd({ + required this.globalPosition + }) : super._(SelectionEventType.endEdgeUpdate); + + /// The new location of the selection edge. + final Offset globalPosition; +} + +/// A registrar that keeps track of [Selectable]s in the subtree. +/// +/// A [Selectable] is only included in the [SelectableRegion] if they are +/// registered with a [SelectionRegistrar]. Once a [Selectable] is registered, +/// it will receive [SelectionEvent]s in +/// [SelectionHandler.dispatchSelectionEvent]. +/// +/// Use [SelectionContainer.maybeOf] to get the immediate [SelectionRegistrar] +/// in the ancestor chain above the build context. +/// +/// See also: +/// * [SelectableRegion], which provides an overview of the selection system. +/// * [SelectionRegistrarScope], which hosts the [SelectionRegistrar] for the +/// subtree. +/// * [SelectionRegistrant], which auto registers the object with the mixin to +/// [SelectionRegistrar]. +abstract class SelectionRegistrar { + /// Adds the [selectable] into the registrar. + /// + /// A [Selectable] must register with the [SelectionRegistrar] in order to + /// receive selection events. + void add(Selectable selectable); + + /// Removes the [selectable] from the registrar. + /// + /// A [Selectable] must unregister itself if it is removed from the rendering + /// tree. + void remove(Selectable selectable); +} + +/// The status that indicates whether there is a selection and whether the +/// selection is collapsed. +/// +/// A collapsed selection means the selection starts and ends at the same +/// location. +enum SelectionStatus { + /// The selection is not collapsed. + /// + /// For example if `{}` represent the selection edges: + /// 'ab{cd}', the collapsing status is [uncollapsed]. + /// '{abcd}', the collapsing status is [uncollapsed]. + uncollapsed, + + /// The selection is collapsed. + /// + /// For example if `{}` represent the selection edges: + /// 'ab{}cd', the collapsing status is [collapsed]. + /// '{}abcd', the collapsing status is [collapsed]. + /// 'abcd{}', the collapsing status is [collapsed]. + collapsed, + + /// No selection. + none, +} + +/// The geometry of the current selection. +/// +/// This includes details such as the locations of the selection start and end, +/// line height, etc. This information is used for drawing selection controls +/// for mobile platforms. +/// +/// The positions in geometry are in local coordinates of the [SelectionHandler] +/// or [Selectable]. +@immutable +class SelectionGeometry { + /// Creates a selection geometry object. + /// + /// If any of the [startSelectionPoint] and [endSelectionPoint] is not null, + /// the [status] must not be [SelectionStatus.none]. + const SelectionGeometry({ + this.startSelectionPoint, + this.endSelectionPoint, + required this.status, + required this.hasContent, + }) : assert((startSelectionPoint == null && endSelectionPoint == null) || status != SelectionStatus.none); + + /// The geometry information at the selection start. + /// + /// This information is used for drawing mobile selection controls. The + /// [SelectionPoint.localPosition] of the selection start is typically at the + /// start of the selection highlight at where the start selection handle + /// should be drawn. + /// + /// The [SelectionPoint.handleType] should be [TextSelectionHandleType.left] + /// for forward selection or [TextSelectionHandleType.right] for backward + /// selection in most cases. + /// + /// Can be null if the selection start is offstage, for example, when the + /// selection is outside of the viewport or is kept alive by a scrollable. + final SelectionPoint? startSelectionPoint; + + /// The geometry information at the selection end. + /// + /// This information is used for drawing mobile selection controls. The + /// [SelectionPoint.localPosition] of the selection end is typically at the end + /// of the selection highlight at where the end selection handle should be + /// drawn. + /// + /// The [SelectionPoint.handleType] should be [TextSelectionHandleType.right] + /// for forward selection or [TextSelectionHandleType.left] for backward + /// selection in most cases. + /// + /// Can be null if the selection end is offstage, for example, when the + /// selection is outside of the viewport or is kept alive by a scrollable. + final SelectionPoint? endSelectionPoint; + + /// The status of ongoing selection in the [Selectable] or [SelectionHandler]. + final SelectionStatus status; + + /// Whether there is any selectable content in the [Selectable] or + /// [SelectionHandler]. + final bool hasContent; + + /// Whether there is an ongoing selection. + bool get hasSelection => status != SelectionStatus.none; + + /// Makes a copy of this object with the given values updated. + SelectionGeometry copyWith({ + SelectionPoint? startSelectionPoint, + SelectionPoint? endSelectionPoint, + SelectionStatus? status, + bool? hasContent, + }) { + return SelectionGeometry( + startSelectionPoint: startSelectionPoint ?? this.startSelectionPoint, + endSelectionPoint: endSelectionPoint ?? this.endSelectionPoint, + status: status ?? this.status, + hasContent: hasContent ?? this.hasContent, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + return other is SelectionGeometry + && other.startSelectionPoint == startSelectionPoint + && other.endSelectionPoint == endSelectionPoint + && other.status == status + && other.hasContent == hasContent; + } + + @override + int get hashCode { + return hashValues( + startSelectionPoint, + endSelectionPoint, + status, + hasContent, + ); + } +} + +/// The geometry information of a selection point. +@immutable +class SelectionPoint { + /// Creates a selection point object. + /// + /// All properties must not be null. + const SelectionPoint({ + required this.localPosition, + required this.lineHeight, + required this.handleType, + }) : assert(localPosition != null), + assert(lineHeight != null), + assert(handleType != null); + + /// The position of the selection point in the local coordinates of the + /// containing [Selectable]. + final Offset localPosition; + + /// The line height at the selection point. + final double lineHeight; + + /// The selection handle type that should be used at the selection point. + /// + /// This is used for building the mobile selection handle. + final TextSelectionHandleType handleType; + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + return other is SelectionPoint + && other.localPosition == localPosition + && other.lineHeight == lineHeight + && other.handleType == handleType; + } + + @override + int get hashCode { + return hashValues( + localPosition, + lineHeight, + handleType, + ); + } +} + +/// The type of selection handle to be displayed. +/// +/// With mixed-direction text, both handles may be the same type. Examples: +/// +/// * LTR text: 'the <quick brown> fox': +/// +/// The '<' is drawn with the [left] type, the '>' with the [right] +/// +/// * RTL text: 'XOF <NWORB KCIUQ> EHT': +/// +/// Same as above. +/// +/// * mixed text: '<the NWOR<B KCIUQ fox' +/// +/// Here 'the QUICK B' is selected, but 'QUICK BROWN' is RTL. Both are drawn +/// with the [left] type. +/// +/// See also: +/// +/// * [TextDirection], which discusses left-to-right and right-to-left text in +/// more detail. +enum TextSelectionHandleType { + /// The selection handle is to the left of the selection end point. + left, + + /// The selection handle is to the right of the selection end point. + right, + + /// The start and end of the selection are co-incident at this point. + collapsed, +} diff --git a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart index 24dd49d912a2..cd926005eae5 100644 --- a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart +++ b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart @@ -117,6 +117,7 @@ abstract class RenderSliverBoxChildManager { /// true without making any assertions. bool debugAssertChildListLocked() => true; } + /// Parent data structure used by [RenderSliverWithKeepAliveMixin]. mixin KeepAliveParentDataMixin implements ParentData { /// Whether to keep the child alive even when it is no longer visible. diff --git a/packages/flutter/lib/src/widgets/automatic_keep_alive.dart b/packages/flutter/lib/src/widgets/automatic_keep_alive.dart index a7f479edb025..5f3adff45b52 100644 --- a/packages/flutter/lib/src/widgets/automatic_keep_alive.dart +++ b/packages/flutter/lib/src/widgets/automatic_keep_alive.dart @@ -42,6 +42,8 @@ class AutomaticKeepAlive extends StatefulWidget { class _AutomaticKeepAliveState extends State { Map? _handles; + // In order to apply parent data out of turn, the child of the KeepAlive + // widget must be the same across frames. late Widget _child; bool _keepingAlive = false; diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 08edde66ced3..3633037da8e3 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -5453,6 +5453,35 @@ class Flow extends MultiChildRenderObjectWidget { /// ``` /// {@end-tool} /// +/// ## Selections +/// +/// To make this [RichText] Selectable, the [RichText] needs to be in the +/// subtree of a [SelectionArea] or [SelectableRegion] and a +/// [SelectionRegistrar] needs to be assigned to the +/// [RichText.selectionRegistrar]. One can use +/// [SelectionContainer.maybeOf] to get the [SelectionRegistrar] from a +/// context. This enables users to select the text in [RichText]s with mice or +/// touch events. +/// +/// The [selectionColor] also needs to be set if the selection is enabled to +/// draw the selection highlights. +/// +/// {@tool snippet} +/// +/// This sample demonstrates how to assign a [SelectionRegistrar] for RichTexts +/// in the SelectionArea subtree. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/widgets/rich_text.png) +/// +/// ```dart +/// RichText( +/// text: const TextSpan(text: 'Hello'), +/// selectionRegistrar: SelectionContainer.maybeOf(context), +/// selectionColor: const Color(0xAF6694e8), +/// ) +/// ``` +/// {@end-tool} +/// /// See also: /// /// * [TextStyle], which discusses how to style text. @@ -5461,6 +5490,7 @@ class Flow extends MultiChildRenderObjectWidget { /// [DefaultTextStyle] to a single string. /// * [Text.rich], a const text widget that provides similar functionality /// as [RichText]. [Text.rich] will inherit [TextStyle] from [DefaultTextStyle]. +/// * [SelectableRegion], which provides an overview of the selection system. class RichText extends MultiChildRenderObjectWidget { /// Creates a paragraph of rich text. /// @@ -5485,6 +5515,8 @@ class RichText extends MultiChildRenderObjectWidget { this.strutStyle, this.textWidthBasis = TextWidthBasis.parent, this.textHeightBehavior, + this.selectionRegistrar, + this.selectionColor, }) : assert(text != null), assert(textAlign != null), assert(softWrap != null), @@ -5492,6 +5524,7 @@ class RichText extends MultiChildRenderObjectWidget { assert(textScaleFactor != null), assert(maxLines == null || maxLines > 0), assert(textWidthBasis != null), + assert(selectionRegistrar == null || selectionColor != null), super(children: _extractChildren(text)); // Traverses the InlineSpan tree and depth-first collects the list of @@ -5573,6 +5606,12 @@ class RichText extends MultiChildRenderObjectWidget { /// {@macro dart.ui.textHeightBehavior} final ui.TextHeightBehavior? textHeightBehavior; + /// The [SelectionRegistrar] this rich text is subscribed to. + final SelectionRegistrar? selectionRegistrar; + + /// The color to use when painting the selection. + final Color? selectionColor; + @override RenderParagraph createRenderObject(BuildContext context) { assert(textDirection != null || debugCheckHasDirectionality(context)); @@ -5587,6 +5626,8 @@ class RichText extends MultiChildRenderObjectWidget { textWidthBasis: textWidthBasis, textHeightBehavior: textHeightBehavior, locale: locale ?? Localizations.maybeLocaleOf(context), + registrar: selectionRegistrar, + selectionColor: selectionColor, ); } @@ -5604,7 +5645,9 @@ class RichText extends MultiChildRenderObjectWidget { ..strutStyle = strutStyle ..textWidthBasis = textWidthBasis ..textHeightBehavior = textHeightBehavior - ..locale = locale ?? Localizations.maybeLocaleOf(context); + ..locale = locale ?? Localizations.maybeLocaleOf(context) + ..registrar = selectionRegistrar + ..selectionColor = selectionColor; } @override diff --git a/packages/flutter/lib/src/widgets/reorderable_list.dart b/packages/flutter/lib/src/widgets/reorderable_list.dart index f46b3470ec86..35cde53e36bb 100644 --- a/packages/flutter/lib/src/widgets/reorderable_list.dart +++ b/packages/flutter/lib/src/widgets/reorderable_list.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:math' as math; - import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; @@ -574,7 +572,7 @@ class SliverReorderableListState extends State with Ticke // so the gap calculation can compensate for it. bool _dragStartTransitionComplete = false; - _EdgeDraggingAutoScroller? _autoScroller; + EdgeDraggingAutoScroller? _autoScroller; late ScrollableState _scrollable; Axis get _scrollDirection => axisDirectionToAxis(_scrollable.axisDirection); @@ -588,7 +586,7 @@ class SliverReorderableListState extends State with Ticke _scrollable = Scrollable.of(context)!; if (_autoScroller?.scrollable != _scrollable) { _autoScroller?.stopAutoScroll(); - _autoScroller = _EdgeDraggingAutoScroller( + _autoScroller = EdgeDraggingAutoScroller( _scrollable, onScrollViewScrolled: _handleScrollableAutoScrolled ); @@ -927,146 +925,6 @@ class SliverReorderableListState extends State with Ticke } } -/// An auto scroller that scrolls the [scrollable] if a drag gesture drag close -/// to its edge. -/// -/// The scroll velocity is controlled by the [velocityScalar]: -/// -/// velocity = * [_kDefaultAutoScrollVelocityScalar]. -class _EdgeDraggingAutoScroller { - /// Creates a auto scroller that scrolls the [scrollable]. - _EdgeDraggingAutoScroller(this.scrollable, {this.onScrollViewScrolled}); - - // An eyeball value - static const double _kDefaultAutoScrollVelocityScalar = 7; - - /// The [Scrollable] this auto scroller is scrolling. - final ScrollableState scrollable; - - /// Called when a scroll view is scrolled. - /// - /// The scroll view may be scrolled multiple times in a roll until the drag - /// target no longer triggers the auto scroll. This callback will be called - /// in between each scroll. - final VoidCallback? onScrollViewScrolled; - - late Rect _dragTargetRelatedToScrollOrigin; - - /// Whether the auto scroll is in progress. - bool get scrolling => _scrolling; - bool _scrolling = false; - - double _offsetExtent(Offset offset, Axis scrollDirection) { - switch (scrollDirection) { - case Axis.horizontal: - return offset.dx; - case Axis.vertical: - return offset.dy; - } - } - - double _sizeExtent(Size size, Axis scrollDirection) { - switch (scrollDirection) { - case Axis.horizontal: - return size.width; - case Axis.vertical: - return size.height; - } - } - - AxisDirection get _axisDirection => scrollable.axisDirection; - Axis get _scrollDirection => axisDirectionToAxis(_axisDirection); - - /// Starts the auto scroll if the [dragTarget] is close to the edge. - /// - /// The scroll starts to scroll the [scrollable] if the target rect is close - /// to the edge of the [scrollable]; otherwise, it remains stationary. - /// - /// If the scrollable is already scrolling, calling this method updates the - /// previous dragTarget to the new value and continue scrolling if necessary. - void startAutoScrollIfNecessary(Rect dragTarget) { - final Offset deltaToOrigin = _getDeltaToScrollOrigin(scrollable); - _dragTargetRelatedToScrollOrigin = dragTarget.translate(deltaToOrigin.dx, deltaToOrigin.dy); - if (_scrolling) { - // The change will be picked up in the next scroll. - return; - } - if (!_scrolling) - _scroll(); - } - - /// Stop any ongoing auto scrolling. - void stopAutoScroll() { - _scrolling = false; - } - - Future _scroll() async { - final RenderBox scrollRenderBox = scrollable.context.findRenderObject()! as RenderBox; - final Rect globalRect = MatrixUtils.transformRect( - scrollRenderBox.getTransformTo(null), - Rect.fromLTWH(0, 0, scrollRenderBox.size.width, scrollRenderBox.size.height) - ); - _scrolling = true; - double? newOffset; - const double overDragMax = 20.0; - - final Offset deltaToOrigin = _getDeltaToScrollOrigin(scrollable); - final Offset viewportOrigin = globalRect.topLeft.translate(deltaToOrigin.dx, deltaToOrigin.dy); - final double viewportStart = _offsetExtent(viewportOrigin, _scrollDirection); - final double viewportEnd = viewportStart + _sizeExtent(globalRect.size, _scrollDirection); - - final double proxyStart = _offsetExtent(_dragTargetRelatedToScrollOrigin.topLeft, _scrollDirection); - final double proxyEnd = _offsetExtent(_dragTargetRelatedToScrollOrigin.bottomRight, _scrollDirection); - late double overDrag; - if (_axisDirection == AxisDirection.up || _axisDirection == AxisDirection.left) { - if (proxyEnd > viewportEnd && scrollable.position.pixels > scrollable.position.minScrollExtent) { - overDrag = math.max(proxyEnd - viewportEnd, overDragMax); - newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag); - } else if (proxyStart < viewportStart && scrollable.position.pixels < scrollable.position.maxScrollExtent) { - overDrag = math.max(viewportStart - proxyStart, overDragMax); - newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag); - } - } else { - if (proxyStart < viewportStart && scrollable.position.pixels > scrollable.position.minScrollExtent) { - overDrag = math.max(viewportStart - proxyStart, overDragMax); - newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag); - } else if (proxyEnd > viewportEnd && scrollable.position.pixels < scrollable.position.maxScrollExtent) { - overDrag = math.max(proxyEnd - viewportEnd, overDragMax); - newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag); - } - } - - if (newOffset == null || (newOffset - scrollable.position.pixels).abs() < 1.0) { - // Drag should not trigger scroll. - _scrolling = false; - return; - } - final Duration duration = Duration(milliseconds: (1000 / _kDefaultAutoScrollVelocityScalar).round()); - await scrollable.position.animateTo( - newOffset, - duration: duration, - curve: Curves.linear, - ); - if (onScrollViewScrolled != null) - onScrollViewScrolled!(); - if (_scrolling) - await _scroll(); - } -} - -Offset _getDeltaToScrollOrigin(ScrollableState scrollableState) { - switch (scrollableState.axisDirection) { - case AxisDirection.down: - return Offset(0, scrollableState.position.pixels); - case AxisDirection.up: - return Offset(0, -scrollableState.position.pixels); - case AxisDirection.left: - return Offset(-scrollableState.position.pixels, 0); - case AxisDirection.right: - return Offset(scrollableState.position.pixels, 0); - } -} - class _ReorderableItem extends StatefulWidget { const _ReorderableItem({ required Key key, diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index b309abb26f4e..f0b7b50fd19b 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -6,8 +6,10 @@ import 'dart:async'; import 'dart:math' as math; import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'actions.dart'; @@ -20,12 +22,15 @@ import 'notification_listener.dart'; import 'primary_scroll_controller.dart'; import 'restoration.dart'; import 'restoration_properties.dart'; +import 'scroll_activity.dart'; import 'scroll_configuration.dart'; import 'scroll_context.dart'; import 'scroll_controller.dart'; import 'scroll_metrics.dart'; import 'scroll_physics.dart'; import 'scroll_position.dart'; +import 'selectable_region.dart'; +import 'selection_container.dart'; import 'ticker_provider.dart'; import 'viewport.dart'; @@ -784,11 +789,24 @@ class ScrollableState extends State with TickerProviderStateMixin, R controller: _effectiveScrollController, ); - return _configuration.buildScrollbar( + result = _configuration.buildScrollbar( context, _configuration.buildOverscrollIndicator(context, result, details), details, ); + + // Selection is only enabled when there is a parent registrar. + final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); + if (registrar != null) { + result = _ScrollableSelectionHandler( + state: this, + position: position, + registrar: registrar, + child: result + ); + } + + return result; } @override @@ -802,6 +820,471 @@ class ScrollableState extends State with TickerProviderStateMixin, R String? get restorationId => widget.restorationId; } +/// A widget to handle selection for a scrollable. +/// +/// This widget registers itself to the [registrar] and uses +/// [SelectionContainer] to collect selectables from its subtree. +class _ScrollableSelectionHandler extends StatefulWidget { + const _ScrollableSelectionHandler({ + required this.state, + required this.position, + required this.registrar, + required this.child, + }); + + final ScrollableState state; + final ScrollPosition position; + final Widget child; + final SelectionRegistrar registrar; + + @override + _ScrollableSelectionHandlerState createState() => _ScrollableSelectionHandlerState(); +} + +class _ScrollableSelectionHandlerState extends State<_ScrollableSelectionHandler> { + late _ScrollableSelectionContainerDelegate _selectionDelegate; + + @override + void initState() { + super.initState(); + _selectionDelegate = _ScrollableSelectionContainerDelegate( + state: widget.state, + position: widget.position, + ); + } + + @override + void didUpdateWidget(_ScrollableSelectionHandler oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.position != widget.position) { + _selectionDelegate.position = widget.position; + } + } + + @override + void dispose() { + _selectionDelegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionContainer( + registrar: widget.registrar, + delegate: _selectionDelegate, + child: widget.child, + ); + } +} + +/// An auto scroller that scrolls the [scrollable] if a drag gesture drags close +/// to its edge. +/// +/// The scroll velocity is controlled by the [velocityScalar]: +/// +/// velocity = * [velocityScalar]. +class EdgeDraggingAutoScroller { + /// Creates a auto scroller that scrolls the [scrollable]. + EdgeDraggingAutoScroller(this.scrollable, {this.onScrollViewScrolled, this.velocityScalar = _kDefaultAutoScrollVelocityScalar}); + + // An eyeballed value for a smooth scrolling experience. + static const double _kDefaultAutoScrollVelocityScalar = 7; + + /// The [Scrollable] this auto scroller is scrolling. + final ScrollableState scrollable; + + /// Called when a scroll view is scrolled. + /// + /// The scroll view may be scrolled multiple times in a row until the drag + /// target no longer triggers the auto scroll. This callback will be called + /// in between each scroll. + final VoidCallback? onScrollViewScrolled; + + /// The velocity scalar per pixel over scroll. + /// + /// It represents how the velocity scale with the over scroll distance. The + /// auto-scroll velocity = * velocityScalar. + final double velocityScalar; + + late Rect _dragTargetRelatedToScrollOrigin; + + /// Whether the auto scroll is in progress. + bool get scrolling => _scrolling; + bool _scrolling = false; + + double _offsetExtent(Offset offset, Axis scrollDirection) { + switch (scrollDirection) { + case Axis.horizontal: + return offset.dx; + case Axis.vertical: + return offset.dy; + } + } + + double _sizeExtent(Size size, Axis scrollDirection) { + switch (scrollDirection) { + case Axis.horizontal: + return size.width; + case Axis.vertical: + return size.height; + } + } + + AxisDirection get _axisDirection => scrollable.axisDirection; + Axis get _scrollDirection => axisDirectionToAxis(_axisDirection); + + /// Starts the auto scroll if the [dragTarget] is close to the edge. + /// + /// The scroll starts to scroll the [scrollable] if the target rect is close + /// to the edge of the [scrollable]; otherwise, it remains stationary. + /// + /// If the scrollable is already scrolling, calling this method updates the + /// previous dragTarget to the new value and continues scrolling if necessary. + void startAutoScrollIfNecessary(Rect dragTarget) { + final Offset deltaToOrigin = _getDeltaToScrollOrigin(scrollable); + _dragTargetRelatedToScrollOrigin = dragTarget.translate(deltaToOrigin.dx, deltaToOrigin.dy); + if (_scrolling) { + // The change will be picked up in the next scroll. + return; + } + if (!_scrolling) + _scroll(); + } + + /// Stop any ongoing auto scrolling. + void stopAutoScroll() { + _scrolling = false; + } + + Future _scroll() async { + final RenderBox scrollRenderBox = scrollable.context.findRenderObject()! as RenderBox; + final Rect globalRect = MatrixUtils.transformRect( + scrollRenderBox.getTransformTo(null), + Rect.fromLTWH(0, 0, scrollRenderBox.size.width, scrollRenderBox.size.height) + ); + _scrolling = true; + double? newOffset; + const double overDragMax = 20.0; + + final Offset deltaToOrigin = _getDeltaToScrollOrigin(scrollable); + final Offset viewportOrigin = globalRect.topLeft.translate(deltaToOrigin.dx, deltaToOrigin.dy); + final double viewportStart = _offsetExtent(viewportOrigin, _scrollDirection); + final double viewportEnd = viewportStart + _sizeExtent(globalRect.size, _scrollDirection); + + final double proxyStart = _offsetExtent(_dragTargetRelatedToScrollOrigin.topLeft, _scrollDirection); + final double proxyEnd = _offsetExtent(_dragTargetRelatedToScrollOrigin.bottomRight, _scrollDirection); + late double overDrag; + if (_axisDirection == AxisDirection.up || _axisDirection == AxisDirection.left) { + if (proxyEnd > viewportEnd && scrollable.position.pixels > scrollable.position.minScrollExtent) { + overDrag = math.max(proxyEnd - viewportEnd, overDragMax); + newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag); + } else if (proxyStart < viewportStart && scrollable.position.pixels < scrollable.position.maxScrollExtent) { + overDrag = math.max(viewportStart - proxyStart, overDragMax); + newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag); + } + } else { + if (proxyStart < viewportStart && scrollable.position.pixels > scrollable.position.minScrollExtent) { + overDrag = math.max(viewportStart - proxyStart, overDragMax); + newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag); + } else if (proxyEnd > viewportEnd && scrollable.position.pixels < scrollable.position.maxScrollExtent) { + overDrag = math.max(proxyEnd - viewportEnd, overDragMax); + newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag); + } + } + + if (newOffset == null || (newOffset - scrollable.position.pixels).abs() < 1.0) { + // Drag should not trigger scroll. + _scrolling = false; + return; + } + final Duration duration = Duration(milliseconds: (1000 / velocityScalar).round()); + await scrollable.position.animateTo( + newOffset, + duration: duration, + curve: Curves.linear, + ); + if (onScrollViewScrolled != null) + onScrollViewScrolled!(); + if (_scrolling) + await _scroll(); + } +} + +/// This updater handles the case where the selectables change frequently, and +/// it optimizes toward scrolling updates. +/// +/// It keeps track of the drag start offset relative to scroll origin for every +/// selectable. The records are used to determine whether the selection is up to +/// date with the scroll position when it sends the drag update event to a +/// selectable. +class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionContainerDelegate { + _ScrollableSelectionContainerDelegate({ + required this.state, + required ScrollPosition position + }) : _position = position, + _autoScroller = EdgeDraggingAutoScroller(state, velocityScalar: _kDefaultSelectToScrollVelocityScalar) { + _position.addListener(_scheduleLayoutChange); + } + + static const double _kDefaultDragTargetSize = 200; + static const double _kDefaultSelectToScrollVelocityScalar = 30; + + final ScrollableState state; + final EdgeDraggingAutoScroller _autoScroller; + bool _scheduledLayoutChange = false; + Offset? _currentDragStartRelatedToOrigin; + Offset? _currentDragEndRelatedToOrigin; + + // The scrollable only auto scrolls if the selection starts in the scrollable. + bool _selectionStartsInScrollable = false; + + ScrollPosition get position => _position; + ScrollPosition _position; + set position(ScrollPosition other) { + if (other == _position) + return; + _position.removeListener(_scheduleLayoutChange); + _position = other; + _position.addListener(_scheduleLayoutChange); + } + + // The layout will only be updated a frame later than position changes. + // Schedule PostFrameCallback to capture the accurate layout. + void _scheduleLayoutChange() { + if (_scheduledLayoutChange) + return; + _scheduledLayoutChange = true; + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!_scheduledLayoutChange) + return; + _scheduledLayoutChange = false; + layoutDidChange(); + }); + } + + /// Stores the scroll offset when a scrollable receives the last + /// [SelectionEdgeUpdateEvent]. + /// + /// The stored scroll offset may be null if a scrollable never receives a + /// [SelectionEdgeUpdateEvent]. + /// + /// When a new [SelectionEdgeUpdateEvent] is dispatched to a selectable, this + /// updater checks the current scroll offset against the one stored in these + /// records. If the scroll offset is different, it synthesizes an opposite + /// [SelectionEdgeUpdateEvent] and dispatches the event before dispatching the + /// new event. + /// + /// For example, if a selectable receives an end [SelectionEdgeUpdateEvent] + /// and its scroll offset in the records is different from the current value, + /// it synthesizes a start [SelectionEdgeUpdateEvent] and dispatches it before + /// dispatching the original end [SelectionEdgeUpdateEvent]. + final Map _selectableStartEdgeUpdateRecords = {}; + final Map _selectableEndEdgeUpdateRecords = {}; + + @override + void didChangeSelectables() { + final Set selectableSet = selectables.toSet(); + _selectableStartEdgeUpdateRecords.removeWhere((Selectable key, double value) => !selectableSet.contains(key)); + _selectableEndEdgeUpdateRecords.removeWhere((Selectable key, double value) => !selectableSet.contains(key)); + super.didChangeSelectables(); + } + + @override + SelectionResult handleClearSelection(ClearSelectionEvent event) { + _selectableStartEdgeUpdateRecords.clear(); + _selectableEndEdgeUpdateRecords.clear(); + _currentDragStartRelatedToOrigin = null; + _currentDragEndRelatedToOrigin = null; + _selectionStartsInScrollable = false; + return super.handleClearSelection(event); + } + + @override + SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + if (_currentDragEndRelatedToOrigin == null && _currentDragStartRelatedToOrigin == null) { + assert(!_selectionStartsInScrollable); + _selectionStartsInScrollable = _globalPositionInScrollable(event.globalPosition); + } + final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); + if (event.type == SelectionEventType.endEdgeUpdate) { + _currentDragEndRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition); + final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); + event = SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset); + } else { + _currentDragStartRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition); + final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); + event = SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset); + } + final SelectionResult result = super.handleSelectionEdgeUpdate(event); + + // Result may be pending if one of the selectable child is also a scrollable. + // In that case, the parent scrollable needs to wait for the child to finish + // scrolling. + if (result == SelectionResult.pending) { + _autoScroller.stopAutoScroll(); + return result; + } + if (_selectionStartsInScrollable) { + _autoScroller.startAutoScrollIfNecessary(_dragTargetFromEvent(event)); + if (_autoScroller.scrolling) { + return SelectionResult.pending; + } + } + return result; + } + + Offset _inferPositionRelatedToOrigin(Offset globalPosition) { + final RenderBox box = state.context.findRenderObject()! as RenderBox; + final Offset localPosition = box.globalToLocal(globalPosition); + if (!_selectionStartsInScrollable) { + // If the selection starts outside of the scrollable, selecting across the + // scrollable boundary will act as selecting the entire content in the + // scrollable. This logic move the offset to the 0.0 or infinity to cover + // the entire content if the input position is outside of the scrollable. + if (localPosition.dy < 0 || localPosition.dx < 0) { + return box.localToGlobal(Offset.zero); + } + if (localPosition.dy > box.size.height || localPosition.dx > box.size.width) { + return Offset.infinite; + } + } + final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); + return box.localToGlobal(localPosition.translate(deltaToOrigin.dx, deltaToOrigin.dy)); + } + + /// Infers the [_currentDragStartRelatedToOrigin] and + /// [_currentDragEndRelatedToOrigin] from the geometry. + /// + /// This method is called after a select word and select all event where the + /// selection is triggered by none drag events. The + /// [_currentDragStartRelatedToOrigin] and [_currentDragEndRelatedToOrigin] + /// are essential to handle future [SelectionEdgeUpdateEvent]s. + void _updateDragLocationsFromGeometries() { + final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); + final RenderBox box = state.context.findRenderObject()! as RenderBox; + final Matrix4 transform = box.getTransformTo(null); + if (currentSelectionStartIndex != -1) { + final SelectionGeometry geometry = selectables[currentSelectionStartIndex].value; + assert(geometry.hasSelection); + final SelectionPoint start = geometry.startSelectionPoint!; + final Matrix4 childTransform = selectables[currentSelectionStartIndex].getTransformTo(box); + final Offset localDragStart = MatrixUtils.transformPoint( + childTransform, + start.localPosition + Offset(0, - start.lineHeight / 2), + ); + _currentDragStartRelatedToOrigin = MatrixUtils.transformPoint(transform, localDragStart + deltaToOrigin); + } + if (currentSelectionEndIndex != -1) { + final SelectionGeometry geometry = selectables[currentSelectionEndIndex].value; + assert(geometry.hasSelection); + final SelectionPoint end = geometry.endSelectionPoint!; + final Matrix4 childTransform = selectables[currentSelectionEndIndex].getTransformTo(box); + final Offset localDragEnd = MatrixUtils.transformPoint( + childTransform, + end.localPosition + Offset(0, - end.lineHeight / 2), + ); + _currentDragEndRelatedToOrigin = MatrixUtils.transformPoint(transform, localDragEnd + deltaToOrigin); + } + } + + @override + SelectionResult handleSelectAll(SelectAllSelectionEvent event) { + assert(!_selectionStartsInScrollable); + final SelectionResult result = super.handleSelectAll(event); + assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1)); + if (currentSelectionStartIndex != -1) { + _updateDragLocationsFromGeometries(); + } + return result; + } + + @override + SelectionResult handleSelectWord(SelectWordSelectionEvent event) { + _selectionStartsInScrollable = _globalPositionInScrollable(event.globalPosition); + final SelectionResult result = super.handleSelectWord(event); + _updateDragLocationsFromGeometries(); + return result; + } + + bool _globalPositionInScrollable(Offset globalPosition) { + final RenderBox box = state.context.findRenderObject()! as RenderBox; + final Offset localPosition = box.globalToLocal(globalPosition); + final Rect rect = Rect.fromLTWH(0, 0, box.size.width, box.size.height); + return rect.contains(localPosition); + } + + Rect _dragTargetFromEvent(SelectionEdgeUpdateEvent event) { + return Rect.fromCenter(center: event.globalPosition, width: _kDefaultDragTargetSize, height: _kDefaultDragTargetSize); + } + + @override + SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) { + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + _selectableStartEdgeUpdateRecords[selectable] = state.position.pixels; + ensureChildUpdated(selectable); + break; + case SelectionEventType.endEdgeUpdate: + _selectableEndEdgeUpdateRecords[selectable] = state.position.pixels; + ensureChildUpdated(selectable); + break; + case SelectionEventType.clear: + _selectableEndEdgeUpdateRecords.remove(selectable); + _selectableStartEdgeUpdateRecords.remove(selectable); + break; + case SelectionEventType.selectAll: + case SelectionEventType.selectWord: + _selectableEndEdgeUpdateRecords[selectable] = state.position.pixels; + _selectableStartEdgeUpdateRecords[selectable] = state.position.pixels; + break; + } + return super.dispatchSelectionEventToChild(selectable, event); + } + + @override + void ensureChildUpdated(Selectable selectable) { + final double newRecord = state.position.pixels; + final double? previousStartRecord = _selectableStartEdgeUpdateRecords[selectable]; + if (_currentDragStartRelatedToOrigin != null && + (previousStartRecord == null || (newRecord - previousStartRecord).abs() > precisionErrorTolerance)) { + // Make sure the selectable has up to date events. + final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); + final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); + selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset)); + } + final double? previousEndRecord = _selectableEndEdgeUpdateRecords[selectable]; + if (_currentDragEndRelatedToOrigin != null && + (previousEndRecord == null || (newRecord - previousEndRecord).abs() > precisionErrorTolerance)) { + // Make sure the selectable has up to date events. + final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); + final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); + selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset)); + } + } + + @override + void dispose() { + _selectableStartEdgeUpdateRecords.clear(); + _selectableEndEdgeUpdateRecords.clear(); + _scheduledLayoutChange = false; + _autoScroller.stopAutoScroll(); + super.dispose(); + } +} + +Offset _getDeltaToScrollOrigin(ScrollableState scrollableState) { + switch (scrollableState.axisDirection) { + case AxisDirection.down: + return Offset(0, scrollableState.position.pixels); + case AxisDirection.up: + return Offset(0, -scrollableState.position.pixels); + case AxisDirection.left: + return Offset(-scrollableState.position.pixels, 0); + case AxisDirection.right: + return Offset(scrollableState.position.pixels, 0); + } +} + /// Describes the aspects of a Scrollable widget to inform inherited widgets /// like [ScrollBehavior] for decorating. /// diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart new file mode 100644 index 000000000000..2737a6399f31 --- /dev/null +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -0,0 +1,1606 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +import 'actions.dart'; +import 'basic.dart'; +import 'focus_manager.dart'; +import 'focus_scope.dart'; +import 'framework.dart'; +import 'gesture_detector.dart'; +import 'overlay.dart'; +import 'selection_container.dart'; +import 'text_editing_intents.dart'; +import 'text_selection.dart'; + +const Set _kLongPressSelectionDevices = { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, +}; + +/// A widget that introduces an area for user selections. +/// +/// Flutter widgets are not selectable by default. To enable selection for +/// a Flutter application, consider wrapping a portion of widget subtree with +/// [SelectableRegion]. The wrapped subtree can be selected by users using mouse +/// or touch gestures, e.g. users can select widgets by holding the mouse +/// left-click and dragging across widgets, or they can use long press gestures +/// to select words on touch devices. +/// +/// ## An overview of the selection system. +/// +/// Every [Selectable] under the [SelectableRegion] can be selected. They form a +/// selection tree structure to handle the selection. +/// +/// The [SelectableRegion] is a wrapper over [SelectionContainer]. It listens to +/// user gestures and sends corresponding [SelectionEvent]s to the +/// [SelectionContainer] it creates. +/// +/// A [SelectionContainer] is a single [Selectable] that handles +/// [SelectionEvent]s on behalf of child [Selectable]s in the subtree. It +/// creates a [SelectionRegistrarScope] with its [SelectionContainer.delegate] +/// to collect child [Selectable]s and sends the [SelectionEvent]s it receives +/// from the parent [SelectionRegistrar] to the appropriate child [Selectable]s. +/// It creates an abstraction for the parent [SelectionRegistrar] as if it is +/// interacting with a single [Selectable]. +/// +/// The [SelectionContainer] created by [SelectableRegion] is the root node of a +/// selection tree. Each non-leaf node in the tree is a [SelectionContainer], +/// and the leaf node is a leaf widget whose render object implements +/// [Selectable]. They are connected through [SelectionRegistrarScope]s created +/// by [SelectionContainer]s. +/// +/// Both [SelectionContainer]s and the leaf [Selectable]s need to register +/// themselves to the [SelectionRegistrar] from the +/// [SelectionContainer.maybeOf] if they want to participate in the +/// selection. +/// +/// An example selection tree will look like: +/// +/// {@tool snippet} +/// +/// ```dart +/// MaterialApp( +/// home: SelectableRegion( +/// selectionControls: materialTextSelectionControls, +/// focusNode: FocusNode(), +/// child: Scaffold( +/// appBar: AppBar(title: const Text('Flutter Code Sample')), +/// body: ListView( +/// children: const [ +/// Text('Item 0', style: TextStyle(fontSize: 50.0)), +/// Text('Item 1', style: TextStyle(fontSize: 50.0)), +/// ], +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ``` +/// +/// SelectionContainer +/// (SelectableRegion) +/// / \ +/// / \ +/// / \ +/// Selectable \ +/// ("Flutter Code Sample") \ +/// \ +/// SelectionContainer +/// (ListView) +/// / \ +/// / \ +/// / \ +/// Selectable Selectable +/// ("Item 0") ("Item 1") +/// +///``` +/// +/// ## Making a widget selectable +/// +/// Some leaf widgets, such as [Text], have all of the selection logic wired up +/// automatically and can be selected as long as they are under a +/// [SelectableRegion]. +/// +/// To make a custom selectable widget, its render object needs to mix in +/// [Selectable] and implement the required APIs to handle [SelectionEvent]s +/// as well as paint appropriate selection highlights. +/// +/// The render object also needs to register itself to a [SelectionRegistrar]. +/// For the most cases, one can use [SelectionRegistrant] to auto-register +/// itself with the register returned from [SelectionContainer.maybeOf] as +/// seen in the example below. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create an adapter widget that makes any +/// child widget selectable. +/// +/// ** See code in examples/api/lib/material/selection_area/custom_selectable.dart ** +/// {@end-tool} +/// +/// ## Complex layout +/// +/// By default, the screen order is used as the selection order. If a group of +/// [Selectable]s needs to select differently, consider wrapping them with a +/// [SelectionContainer] to customize its selection behavior. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a [SelectionContainer] that only +/// allows selecting everything or nothing with no partial selection. +/// +/// ** See code in examples/api/lib/material/selection_area/custom_container.dart ** +/// {@end-tool} +/// +/// In the case where a group of widgets should be excluded from selection under +/// a [SelectableRegion], consider wrapping that group of widgets using +/// [SelectionContainer.disabled]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to disable selection for a Text in a Column. +/// +/// ** See code in examples/api/lib/material/selection_area/disable_partial_selection.dart ** +/// {@end-tool} +/// +/// To create a separate selection system from its parent selection area, +/// wrap part of the subtree with another [SelectableRegion]. The selection of the +/// child selection area can not extend past its subtree, and the selection of +/// the parent selection area can not extend inside the child selection area. +/// +/// See also: +/// * [SelectionArea], which creates a [SelectableRegion] with +/// platform-adaptive selection controls. +/// * [SelectionHandler], which contains APIs to handle selection events from the +/// [SelectableRegion]. +/// * [Selectable], which provides API to participate in the selection system. +/// * [SelectionRegistrar], which [Selectable] needs to subscribe to receive +/// selection events. +/// * [SelectionContainer], which collects selectable widgets in the subtree +/// and provides api to dispatch selection event to the collected widget. +class SelectableRegion extends StatefulWidget { + /// Create a new [SelectableRegion] widget. + /// + /// The [selectionControls] are used for building the selection handles and + /// toolbar for mobile devices. + const SelectableRegion({ + super.key, + required this.focusNode, + required this.selectionControls, + required this.child, + }); + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode focusNode; + + /// The child widget this selection area applies to. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// The delegate to build the selection handles and toolbar for mobile + /// devices. + final TextSelectionControls selectionControls; + + @override + State createState() => _SelectableRegionState(); +} + +class _SelectableRegionState extends State with TextSelectionDelegate implements SelectionRegistrar { + late final Map> _actions = >{ + SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), + CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), + }; + final Map _gestureRecognizers = {}; + SelectionOverlay? _selectionOverlay; + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); + final LayerLink _toolbarLayerLink = LayerLink(); + final _SelectableRegionContainerDelegate _selectionDelegate = _SelectableRegionContainerDelegate(); + // there should only ever be one selectable, which is the SelectionContainer. + Selectable? _selectable; + + bool get _hasSelectionOverlayGeometry => _selectionDelegate.value.startSelectionPoint != null + || _selectionDelegate.value.endSelectionPoint != null; + + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_handleFocusChanged); + _initMouseGestureRecognizer(); + _initTouchGestureRecognizer(); + // Taps and right clicks. + _gestureRecognizers[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance.onTap = _clearSelection; + instance.onSecondaryTapDown = _handleRightClickDown; + }, + ); + } + + @override + void didUpdateWidget(SelectableRegion oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocusChanged); + widget.focusNode.addListener(_handleFocusChanged); + if (widget.focusNode.hasFocus != oldWidget.focusNode.hasFocus) + _handleFocusChanged(); + } + } + + Action _makeOverridable(Action defaultAction) { + return Action.overridable(context: context, defaultAction: defaultAction); + } + + void _handleFocusChanged() { + if (!widget.focusNode.hasFocus) { + _clearSelection(); + } + } + + void _updateSelectionStatus() { + final TextSelection selection; + final SelectionGeometry geometry = _selectionDelegate.value; + switch(geometry.status) { + case SelectionStatus.uncollapsed: + case SelectionStatus.collapsed: + selection = const TextSelection(baseOffset: 0, extentOffset: 1); + break; + case SelectionStatus.none: + selection = const TextSelection.collapsed(offset: 1); + break; + } + textEditingValue = TextEditingValue(text: '__', selection: selection); + if (_hasSelectionOverlayGeometry) { + _updateSelectionOverlay(); + } else { + _selectionOverlay?.dispose(); + _selectionOverlay = null; + } + } + + // gestures. + + void _initMouseGestureRecognizer() { + _gestureRecognizers[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(debugOwner:this, supportedDevices: { PointerDeviceKind.mouse }), + (PanGestureRecognizer instance) { + instance + ..onDown = _startNewMouseSelectionGesture + ..onStart = _handleMouseDragStart + ..onUpdate = _handleMouseDragUpdate + ..onEnd = _handleMouseDragEnd + ..onCancel = _clearSelection + ..dragStartBehavior = DragStartBehavior.down; + }, + ); + } + + void _initTouchGestureRecognizer() { + _gestureRecognizers[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(debugOwner: this, supportedDevices: _kLongPressSelectionDevices), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleTouchLongPressStart + ..onLongPressMoveUpdate = _handleTouchLongPressMoveUpdate + ..onLongPressEnd = _handleTouchLongPressEnd + ..onLongPressCancel = _clearSelection; + }, + ); + } + + void _startNewMouseSelectionGesture(DragDownDetails details) { + widget.focusNode.requestFocus(); + hideToolbar(); + _clearSelection(); + } + + void _handleMouseDragStart(DragStartDetails details) { + _selectStartTo(offset: details.globalPosition); + } + + void _handleMouseDragUpdate(DragUpdateDetails details) { + _selectEndTo(offset: details.globalPosition, continuous: true); + } + + void _handleMouseDragEnd(DragEndDetails details) { + _finalizeSelection(); + } + + void _handleTouchLongPressStart(LongPressStartDetails details) { + widget.focusNode.requestFocus(); + _selectWordAt(offset: details.globalPosition); + _showToolbar(); + _showHandles(); + } + + void _handleTouchLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + _selectEndTo(offset: details.globalPosition); + } + + void _handleTouchLongPressEnd(LongPressEndDetails details) { + _finalizeSelection(); + } + + void _handleRightClickDown(TapDownDetails details) { + widget.focusNode.requestFocus(); + _selectWordAt(offset: details.globalPosition); + _showHandles(); + _showToolbar(location: details.globalPosition); + } + + // Selection update helper methods. + + Offset? _selectionEndPosition; + bool get _userDraggingSelectionEnd => _selectionEndPosition != null; + bool _scheduledSelectionEndEdgeUpdate = false; + + /// Sends end [SelectionEdgeUpdateEvent] to the selectable subtree. + /// + /// If the selectable subtree returns a [SelectionResult.pending], this method + /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result + /// is not pending or users end their gestures. + void _triggerSelectionEndEdgeUpdate() { + // This method can be called when the drag is not in progress. This can + // happen if the the child scrollable returns SelectionResult.pending, and + // the selection area scheduled a selection update for the next frame, but + // the drag is lifted before the scheduled selection update is run. + if (_scheduledSelectionEndEdgeUpdate || !_userDraggingSelectionEnd) + return; + if (_selectable?.dispatchSelectionEvent( + SelectionEdgeUpdateEvent.forEnd(globalPosition: _selectionEndPosition!)) == SelectionResult.pending) { + _scheduledSelectionEndEdgeUpdate = true; + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!_scheduledSelectionEndEdgeUpdate) + return; + _scheduledSelectionEndEdgeUpdate = false; + _triggerSelectionEndEdgeUpdate(); + }); + return; + } + } + + void _stopSelectionEndEdgeUpdate() { + _scheduledSelectionEndEdgeUpdate = false; + _selectionEndPosition = null; + } + + Offset? _selectionStartPosition; + bool get _userDraggingSelectionStart => _selectionStartPosition != null; + bool _scheduledSelectionStartEdgeUpdate = false; + + /// Sends start [SelectionEdgeUpdateEvent] to the selectable subtree. + /// + /// If the selectable subtree returns a [SelectionResult.pending], this method + /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result + /// is not pending or users end their gestures. + void _triggerSelectionStartEdgeUpdate() { + // This method can be called when the drag is not in progress. This can + // happen if the the child scrollable returns SelectionResult.pending, and + // the selection area scheduled a selection update for the next frame, but + // the drag is lifted before the scheduled selection update is run. + if (_scheduledSelectionStartEdgeUpdate || !_userDraggingSelectionStart) + return; + if (_selectable?.dispatchSelectionEvent( + SelectionEdgeUpdateEvent.forStart(globalPosition: _selectionStartPosition!)) == SelectionResult.pending) { + _scheduledSelectionStartEdgeUpdate = true; + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!_scheduledSelectionStartEdgeUpdate) + return; + _scheduledSelectionStartEdgeUpdate = false; + _triggerSelectionStartEdgeUpdate(); + }); + return; + } + } + + void _stopSelectionStartEdgeUpdate() { + _scheduledSelectionStartEdgeUpdate = false; + _selectionEndPosition = null; + } + + // SelectionOverlay helper methods. + + late Offset _selectionStartHandleDragPosition; + late Offset _selectionEndHandleDragPosition; + + void _handleSelectionStartHandleDragStart(DragStartDetails details) { + assert(_selectionDelegate.value.startSelectionPoint != null); + _selectionStartHandleDragPosition = _selectionDelegate.value.startSelectionPoint!.localPosition; + } + + void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { + _selectionStartHandleDragPosition = _selectionStartHandleDragPosition + details.delta; + // The value corresponds to the paint origin of the selection handle. + // Offset it to the center of the line to make it feel more natural. + _selectionStartPosition = _selectionStartHandleDragPosition - Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2); + _triggerSelectionStartEdgeUpdate(); + } + + void _handleSelectionEndHandleDragStart(DragStartDetails details) { + assert(_selectionDelegate.value.endSelectionPoint != null); + _selectionEndHandleDragPosition = _selectionDelegate.value.endSelectionPoint!.localPosition; + } + + void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { + _selectionEndHandleDragPosition = _selectionEndHandleDragPosition + details.delta; + // The value corresponds to the paint origin of the selection handle. + // Offset it to the center of the line to make it feel more natural. + _selectionEndPosition = _selectionEndHandleDragPosition - Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2); + _triggerSelectionEndEdgeUpdate(); + } + + void _createSelectionOverlay() { + assert(_hasSelectionOverlayGeometry); + if (_selectionOverlay != null) + return; + final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; + final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; + late List points; + final Offset startLocalPosition = start?.localPosition ?? end!.localPosition; + final Offset endLocalPosition = end?.localPosition ?? start!.localPosition; + if (startLocalPosition.dy > endLocalPosition.dy) { + points = [ + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + ]; + } else { + points = [ + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + ]; + } + _selectionOverlay = SelectionOverlay( + context: context, + debugRequiredFor: widget, + startHandleType: start?.handleType ?? TextSelectionHandleType.left, + lineHeightAtStart: start?.lineHeight ?? end!.lineHeight, + onStartHandleDragStart: _handleSelectionStartHandleDragStart, + onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, + onStartHandleDragEnd: (DragEndDetails details) => _stopSelectionStartEdgeUpdate(), + endHandleType: end?.handleType ?? TextSelectionHandleType.right, + lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight, + onEndHandleDragStart: _handleSelectionEndHandleDragStart, + onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, + onEndHandleDragEnd: (DragEndDetails details) => _stopSelectionEndEdgeUpdate(), + selectionEndPoints: points, + selectionControls: widget.selectionControls, + selectionDelegate: this, + clipboardStatus: null, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + toolbarLayerLink: _toolbarLayerLink, + ); + } + + void _updateSelectionOverlay() { + if (_selectionOverlay == null) + return; + assert(_hasSelectionOverlayGeometry); + final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; + final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; + late List points; + final Offset startLocalPosition = start?.localPosition ?? end!.localPosition; + final Offset endLocalPosition = end?.localPosition ?? start!.localPosition; + if (startLocalPosition.dy > endLocalPosition.dy) { + points = [ + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + ]; + } else { + points = [ + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + ]; + } + _selectionOverlay! + ..startHandleType = start?.handleType ?? TextSelectionHandleType.left + ..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight + ..endHandleType = end?.handleType ?? TextSelectionHandleType.right + ..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight + ..selectionEndPoints = points; + } + + /// Shows the selection handles. + /// + /// Returns true if the handles are shown, false if the handles can't be + /// shown. + bool _showHandles() { + if (_selectionOverlay != null) { + _selectionOverlay!.showHandles(); + return true; + } + + if (!_hasSelectionOverlayGeometry) + return false; + + _createSelectionOverlay(); + _selectionOverlay!.showHandles(); + return true; + } + + /// Shows the text selection toolbar. + /// + /// If the parameter `location` is set, the toolbar will be shown at the + /// location. Otherwise, the toolbar location will be calculated based on the + /// handles' locations. The `location` is in the coordinates system of the + /// [Overlay]. + /// + /// Returns true if the toolbar is shown, false if the toolbar can't be shown. + bool _showToolbar({Offset? location}) { + if (!_hasSelectionOverlayGeometry && _selectionOverlay == null) + return false; + + // Web is using native dom elements to enable clipboard functionality of the + // toolbar: copy, paste, select, cut. It might also provide additional + // functionality depending on the browser (such as translate). Due to this + // we should not show a Flutter toolbar for the editable text elements. + if (kIsWeb) + return false; + + if (_selectionOverlay == null) + _createSelectionOverlay(); + + _selectionOverlay!.toolbarLocation = location; + _selectionOverlay!.showToolbar(); + return true; + } + + /// Sets or updates selection end edge to the `offset` location. + /// + /// A selection always contains a select start edge and selection end edge. + /// They can be created by calling both [_selectStartTo] and [_selectEndTo], or + /// use other selection APIs, such as [_selectWordAt] or [selectAll]. + /// + /// This method sets or updates the selection end edge by sending + /// [SelectionEdgeUpdateEvent]s to the child [Selectable]s. + /// + /// If `continuous` is set to true and the update causes scrolling, the + /// method will continue sending the same [SelectionEdgeUpdateEvent]s to the + /// child [Selectable]s every frame until the scrolling finishes or a + /// [_finalizeSelection] is called. + /// + /// The `continuous` argument defaults to false. + /// + /// The `offset` is in global coordinates. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [_clearSelection], which clear the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [selectAll], which selects the entire content. + void _selectEndTo({required Offset offset, bool continuous = false}) { + if (!continuous) { + _selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: offset)); + return; + } + if (_selectionEndPosition != offset) { + _selectionEndPosition = offset; + _triggerSelectionEndEdgeUpdate(); + } + } + + /// Sets or updates selection start edge to the `offset` location. + /// + /// A selection always contains a select start edge and selection end edge. + /// They can be created by calling both [_selectStartTo] and [_selectEndTo], or + /// use other selection APIs, such as [_selectWordAt] or [selectAll]. + /// + /// This method sets or updates the selection start edge by sending + /// [SelectionEdgeUpdateEvent]s to the child [Selectable]s. + /// + /// If `continuous` is set to true and the update causes scrolling, the + /// method will continue sending the same [SelectionEdgeUpdateEvent]s to the + /// child [Selectable]s every frame until the scrolling finishes or a + /// [_finalizeSelection] is called. + /// + /// The `continuous` argument defaults to false. + /// + /// The `offset` is in global coordinates. + /// + /// See also: + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [_clearSelection], which clear the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [selectAll], which selects the entire content. + void _selectStartTo({required Offset offset, bool continuous = false}) { + if (!continuous) { + _selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: offset)); + return; + } + if (_selectionStartPosition != offset) { + _selectionStartPosition = offset; + _triggerSelectionStartEdgeUpdate(); + } + } + + /// Selects a whole word at the `offset` location. + /// + /// If the whole word is already in the current selection, selection won't + /// change. One call [_clearSelection] first if the selection needs to be + /// updated even if the word is already covered by the current selection. + /// + /// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection + /// edges after calling this method. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [_clearSelection], which clear the ongoing selection. + /// * [selectAll], which selects the entire content. + void _selectWordAt({required Offset offset}) { + // There may be other selection ongoing. + _finalizeSelection(); + _selectable?.dispatchSelectionEvent(SelectWordSelectionEvent(globalPosition: offset)); + } + + /// Stops any ongoing selection updates. + /// + /// This method is different from [_clearSelection] that it does not remove + /// the current selection. It only stops the continuous updates. + /// + /// A continuous update can happen as result of calling [_selectStartTo] or + /// [_selectEndTo] with `continuous` sets to true which causes a [Selectable] + /// to scroll. Calling this method will stop the update as well as the + /// scrolling. + void _finalizeSelection() { + _stopSelectionEndEdgeUpdate(); + _stopSelectionStartEdgeUpdate(); + } + + /// Removes the ongoing selection. + void _clearSelection() { + _finalizeSelection(); + _selectable?.dispatchSelectionEvent(const ClearSelectionEvent()); + } + + Future _copy() async { + final SelectedContent? data = _selectable?.getSelectedContent(); + if (data == null) { + return; + } + await Clipboard.setData(ClipboardData(text: data.plainText)); + } + + // [TextSelectionDelegate] overrides. + + @override + bool get cutEnabled => false; + + @override + bool get pasteEnabled => false; + + @override + void hideToolbar([bool hideHandles = true]) { + _selectionOverlay?.hideToolbar(); + if (hideHandles) { + _selectionOverlay?.hideToolbar(); + } + } + + @override + void selectAll([SelectionChangedCause? cause]) { + _clearSelection(); + _selectable?.dispatchSelectionEvent(const SelectAllSelectionEvent()); + if (cause == SelectionChangedCause.toolbar) { + _showToolbar(); + _showHandles(); + } + } + + @override + void copySelection(SelectionChangedCause cause) { + _copy(); + _clearSelection(); + } + + // TODO(chunhtai): remove this workaround after decoupling text selection + // from text editing in TextSelectionDelegate. + @override + TextEditingValue textEditingValue = const TextEditingValue(text: '_'); + + @override + void bringIntoView(TextPosition position) {/* SelectableRegion must be in view at this point. */} + + @override + void cutSelection(SelectionChangedCause cause) { + assert(false); + } + + @override + void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) {/* SelectableRegion maintains its own state */} + + @override + Future pasteText(SelectionChangedCause cause) async { + assert(false); + } + + // [SelectionRegistrar] override. + + @override + void add(Selectable selectable) { + assert(_selectable == null); + _selectable = selectable; + _selectable!.addListener(_updateSelectionStatus); + _selectable!.pushHandleLayers(_startHandleLayerLink, _endHandleLayerLink); + } + + @override + void remove(Selectable selectable) { + assert(_selectable == selectable); + _selectable!.removeListener(_updateSelectionStatus); + _selectable!.pushHandleLayers(null, null); + _selectable = null; + } + + @override + void dispose() { + _selectable?.removeListener(_updateSelectionStatus); + _selectable?.pushHandleLayers(null, null); + _selectionDelegate.dispose(); + _selectionOverlay?.dispose(); + _selectionOverlay = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(Overlay.of(context, debugRequiredFor: widget) != null); + return CompositedTransformTarget( + link: _toolbarLayerLink, + child: RawGestureDetector( + gestures: _gestureRecognizers, + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + child: Actions( + actions: _actions, + child: Focus( + focusNode: widget.focusNode, + child: SelectionContainer( + registrar: this, + delegate: _selectionDelegate, + child: widget.child, + ), + ), + ), + ), + ); + } +} + +/// An action that does not override any [Action.overridable] in the subtree. +/// +/// If this action is invoked by an [Action.overridable], it will immediately +/// invoke the [Action.overridable] and do nothing else. Otherwise, it will call +/// [invokeAction]. +abstract class _NonOverrideAction extends ContextAction { + Object? invokeAction(T intent, [BuildContext? context]); + + @override + Object? invoke(T intent, [BuildContext? context]) { + if (callingAction != null) + return callingAction!.invoke(intent); + return invokeAction(intent, context); + } +} + +class _SelectAllAction extends _NonOverrideAction { + _SelectAllAction(this.state); + + final _SelectableRegionState state; + + @override + void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) { + state.selectAll(SelectionChangedCause.keyboard); + } +} + +class _CopySelectionAction extends _NonOverrideAction { + _CopySelectionAction(this.state); + + final _SelectableRegionState state; + + @override + void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) { + state._copy(); + } +} + +class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContainerDelegate { + final Set _hasReceivedStartEvent = {}; + final Set _hasReceivedEndEvent = {}; + + Offset? _lastStartEdgeUpdateGlobalPosition; + Offset? _lastEndEdgeUpdateGlobalPosition; + + @override + void remove(Selectable selectable) { + _hasReceivedStartEvent.remove(selectable); + _hasReceivedEndEvent.remove(selectable); + super.remove(selectable); + } + + void _updateLastEdgeEventsFromGeometries() { + if (currentSelectionStartIndex != -1) { + final Selectable start = selectables[currentSelectionStartIndex]; + final Offset localStartEdge = start.value.startSelectionPoint!.localPosition + + Offset(0, - start.value.startSelectionPoint!.lineHeight / 2); + _lastStartEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(start.getTransformTo(null), localStartEdge); + } + if (currentSelectionEndIndex != -1) { + final Selectable end = selectables[currentSelectionEndIndex]; + final Offset localEndEdge = end.value.endSelectionPoint!.localPosition + + Offset(0, -end.value.endSelectionPoint!.lineHeight / 2); + _lastEndEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(end.getTransformTo(null), localEndEdge); + } + } + + @override + SelectionResult handleSelectAll(SelectAllSelectionEvent event) { + final SelectionResult result = super.handleSelectAll(event); + for (final Selectable selectable in selectables) { + _hasReceivedStartEvent.add(selectable); + _hasReceivedEndEvent.add(selectable); + } + // Synthesize last update event so the edge updates continue to work. + _updateLastEdgeEventsFromGeometries(); + return result; + } + + /// Selects a word in a selectable at the location + /// [SelectWordSelectionEvent.globalPosition]. + @override + SelectionResult handleSelectWord(SelectWordSelectionEvent event) { + final SelectionResult result = super.handleSelectWord(event); + if (currentSelectionStartIndex != -1) + _hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]); + if (currentSelectionEndIndex != -1) + _hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]); + _updateLastEdgeEventsFromGeometries(); + return result; + } + + @override + SelectionResult handleClearSelection(ClearSelectionEvent event) { + final SelectionResult result = super.handleClearSelection(event); + _hasReceivedStartEvent.clear(); + _hasReceivedEndEvent.clear(); + _lastStartEdgeUpdateGlobalPosition = null; + _lastEndEdgeUpdateGlobalPosition = null; + return result; + } + + @override + SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + if (event.type == SelectionEventType.endEdgeUpdate) { + _lastEndEdgeUpdateGlobalPosition = event.globalPosition; + } else { + _lastStartEdgeUpdateGlobalPosition = event.globalPosition; + } + return super.handleSelectionEdgeUpdate(event); + } + + @override + void dispose() { + _hasReceivedStartEvent.clear(); + _hasReceivedEndEvent.clear(); + super.dispose(); + } + + @override + SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) { + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + _hasReceivedStartEvent.add(selectable); + ensureChildUpdated(selectable); + break; + case SelectionEventType.endEdgeUpdate: + _hasReceivedEndEvent.add(selectable); + ensureChildUpdated(selectable); + break; + case SelectionEventType.clear: + _hasReceivedStartEvent.remove(selectable); + _hasReceivedEndEvent.remove(selectable); + break; + case SelectionEventType.selectAll: + case SelectionEventType.selectWord: + break; + } + return super.dispatchSelectionEventToChild(selectable, event); + } + + @override + void ensureChildUpdated(Selectable selectable) { + if (_lastEndEdgeUpdateGlobalPosition != null && _hasReceivedEndEvent.add(selectable)) { + final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forEnd( + globalPosition: _lastEndEdgeUpdateGlobalPosition!, + ); + if (currentSelectionEndIndex == -1) { + handleSelectionEdgeUpdate(synthesizedEvent); + } + selectable.dispatchSelectionEvent(synthesizedEvent); + } + if (_lastStartEdgeUpdateGlobalPosition != null && _hasReceivedStartEvent.add(selectable)) { + final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forStart( + globalPosition: _lastStartEdgeUpdateGlobalPosition!, + ); + if (currentSelectionStartIndex == -1) { + handleSelectionEdgeUpdate(synthesizedEvent); + } + selectable.dispatchSelectionEvent(synthesizedEvent); + } + } + + @override + void didChangeSelectables() { + if (_lastEndEdgeUpdateGlobalPosition != null) { + handleSelectionEdgeUpdate( + SelectionEdgeUpdateEvent.forEnd( + globalPosition: _lastEndEdgeUpdateGlobalPosition!, + ), + ); + } + if (_lastStartEdgeUpdateGlobalPosition != null) { + handleSelectionEdgeUpdate( + SelectionEdgeUpdateEvent.forStart( + globalPosition: _lastStartEdgeUpdateGlobalPosition!, + ), + ); + } + final Set selectableSet = selectables.toSet(); + _hasReceivedEndEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable)); + _hasReceivedStartEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable)); + super.didChangeSelectables(); + } +} + +/// An abstract base class for updating multiple selectable children. +/// +/// This class provide basic [SelectionEvent] handling and child [Selectable] +/// updating. The subclass needs to implement [ensureChildUpdated] to ensure +/// child [Selectable] is updated properly. +/// +/// This class optimize the selection update by keeping track of the +/// [Selectable]s that currently contain the selection edges. +abstract class MultiSelectableSelectionContainerDelegate extends SelectionContainerDelegate with ChangeNotifier { + /// Gets the list of selectables this delegate is managing. + List selectables = []; + + /// The current selectable that contains the selection end edge. + @protected + int currentSelectionEndIndex = -1; + + /// The current selectable that contains the selection start edge. + @protected + int currentSelectionStartIndex = -1; + + LayerLink? _startHandleLayer; + Selectable? _startHandleLayerOwner; + LayerLink? _endHandleLayer; + Selectable? _endHandleLayerOwner; + + bool _isHandlingSelectionEvent = false; + bool _scheduledSelectableUpdate = false; + bool _selectionInProgress = false; + Set _additions = {}; + + @override + void add(Selectable selectable) { + assert(!selectables.contains(selectable)); + _additions.add(selectable); + _scheduleSelectableUpdate(); + } + + @override + void remove(Selectable selectable) { + if (_additions.remove(selectable)) { + return; + } + _removeSelectable(selectable); + _scheduleSelectableUpdate(); + } + + /// Notifies this delegate that layout of the container has changed. + void layoutDidChange() { + _updateSelectionGeometry(); + } + + void _scheduleSelectableUpdate() { + if (!_scheduledSelectableUpdate) { + _scheduledSelectableUpdate = true; + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!_scheduledSelectableUpdate) + return; + _scheduledSelectableUpdate = false; + _updateSelectables(); + }); + } + } + + void _updateSelectables() { + // Remove offScreen selectable. + if (_additions.isNotEmpty) { + _flushAdditions(); + } + didChangeSelectables(); + } + + void _flushAdditions() { + final List mergingSelectables = _additions.toList()..sort(compareOrder); + final List existingSelectables = selectables; + selectables = []; + int mergingIndex = 0; + int existingIndex = 0; + int selectionStartIndex = currentSelectionStartIndex; + int selectionEndIndex = currentSelectionEndIndex; + // Merge two sorted lists. + while (mergingIndex < mergingSelectables.length || existingIndex < existingSelectables.length) { + if (mergingIndex >= mergingSelectables.length || + (existingIndex < existingSelectables.length && + compareOrder(existingSelectables[existingIndex], mergingSelectables[mergingIndex]) < 0)) { + if (existingIndex == currentSelectionStartIndex) { + selectionStartIndex = selectables.length; + } + if (existingIndex == currentSelectionEndIndex) { + selectionEndIndex = selectables.length; + } + selectables.add(existingSelectables[existingIndex]); + existingIndex += 1; + continue; + } + + // If the merging selectable falls in the selection range, their selection + // needs to be updated. + final Selectable mergingSelectable = mergingSelectables[mergingIndex]; + if (existingIndex < max(currentSelectionStartIndex, currentSelectionEndIndex) && + existingIndex > min(currentSelectionStartIndex, currentSelectionEndIndex)) { + ensureChildUpdated(mergingSelectable); + } + mergingSelectable.addListener(_handleSelectableGeometryChange); + selectables.add(mergingSelectable); + mergingIndex += 1; + } + assert(mergingIndex == mergingSelectables.length && + existingIndex == existingSelectables.length && + selectables.length == existingIndex + mergingIndex); + assert(selectionStartIndex >= -1 || selectionStartIndex < selectables.length); + assert(selectionEndIndex >= -1 || selectionEndIndex < selectables.length); + // selection indices should not be set to -1 unless they originally were. + assert((currentSelectionStartIndex == -1) == (selectionStartIndex == -1)); + assert((currentSelectionEndIndex == -1) == (selectionEndIndex == -1)); + currentSelectionEndIndex = selectionEndIndex; + currentSelectionStartIndex = selectionStartIndex; + _additions = {}; + } + + void _removeSelectable(Selectable selectable) { + assert(selectables.contains(selectable), 'The selectable is not in this registrar.'); + final int index = selectables.indexOf(selectable); + selectables.removeAt(index); + if (index <= currentSelectionEndIndex) { + currentSelectionEndIndex -= 1; + } + if (index <= currentSelectionStartIndex) { + currentSelectionStartIndex -= 1; + } + selectable.removeListener(_handleSelectableGeometryChange); + } + + /// Called when this delegate finishes updating the selectables. + @protected + @mustCallSuper + void didChangeSelectables() { + _updateSelectionGeometry(); + } + + @override + SelectionGeometry get value => _selectionGeometry; + SelectionGeometry _selectionGeometry = const SelectionGeometry( + hasContent: false, + status: SelectionStatus.none, + ); + + /// Updates the [value] in this class and notifies listeners if necessary. + void _updateSelectionGeometry() { + final SelectionGeometry newValue = getSelectionGeometry(); + if (_selectionGeometry != newValue) { + _selectionGeometry = newValue; + notifyListeners(); + } + _updateHandleLayersAndOwners(); + } + + /// The compare function this delegate used for determining the selection + /// order of the selectables. + /// + /// Defaults to screen order. + @protected + Comparator get compareOrder => _compareScreenOrder; + + int _compareScreenOrder(Selectable a, Selectable b) { + final Rect rectA = MatrixUtils.transformRect( + a.getTransformTo(null), + Rect.fromLTWH(0, 0, a.size.width, a.size.height), + ); + final Rect rectB = MatrixUtils.transformRect( + b.getTransformTo(null), + Rect.fromLTWH(0, 0, b.size.width, b.size.height), + ); + final int result = _compareVertically(rectA, rectB); + if (result != 0) + return result; + return _compareHorizontally(rectA, rectB); + } + + /// Compares two rectangles in the screen order solely by their vertical + /// positions. + /// + /// Returns positive if a is lower, negative if a is higher, 0 if their + /// order can't be determine solely by their vertical position. + static int _compareVertically(Rect a, Rect b) { + if ((a.top - b.top < precisionErrorTolerance && a.bottom - b.bottom > - precisionErrorTolerance) || + (b.top - a.top < precisionErrorTolerance && b.bottom - a.bottom > - precisionErrorTolerance)) { + return 0; + } + if ((a.top - b.top).abs() > precisionErrorTolerance) + return a.top > b.top ? 1 : -1; + return a.bottom > b.bottom ? 1 : -1; + } + + /// Compares two rectangles in the screen order by their horizontal positions + /// assuming one of the rectangles enclose the other rect vertically. + /// + /// Returns positive if a is lower, negative if a is higher. + static int _compareHorizontally(Rect a, Rect b) { + if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) { + // a encloses b. + return -1; + } + if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) { + // b encloses a. + return 1; + } + if ((a.left - b.left).abs() > precisionErrorTolerance) + return a.left > b.left ? 1 : -1; + return a.right > b.right ? 1 : -1; + } + + void _handleSelectableGeometryChange() { + // Geometries of selectable children may change multiple times when handling + // selection events. Ignore these updates since the selection geometry of + // this delegate will be updated after handling the selection events. + if (_isHandlingSelectionEvent) + return; + _updateSelectionGeometry(); + } + + /// Gets the combined selection geometry for child selectables. + @protected + SelectionGeometry getSelectionGeometry() { + if (currentSelectionEndIndex == -1 || + currentSelectionStartIndex == -1 || + selectables.isEmpty) { + // There is no valid selection. + return SelectionGeometry( + status: SelectionStatus.none, + hasContent: selectables.isNotEmpty, + ); + } + + currentSelectionStartIndex = _adjustSelectionIndexBasedOnSelectionGeometry( + currentSelectionStartIndex, + currentSelectionEndIndex, + ); + currentSelectionEndIndex = _adjustSelectionIndexBasedOnSelectionGeometry( + currentSelectionEndIndex, + currentSelectionStartIndex, + ); + + // Need to find the non-null start selection point. + SelectionGeometry startGeometry = selectables[currentSelectionStartIndex].value; + final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex; + int startIndexWalker = currentSelectionStartIndex; + while (startIndexWalker != currentSelectionEndIndex && startGeometry.startSelectionPoint == null) { + startIndexWalker += forwardSelection ? 1 : -1; + startGeometry = selectables[startIndexWalker].value; + } + + SelectionPoint? startPoint; + if (startGeometry.startSelectionPoint != null) { + final Matrix4 startTransform = getTransformFrom(selectables[startIndexWalker]); + final Offset start = MatrixUtils.transformPoint(startTransform, startGeometry.startSelectionPoint!.localPosition); + // It can be NaN if it is detached or off-screen. + if (start.isFinite) { + startPoint = SelectionPoint( + localPosition: start, + lineHeight: startGeometry.startSelectionPoint!.lineHeight, + handleType: startGeometry.startSelectionPoint!.handleType, + ); + } + } + + // Need to find the non-null end selection point. + SelectionGeometry endGeometry = selectables[currentSelectionEndIndex].value; + int endIndexWalker = currentSelectionEndIndex; + while (endIndexWalker != currentSelectionStartIndex && endGeometry.endSelectionPoint == null) { + endIndexWalker += forwardSelection ? -1 : 1; + endGeometry = selectables[endIndexWalker].value; + } + SelectionPoint? endPoint; + if (endGeometry.endSelectionPoint != null) { + final Matrix4 endTransform = getTransformFrom(selectables[endIndexWalker]); + final Offset end = MatrixUtils.transformPoint(endTransform, endGeometry.endSelectionPoint!.localPosition); + // It can be NaN if it is detached or off-screen. + if (end.isFinite) { + endPoint = SelectionPoint( + localPosition: end, + lineHeight: endGeometry.endSelectionPoint!.lineHeight, + handleType: endGeometry.endSelectionPoint!.handleType, + ); + } + } + + return SelectionGeometry( + startSelectionPoint: startPoint, + endSelectionPoint: endPoint, + status: startGeometry != endGeometry + ? SelectionStatus.uncollapsed + : startGeometry.status, + // Would have at least one selectable child. + hasContent: true, + ); + } + + // The currentSelectionStartIndex or currentSelectionEndIndex may not be + // the current index that contains selection edges. This can happen if the + // selection edge is in between two selectables. One of the selectable will + // have its selection collapsed at the index 0 or contentLength depends on + // whether the selection is reversed or not. The current selection index can + // be point to either one. + // + // This method adjusts the index to point to selectable with valid selection. + int _adjustSelectionIndexBasedOnSelectionGeometry(int currentIndex, int towardIndex) { + final bool forward = towardIndex > currentIndex; + while (currentIndex != towardIndex && + selectables[currentIndex].value.status != SelectionStatus.uncollapsed) { + currentIndex += forward ? 1 : -1; + } + return currentIndex; + } + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { + if (_startHandleLayer == startHandle && _endHandleLayer == endHandle) + return; + _startHandleLayer = startHandle; + _endHandleLayer = endHandle; + _updateHandleLayersAndOwners(); + } + + /// Pushes both handle layers to the selectables that contain selection edges. + /// + /// This method needs to be called every time the selectables that contain the + /// selection edges change, i.e. [currentSelectionStartIndex] or + /// [currentSelectionEndIndex] changes. Otherwise, the handle may be painted + /// in the wrong place. + void _updateHandleLayersAndOwners() { + LayerLink? effectiveStartHandle = _startHandleLayer; + LayerLink? effectiveEndHandle = _endHandleLayer; + if (effectiveStartHandle != null || effectiveEndHandle != null) { + final Rect boxRect = Rect.fromLTWH(0, 0, containerSize.width, containerSize.height); + final bool hideStartHandle = value.startSelectionPoint == null || !boxRect.contains(value.startSelectionPoint!.localPosition); + final bool hideEndHandle = value.endSelectionPoint == null || !boxRect.contains(value.endSelectionPoint!.localPosition); + effectiveStartHandle = hideStartHandle ? null : _startHandleLayer; + effectiveEndHandle = hideEndHandle ? null : _endHandleLayer; + } + if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { + // No valid selection. + if (_startHandleLayerOwner != null) { + _startHandleLayerOwner!.pushHandleLayers(null, null); + _startHandleLayerOwner = null; + } + if (_endHandleLayerOwner != null) { + _endHandleLayerOwner!.pushHandleLayers(null, null); + _endHandleLayerOwner = null; + } + return; + } + + if (selectables[currentSelectionStartIndex] != _startHandleLayerOwner) { + _startHandleLayerOwner?.pushHandleLayers(null, null); + } + if (selectables[currentSelectionEndIndex] != _endHandleLayerOwner) { + _endHandleLayerOwner?.pushHandleLayers(null, null); + } + + _startHandleLayerOwner = selectables[currentSelectionStartIndex]; + + if (currentSelectionStartIndex == currentSelectionEndIndex) { + // Selection edges is on the same selectable. + _endHandleLayerOwner = _startHandleLayerOwner; + _startHandleLayerOwner!.pushHandleLayers(effectiveStartHandle, effectiveEndHandle); + return; + } + + _startHandleLayerOwner!.pushHandleLayers(effectiveStartHandle, null); + _endHandleLayerOwner = selectables[currentSelectionEndIndex]; + _endHandleLayerOwner!.pushHandleLayers(null, effectiveEndHandle); + } + + /// Copies the selected contents of all selectables. + @override + SelectedContent? getSelectedContent() { + final List selections = []; + for (final Selectable selectable in selectables) { + final SelectedContent? data = selectable.getSelectedContent(); + if (data != null) + selections.add(data); + } + if (selections.isEmpty) + return null; + final StringBuffer buffer = StringBuffer(); + for (final SelectedContent selection in selections) { + buffer.write(selection.plainText); + } + return SelectedContent( + plainText: buffer.toString(), + ); + } + + /// Selects all contents of all selectables. + @protected + SelectionResult handleSelectAll(SelectAllSelectionEvent event) { + for (final Selectable selectable in selectables) { + dispatchSelectionEventToChild(selectable, event); + } + currentSelectionStartIndex = 0; + currentSelectionEndIndex = selectables.length - 1; + return SelectionResult.none; + } + + /// Selects a word in a selectable at the location + /// [SelectWordSelectionEvent.globalPosition]. + @protected + SelectionResult handleSelectWord(SelectWordSelectionEvent event) { + for (int index = 0; index < selectables.length; index += 1) { + final Rect localRect = Rect.fromLTWH(0, 0, selectables[index].size.width, selectables[index].size.height); + final Matrix4 transform = selectables[index].getTransformTo(null); + final Rect globalRect = MatrixUtils.transformRect(transform, localRect); + if (globalRect.contains(event.globalPosition)) { + final SelectionGeometry existingGeometry = selectables[index].value; + dispatchSelectionEventToChild(selectables[index], event); + if (selectables[index].value != existingGeometry) { + // Geometry has changed as a result of select word, need to clear the + // selection of other selectables to keep selection in sync. + selectables + .where((Selectable target) => target != selectables[index]) + .forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent())); + currentSelectionStartIndex = currentSelectionEndIndex = index; + } + return SelectionResult.end; + } + } + return SelectionResult.none; + } + + /// Removes the selection of all selectables this delegate manages. + @protected + SelectionResult handleClearSelection(ClearSelectionEvent event) { + for (final Selectable selectable in selectables) { + dispatchSelectionEventToChild(selectable, event); + } + currentSelectionEndIndex = -1; + currentSelectionStartIndex = -1; + return SelectionResult.none; + } + + /// Updates the selection edges. + @protected + SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + if (event.type == SelectionEventType.endEdgeUpdate) { + return currentSelectionEndIndex == -1 ? _initSelection(event, isEnd: true) : _adjustSelection(event, isEnd: true); + } + return currentSelectionStartIndex == -1 ? _initSelection(event, isEnd: false) : _adjustSelection(event, isEnd: false); + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + final bool selectionWillbeInProgress = event is! ClearSelectionEvent; + if (!_selectionInProgress && selectionWillbeInProgress) { + // Sort the selectable every time a selection start. + selectables.sort(compareOrder); + } + _selectionInProgress = selectionWillbeInProgress; + _isHandlingSelectionEvent = true; + late SelectionResult result; + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + case SelectionEventType.endEdgeUpdate: + result = handleSelectionEdgeUpdate(event as SelectionEdgeUpdateEvent); + break; + case SelectionEventType.clear: + result = handleClearSelection(event as ClearSelectionEvent); + break; + case SelectionEventType.selectAll: + result = handleSelectAll(event as SelectAllSelectionEvent); + break; + case SelectionEventType.selectWord: + result = handleSelectWord(event as SelectWordSelectionEvent); + break; + } + _isHandlingSelectionEvent = false; + _updateSelectionGeometry(); + return result; + } + + @override + void dispose() { + for (final Selectable selectable in selectables) { + selectable.removeListener(_handleSelectableGeometryChange); + } + selectables = const []; + _scheduledSelectableUpdate = false; + super.dispose(); + } + + /// Ensures the selectable child has received up to date selection event. + /// + /// This method is called when a new [Selectable] is added to the delegate, + /// and its screen location falls into the previous selection. + /// + /// Subclasses are responsible for updating the selection of this newly added + /// [Selectable]. + @protected + void ensureChildUpdated(Selectable selectable); + + /// Dispatches a selection event to a specific selectable. + /// + /// Override this method if subclasses need to generate additional events or + /// treatments prior to sending the selection events. + @protected + SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) { + return selectable.dispatchSelectionEvent(event); + } + + /// Initializes the selection of the selectable children. + /// + /// The goal is to find the selectable child that contains the selection edge. + /// Returns [SelectionResult.end] if the selection edge ends on any of the + /// children. Otherwise, it returns [SelectionResult.previous] if the selection + /// does not reach any of its children. Returns [SelectionResult.next] + /// if the selection reaches the end of its children. + /// + /// Ideally, this method should only be called twice at the beginning of the + /// drag selection, once for start edge update event, once for end edge update + /// event. + SelectionResult _initSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) { + assert((isEnd && currentSelectionEndIndex == -1) || (!isEnd && currentSelectionStartIndex == -1)); + int newIndex = -1; + bool hasFoundEdgeIndex = false; + SelectionResult? result; + for (int index = 0; index < selectables.length && !hasFoundEdgeIndex; index += 1) { + final Selectable child = selectables[index]; + final SelectionResult childResult = dispatchSelectionEventToChild(child, event); + switch (childResult) { + case SelectionResult.next: + case SelectionResult.none: + newIndex = index; + break; + case SelectionResult.end: + newIndex = index; + result = SelectionResult.end; + hasFoundEdgeIndex = true; + break; + case SelectionResult.previous: + hasFoundEdgeIndex = true; + if (index == 0) { + newIndex = 0; + result = SelectionResult.previous; + } + result ??= SelectionResult.end; + break; + case SelectionResult.pending: + newIndex = index; + result = SelectionResult.pending; + hasFoundEdgeIndex = true; + break; + } + } + + if (newIndex == -1) { + assert(selectables.isEmpty); + return SelectionResult.none; + } + if (isEnd) { + currentSelectionEndIndex = newIndex; + } else { + currentSelectionStartIndex = newIndex; + } + // The result can only be null if the loop went through the entire list + // without any of the selection returned end or previous. In this case, the + // caller of this method needs to find the next selectable in their list. + return result ?? SelectionResult.next; + } + + /// Adjusts the selection based on the drag selection update event if there + /// is already a selectable child that contains the selection edge. + /// + /// This method starts by sending the selection event to the current + /// selectable that contains the selection edge, and finds forward or backward + /// if that selectable no longer contains the selection edge. + SelectionResult _adjustSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) { + assert(() { + if (isEnd) { + assert(currentSelectionEndIndex < selectables.length && currentSelectionEndIndex >= 0); + return true; + } + assert(currentSelectionStartIndex < selectables.length && currentSelectionStartIndex >= 0); + return true; + }()); + SelectionResult? finalResult; + int newIndex = isEnd ? currentSelectionEndIndex : currentSelectionStartIndex; + bool? forward; + late SelectionResult currentSelectableResult; + // This loop sends the selection event to the + // currentSelectionEndIndex/currentSelectionStartIndex to determine the + // direction of the search. If the result is `SelectionResult.next`, this + // loop look backward. Otherwise, it looks forward. + // + // The terminate condition are: + // 1. the selectable returns end, pending, none. + // 2. the selectable returns previous when looking forward. + // 2. the selectable returns next when looking backward. + while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) { + currentSelectableResult = dispatchSelectionEventToChild(selectables[newIndex], event); + switch (currentSelectableResult) { + case SelectionResult.end: + case SelectionResult.pending: + case SelectionResult.none: + finalResult = currentSelectableResult; + break; + case SelectionResult.next: + if (forward == false) { + newIndex += 1; + finalResult = SelectionResult.end; + } else if (newIndex == selectables.length - 1) { + finalResult = currentSelectableResult; + } else { + forward = true; + newIndex += 1; + } + break; + case SelectionResult.previous: + if (forward ?? false) { + newIndex -= 1; + finalResult = SelectionResult.end; + } else if (newIndex == 0) { + finalResult = currentSelectableResult; + } else { + forward = false; + newIndex -= 1; + } + break; + } + } + if (isEnd) { + currentSelectionEndIndex = newIndex; + } else { + currentSelectionStartIndex = newIndex; + } + return finalResult!; + } +} diff --git a/packages/flutter/lib/src/widgets/selection_container.dart b/packages/flutter/lib/src/widgets/selection_container.dart new file mode 100644 index 000000000000..49c2a0bc1667 --- /dev/null +++ b/packages/flutter/lib/src/widgets/selection_container.dart @@ -0,0 +1,303 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/rendering.dart'; + +import 'framework.dart'; + +/// A container that handles [SelectionEvent]s for the [Selectable]s in +/// the subtree. +/// +/// This widget is useful when one wants to customize selection behaviors for +/// a group of [Selectable]s +/// +/// The state of this container is a single selectable and will register +/// itself to the [registrar] if provided. Otherwise, it will register to the +/// [SelectionRegistrar] from the context. +/// +/// The containers handle the [SelectionEvent]s from the registered +/// [SelectionRegistrar] and delegate the events to the [delegate]. +/// +/// This widget uses [SelectionRegistrarScope] to host the [delegate] as the +/// [SelectionRegistrar] for the subtree to collect the [Selectable]s, and +/// [SelectionEvent]s received by this container are sent to the [delegate] using +/// the [SelectionHandler] API of the delegate. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a [SelectionContainer] that only +/// allows selecting everything or nothing with no partial selection. +/// +/// ** See code in examples/api/lib/material/selection_area/custom_container.dart ** +/// {@end-tool} +/// +/// See also: +/// * [SelectableRegion], which provides an overview of the selection system. +/// * [SelectionContainer.disabled], which disable selection for a +/// subtree. +class SelectionContainer extends StatefulWidget { + /// Creates a selection container to collect the [Selectable]s in the subtree. + /// + /// If [registrar] is not provided, this selection container gets the + /// [SelectionRegistrar] from the context instead. + /// + /// The [delegate] and [child] must not be null. + const SelectionContainer({ + super.key, + this.registrar, + required SelectionContainerDelegate this.delegate, + required this.child, + }) : assert(delegate != null), + assert(child != null); + + /// Creates a selection container that disables selection for the + /// subtree. + /// + /// The [child] must not be null. + const SelectionContainer.disabled({ + super.key, + required this.child, + }) : registrar = null, + delegate = null; + + /// The [SelectionRegistrar] this container is registered to. + /// + /// If null, this widget gets the [SelectionRegistrar] from the current + /// context. + final SelectionRegistrar? registrar; + + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// The delegate for [SelectionEvent]s sent to this selection container. + /// + /// The [Selectable]s in the subtree are added or removed from this delegate + /// using [SelectionRegistrar] API. + /// + /// This delegate is responsible for updating the selections for the selectables + /// under this widget. + final SelectionContainerDelegate? delegate; + + /// Gets the immediate ancestor [SelectionRegistrar] of the [BuildContext]. + /// + /// If this returns null, either there is no [SelectionContainer] above + /// the [BuildContext] or the immediate [SelectionContainer] is not + /// enabled. + static SelectionRegistrar? maybeOf(BuildContext context) { + final SelectionRegistrarScope? scope = context.dependOnInheritedWidgetOfExactType(); + return scope?.registrar; + } + + bool get _disabled => delegate == null; + + @override + State createState() => _SelectionContainerState(); +} + +class _SelectionContainerState extends State with Selectable, SelectionRegistrant { + final Set _listeners = {}; + + static const SelectionGeometry _disabledGeometry = SelectionGeometry( + status: SelectionStatus.none, + hasContent: true, + ); + + @override + void initState() { + super.initState(); + if (!widget._disabled) { + widget.delegate!._selectionContainerContext = context; + if (widget.registrar != null) + registrar = widget.registrar; + } + } + + @override + void didUpdateWidget(SelectionContainer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.delegate != widget.delegate) { + if (!oldWidget._disabled) { + oldWidget.delegate!._selectionContainerContext = null; + _listeners.forEach(oldWidget.delegate!.removeListener); + } + if (!widget._disabled) { + widget.delegate!._selectionContainerContext = context; + _listeners.forEach(widget.delegate!.addListener); + } + if (oldWidget.delegate?.value != widget.delegate?.value) { + for (final VoidCallback listener in _listeners) { + listener(); + } + } + } + if (widget._disabled) { + registrar = null; + } else if (widget.registrar != null) { + registrar = widget.registrar; + } + assert(!widget._disabled || registrar == null); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (widget.registrar == null && !widget._disabled) { + registrar = SelectionContainer.maybeOf(context); + } + assert(!widget._disabled || registrar == null); + } + + @override + void addListener(VoidCallback listener) { + assert(!widget._disabled); + widget.delegate!.addListener(listener); + _listeners.add(listener); + } + + @override + void removeListener(VoidCallback listener) { + widget.delegate?.removeListener(listener); + _listeners.remove(listener); + } + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { + assert(!widget._disabled); + widget.delegate!.pushHandleLayers(startHandle, endHandle); + } + + @override + SelectedContent? getSelectedContent() { + assert(!widget._disabled); + return widget.delegate!.getSelectedContent(); + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + assert(!widget._disabled); + return widget.delegate!.dispatchSelectionEvent(event); + } + + @override + SelectionGeometry get value { + if (widget._disabled) + return _disabledGeometry; + return widget.delegate!.value; + } + + @override + Matrix4 getTransformTo(RenderObject? ancestor) { + assert(!widget._disabled); + return context.findRenderObject()!.getTransformTo(ancestor); + } + + @override + Size get size => (context.findRenderObject()! as RenderBox).size; + + @override + void dispose() { + if (!widget._disabled) { + widget.delegate!._selectionContainerContext = null; + _listeners.forEach(widget.delegate!.removeListener); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget._disabled) { + return SelectionRegistrarScope._disabled(child: widget.child); + } + return SelectionRegistrarScope( + registrar: widget.delegate!, + child: widget.child, + ); + } +} + +/// An inherited widget to host a [SelectionRegistrar] for the subtree. +/// +/// Use [SelectionContainer.maybeOf] to get the SelectionRegistrar from +/// a context. +/// +/// This widget is automatically created as part of [SelectionContainer] and +/// is generally not used directly, except for disabling selection for a part +/// of subtree. In that case, one can wrap the subtree with +/// [SelectionContainer.disabled]. +class SelectionRegistrarScope extends InheritedWidget { + /// Creates a selection registrar scope that host the [registrar]. + const SelectionRegistrarScope({ + super.key, + required SelectionRegistrar this.registrar, + required super.child, + }) : assert(registrar != null); + + /// Creates a selection registrar scope that disables selection for the + /// subtree. + const SelectionRegistrarScope._disabled({ + required super.child, + }) : registrar = null; + + /// The [SelectionRegistrar] hosted by this widget. + final SelectionRegistrar? registrar; + + @override + bool updateShouldNotify(SelectionRegistrarScope oldWidget) { + return oldWidget.registrar != registrar; + } +} + +/// A delegate to handle [SelectionEvent]s for a [SelectionContainer]. +/// +/// This delegate needs to implement [SelectionRegistrar] to register +/// [Selectable]s in the [SelectionContainer] subtree. +abstract class SelectionContainerDelegate implements SelectionHandler, SelectionRegistrar { + BuildContext? _selectionContainerContext; + + /// Gets the paint transform from the [Selectable] child to + /// [SelectionContainer] of this delegate. + /// + /// Returns a matrix that maps the [Selectable] paint coordinate system to the + /// coordinate system of [SelectionContainer]. + /// + /// Can only be called after [SelectionContainer] is laid out. + Matrix4 getTransformFrom(Selectable child) { + assert( + _selectionContainerContext?.findRenderObject() != null, + 'getTransformFrom cannot be called before SelectionContainer is laid out.', + ); + return child.getTransformTo(_selectionContainerContext!.findRenderObject()! as RenderBox); + } + + /// Gets the paint transform from the [SelectionContainer] of this delegate to + /// the `ancestor`. + /// + /// Returns a matrix that maps the [SelectionContainer] paint coordinate + /// system to the coordinate system of `ancestor`. + /// + /// If `ancestor` is null, this method returns a matrix that maps from the + /// local paint coordinate system to the coordinate system of the + /// [PipelineOwner.rootNode]. + /// + /// Can only be called after [SelectionContainer] is laid out. + Matrix4 getTransformTo(RenderObject? ancestor) { + assert( + _selectionContainerContext?.findRenderObject() != null, + 'getTransformTo cannot be called before SelectionContainer is laid out.', + ); + final RenderBox box = _selectionContainerContext!.findRenderObject()! as RenderBox; + return box.getTransformTo(ancestor); + } + + /// Gets the size of the [SelectionContainer] of this delegate. + /// + /// Can only be called after [SelectionContainer] is laid out. + Size get containerSize { + assert( + _selectionContainerContext?.findRenderObject() != null, + 'containerSize cannot be called before SelectionContainer is laid out.', + ); + final RenderBox box = _selectionContainerContext!.findRenderObject()! as RenderBox; + return box.size; + } +} diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart index 226bea7c69d5..29835959ea5b 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart'; import 'automatic_keep_alive.dart'; import 'basic.dart'; import 'framework.dart'; +import 'selection_container.dart'; export 'package:flutter/rendering.dart' show SliverGridDelegate, @@ -484,7 +485,7 @@ class SliverChildBuilderDelegate extends SliverChildDelegate { child = IndexedSemantics(index: semanticIndex + semanticIndexOffset, child: child); } if (addAutomaticKeepAlives) - child = AutomaticKeepAlive(child: child); + child = AutomaticKeepAlive(child: _SelectionKeepAlive(child: child)); return KeyedSubtree(key: key, child: child); } @@ -748,7 +749,8 @@ class SliverChildListDelegate extends SliverChildDelegate { child = IndexedSemantics(index: semanticIndex + semanticIndexOffset, child: child); } if (addAutomaticKeepAlives) - child = AutomaticKeepAlive(child: child); + child = AutomaticKeepAlive(child: _SelectionKeepAlive(child: child)); + return KeyedSubtree(key: key, child: child); } @@ -760,6 +762,121 @@ class SliverChildListDelegate extends SliverChildDelegate { return children != oldDelegate.children; } } +class _SelectionKeepAlive extends StatefulWidget { + /// Creates a widget that listens to [KeepAliveNotification]s and maintains a + /// [KeepAlive] widget appropriately. + const _SelectionKeepAlive({ + required this.child, + }); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + State<_SelectionKeepAlive> createState() => _SelectionKeepAliveState(); +} + +class _SelectionKeepAliveState extends State<_SelectionKeepAlive> with AutomaticKeepAliveClientMixin implements SelectionRegistrar { + Set? _selectablesWithSelections; + Map? _selectableAttachments; + SelectionRegistrar? _registrar; + + @override + bool get wantKeepAlive => _wantKeepAlive; + bool _wantKeepAlive = false; + set wantKeepAlive(bool value) { + if (_wantKeepAlive != value) { + _wantKeepAlive = value; + updateKeepAlive(); + } + } + + VoidCallback listensTo(Selectable selectable) { + return () { + if (selectable.value.hasSelection) { + _updateSelectablesWithSelections(selectable, add: true); + } else { + _updateSelectablesWithSelections(selectable, add: false); + } + }; + } + + void _updateSelectablesWithSelections(Selectable selectable, {required bool add}) { + if (add) { + assert(selectable.value.hasSelection); + _selectablesWithSelections ??= {}; + _selectablesWithSelections!.add(selectable); + } else { + _selectablesWithSelections?.remove(selectable); + } + wantKeepAlive = _selectablesWithSelections?.isNotEmpty ?? false; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final SelectionRegistrar? newRegistrar = SelectionContainer.maybeOf(context); + if (_registrar != newRegistrar) { + if (_registrar != null) { + _selectableAttachments?.keys.forEach(_registrar!.remove); + } + _registrar = newRegistrar; + if (_registrar != null) { + _selectableAttachments?.keys.forEach(_registrar!.add); + } + } + } + + @override + void add(Selectable selectable) { + final VoidCallback attachment = listensTo(selectable); + selectable.addListener(attachment); + _selectableAttachments ??= {}; + _selectableAttachments![selectable] = attachment; + _registrar!.add(selectable); + if (selectable.value.hasSelection) + _updateSelectablesWithSelections(selectable, add: true); + } + + @override + void remove(Selectable selectable) { + if (_selectableAttachments == null) { + return; + } + assert(_selectableAttachments!.containsKey(selectable)); + final VoidCallback attachment = _selectableAttachments!.remove(selectable)!; + selectable.removeListener(attachment); + _registrar!.remove(selectable); + _updateSelectablesWithSelections(selectable, add: false); + } + + @override + void dispose() { + if (_selectableAttachments != null) { + for (final Selectable selectable in _selectableAttachments!.keys) { + _registrar!.remove(selectable); + selectable.removeListener(_selectableAttachments![selectable]!); + } + _selectableAttachments = null; + } + _selectablesWithSelections = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + if (_registrar == null) { + return widget.child; + } + return SelectionRegistrarScope( + registrar: this, + child: widget.child, + ); + } +} /// A base class for sliver that have [KeepAlive] children. /// diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index b56a880c2a53..b91f68055713 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -5,11 +5,14 @@ import 'dart:ui' as ui show TextHeightBehavior; import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; import 'basic.dart'; +import 'default_selection_style.dart'; import 'framework.dart'; import 'inherited_theme.dart'; import 'media_query.dart'; +import 'selection_container.dart'; // Examples can assume: // late String _name; @@ -342,10 +345,25 @@ class DefaultTextHeightBehavior extends InheritedTheme { /// [TapGestureRecognizer] as the [TextSpan.recognizer] of the relevant part of /// the text. /// +/// ## Selection +/// +/// [Text] is not selectable by default. To make a [Text] selectable, one can +/// wrap a subtree with a [SelectionArea] widget. To exclude a part of a subtree +/// under [SelectionArea] from selection, once can also wrap that part of the +/// subtree with [SelectionContainer.disabled]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to disable selection for a Text under a +/// SelectionArea. +/// +/// ** See code in examples/api/lib/material/selection_area/disable_partial_selection.dart ** +/// {@end-tool} +/// /// See also: /// /// * [RichText], which gives you more control over the text styles. /// * [DefaultTextStyle], which sets default styles for [Text] widgets. +/// * [SelectableRegion], which provides an overview of the selection system. class Text extends StatelessWidget { /// Creates a text widget. /// @@ -372,6 +390,7 @@ class Text extends StatelessWidget { this.semanticsLabel, this.textWidthBasis, this.textHeightBehavior, + this.selectionColor, }) : assert( data != null, 'A non-null String must be provided to a Text widget.', @@ -403,6 +422,7 @@ class Text extends StatelessWidget { this.semanticsLabel, this.textWidthBasis, this.textHeightBehavior, + this.selectionColor, }) : assert( textSpan != null, 'A non-null TextSpan must be provided to a Text.rich widget.', @@ -512,6 +532,9 @@ class Text extends StatelessWidget { /// {@macro dart.ui.textHeightBehavior} final ui.TextHeightBehavior? textHeightBehavior; + /// The color to use when painting the selection. + final Color? selectionColor; + @override Widget build(BuildContext context) { final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); @@ -520,6 +543,7 @@ class Text extends StatelessWidget { effectiveTextStyle = defaultTextStyle.style.merge(style); if (MediaQuery.boldTextOverride(context)) effectiveTextStyle = effectiveTextStyle!.merge(const TextStyle(fontWeight: FontWeight.bold)); + final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); Widget result = RichText( textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null. @@ -531,12 +555,20 @@ class Text extends StatelessWidget { strutStyle: strutStyle, textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis, textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.of(context), + selectionRegistrar: registrar, + selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor, text: TextSpan( style: effectiveTextStyle, text: data, children: textSpan != null ? [textSpan!] : null, ), ); + if (registrar != null) { + result = MouseRegion( + cursor: SystemMouseCursors.text, + child: result, + ); + } if (semanticsLabel != null) { result = Semantics( textDirection: textDirection, diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index e5567bba0560..663dc51b367a 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -29,38 +29,6 @@ export 'package:flutter/services.dart' show TextSelectionDelegate; /// called. const Duration _kDragSelectionUpdateThrottle = Duration(milliseconds: 50); -/// Which type of selection handle to be displayed. -/// -/// With mixed-direction text, both handles may be the same type. Examples: -/// -/// * LTR text: 'the <quick brown> fox': -/// -/// The '<' is drawn with the [left] type, the '>' with the [right] -/// -/// * RTL text: 'XOF <NWORB KCIUQ> EHT': -/// -/// Same as above. -/// -/// * mixed text: '<the NWOR<B KCIUQ fox' -/// -/// Here 'the QUICK B' is selected, but 'QUICK BROWN' is RTL. Both are drawn -/// with the [left] type. -/// -/// See also: -/// -/// * [TextDirection], which discusses left-to-right and right-to-left text in -/// more detail. -enum TextSelectionHandleType { - /// The selection handle is to the left of the selection end point. - left, - - /// The selection handle is to the right of the selection end point. - right, - - /// The start and end of the selection are co-incident at this point. - collapsed, -} - /// Signature for when a pointer that's dragging to select text has moved again. /// /// The first argument [startDetails] contains the details of the event that diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 60e3ef6c57c0..fecda7221562 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -16,6 +16,7 @@ export 'package:characters/characters.dart'; export 'package:vector_math/vector_math_64.dart' show Matrix4; export 'foundation.dart' show UniqueKey; +export 'rendering.dart' show TextSelectionHandleType; export 'src/widgets/actions.dart'; export 'src/widgets/animated_cross_fade.dart'; export 'src/widgets/animated_list.dart'; @@ -110,6 +111,8 @@ export 'src/widgets/scroll_simulation.dart'; export 'src/widgets/scroll_view.dart'; export 'src/widgets/scrollable.dart'; export 'src/widgets/scrollbar.dart'; +export 'src/widgets/selectable_region.dart'; +export 'src/widgets/selection_container.dart'; export 'src/widgets/semantics_debugger.dart'; export 'src/widgets/shared_app_data.dart'; export 'src/widgets/shortcuts.dart'; diff --git a/packages/flutter/test/material/selection_area_test.dart b/packages/flutter/test/material/selection_area_test.dart new file mode 100644 index 000000000000..c4cb77319cf2 --- /dev/null +++ b/packages/flutter/test/material/selection_area_test.dart @@ -0,0 +1,36 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SelectionArea uses correct selection controls', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: SelectionArea( + child: Text('abc'), + ), + )); + final SelectableRegion region = tester.widget(find.byType(SelectableRegion)); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + expect(region.selectionControls, materialTextSelectionControls); + break; + case TargetPlatform.iOS: + expect(region.selectionControls, cupertinoTextSelectionControls); + break; + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(region.selectionControls, desktopTextSelectionControls); + break; + case TargetPlatform.macOS: + expect(region.selectionControls, cupertinoDesktopTextSelectionControls); + break; + } + }, variant: TargetPlatformVariant.all()); +} diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart index fe781e34d9ce..deddef608757 100644 --- a/packages/flutter/test/rendering/paragraph_test.dart +++ b/packages/flutter/test/rendering/paragraph_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle; +import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle, Paragraph; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; @@ -815,4 +815,134 @@ void main() { paragraph.assembleSemanticsNode(node, SemanticsConfiguration(), []); expect(node.childrenCount, 2); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020 + + group('Selection', () { + void selectionParagraph(RenderParagraph paragraph, TextPosition start, TextPosition end) { + for (final Selectable selectable in (paragraph.registrar! as TestSelectionRegistrar).selectables) { + selectable.dispatchSelectionEvent( + SelectionEdgeUpdateEvent.forStart( + globalPosition: paragraph.getOffsetForCaret(start, Rect.zero), + ), + ); + selectable.dispatchSelectionEvent( + SelectionEdgeUpdateEvent.forEnd( + globalPosition: paragraph.getOffsetForCaret(end, Rect.zero), + ), + ); + } + } + + test('subscribe to SelectionRegistrar', () { + final TestSelectionRegistrar registrar = TestSelectionRegistrar(); + final RenderParagraph paragraph = RenderParagraph( + const TextSpan(text: '1234567'), + textDirection: TextDirection.ltr, + registrar: registrar, + ); + expect(registrar.selectables.length, 1); + + paragraph.text = const TextSpan(text: ''); + expect(registrar.selectables.length, 0); + }); + + test('paints selection highlight', () async { + final TestSelectionRegistrar registrar = TestSelectionRegistrar(); + const Color selectionColor = Color(0xAF6694e8); + final RenderParagraph paragraph = RenderParagraph( + const TextSpan(text: '1234567'), + textDirection: TextDirection.ltr, + registrar: registrar, + selectionColor: selectionColor, + ); + layout(paragraph); + final MockPaintingContext paintingContext = MockPaintingContext(); + paragraph.paint(paintingContext, Offset.zero); + expect(paintingContext.canvas.drawedRect, isNull); + expect(paintingContext.canvas.drawedRectPaint, isNull); + selectionParagraph(paragraph, const TextPosition(offset: 1), const TextPosition(offset: 5)); + paragraph.paint(paintingContext, Offset.zero); + expect(paintingContext.canvas.drawedRect, const Rect.fromLTWH(14.0, 0.0, 56.0, 14.0)); + expect(paintingContext.canvas.drawedRectPaint!.style, PaintingStyle.fill); + expect(paintingContext.canvas.drawedRectPaint!.color, selectionColor); + + selectionParagraph(paragraph, const TextPosition(offset: 2), const TextPosition(offset: 4)); + paragraph.paint(paintingContext, Offset.zero); + expect(paintingContext.canvas.drawedRect, const Rect.fromLTWH(28.0, 0.0, 28.0, 14.0)); + expect(paintingContext.canvas.drawedRectPaint!.style, PaintingStyle.fill); + expect(paintingContext.canvas.drawedRectPaint!.color, selectionColor); + }); + + test('getPositionForOffset works', () async { + final RenderParagraph paragraph = RenderParagraph(const TextSpan(text: '1234567'), textDirection: TextDirection.ltr); + layout(paragraph); + expect(paragraph.getPositionForOffset(const Offset(42.0, 14.0)), const TextPosition(offset: 3)); + }); + + test('can handle select all when contains widget span', () async { + final TestSelectionRegistrar registrar = TestSelectionRegistrar(); + final List renderBoxes = [ + RenderParagraph(const TextSpan(text: 'widget'), textDirection: TextDirection.ltr), + ]; + final RenderParagraph paragraph = RenderParagraph( + const TextSpan( + children: [ + TextSpan(text: 'before the span'), + WidgetSpan(child: Text('widget')), + TextSpan(text: 'after the span'), + ] + ), + textDirection: TextDirection.ltr, + registrar: registrar, + children: renderBoxes, + ); + layout(paragraph); + // The widget span will register to the selection container without going + // through the render paragraph. + expect(registrar.selectables.length, 2); + final Selectable segment1 = registrar.selectables[0]; + segment1.dispatchSelectionEvent(const SelectAllSelectionEvent()); + final SelectionGeometry geometry1 = segment1.value; + expect(geometry1.hasContent, true); + expect(geometry1.status, SelectionStatus.uncollapsed); + + final Selectable segment2 = registrar.selectables[1]; + segment2.dispatchSelectionEvent(const SelectAllSelectionEvent()); + final SelectionGeometry geometry2 = segment2.value; + expect(geometry2.hasContent, true); + expect(geometry2.status, SelectionStatus.uncollapsed); + }); + }); +} + +class MockCanvas extends Fake implements Canvas { + Rect? drawedRect; + Paint? drawedRectPaint; + + @override + void drawRect(Rect rect, Paint paint) { + drawedRect = rect; + drawedRectPaint = paint; + } + + @override + void drawParagraph(ui.Paragraph paragraph, Offset offset) { } +} + +class MockPaintingContext extends Fake implements PaintingContext { + @override + final MockCanvas canvas = MockCanvas(); +} + +class TestSelectionRegistrar extends SelectionRegistrar { + final List selectables = []; + @override + void add(Selectable selectable) { + selectables.add(selectable); + } + + @override + void remove(Selectable selectable) { + expect(selectables.remove(selectable), isTrue); + } + } diff --git a/packages/flutter/test/rendering/selection_test.dart b/packages/flutter/test/rendering/selection_test.dart new file mode 100644 index 000000000000..6fb3cec3b34f --- /dev/null +++ b/packages/flutter/test/rendering/selection_test.dart @@ -0,0 +1,84 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + + +void main() { + const Rect rect = Rect.fromLTWH(100, 100, 200, 500); + const Offset outsideTopLeft = Offset(50, 50); + const Offset outsideLeft = Offset(50, 200); + const Offset outsideBottomLeft = Offset(50, 700); + const Offset outsideTop = Offset(200, 50); + const Offset outsideTopRight = Offset(350, 50); + const Offset outsideRight = Offset(350, 200); + const Offset outsideBottomRight = Offset(350, 700); + const Offset outsideBottom = Offset(200, 700); + const Offset center = Offset(150, 300); + + group('selection utils', () { + test('selectionBasedOnRect works', () { + expect( + SelectionUtils.getResultBasedOnRect(rect, outsideTopLeft), + SelectionResult.previous, + ); + expect( + SelectionUtils.getResultBasedOnRect(rect, outsideLeft), + SelectionResult.previous, + ); + expect( + SelectionUtils.getResultBasedOnRect(rect, outsideBottomLeft), + SelectionResult.next, + ); + expect( + SelectionUtils.getResultBasedOnRect(rect, outsideTop), + SelectionResult.previous, + ); + expect( + SelectionUtils.getResultBasedOnRect(rect, outsideTopRight), + SelectionResult.previous, + ); + expect( + SelectionUtils.getResultBasedOnRect(rect, outsideRight), + SelectionResult.next, + ); + expect( + SelectionUtils.getResultBasedOnRect(rect, outsideBottomRight), + SelectionResult.next, + ); + expect( + SelectionUtils.getResultBasedOnRect(rect, outsideBottom), + SelectionResult.next, + ); + expect( + SelectionUtils.getResultBasedOnRect(rect, center), + SelectionResult.end, + ); + }); + + test('adjustDragOffset works', () { + // ltr + expect(SelectionUtils.adjustDragOffset(rect, outsideTopLeft), rect.topLeft); + expect(SelectionUtils.adjustDragOffset(rect, outsideLeft), rect.topLeft); + expect(SelectionUtils.adjustDragOffset(rect, outsideBottomLeft), rect.bottomRight); + expect(SelectionUtils.adjustDragOffset(rect, outsideTop), rect.topLeft); + expect(SelectionUtils.adjustDragOffset(rect, outsideTopRight), rect.topLeft); + expect(SelectionUtils.adjustDragOffset(rect, outsideRight), rect.bottomRight); + expect(SelectionUtils.adjustDragOffset(rect, outsideBottomRight), rect.bottomRight); + expect(SelectionUtils.adjustDragOffset(rect, outsideBottom), rect.bottomRight); + expect(SelectionUtils.adjustDragOffset(rect, center), center); + // rtl + expect(SelectionUtils.adjustDragOffset(rect, outsideTopLeft, direction: TextDirection.rtl), rect.topRight); + expect(SelectionUtils.adjustDragOffset(rect, outsideLeft, direction: TextDirection.rtl), rect.topRight); + expect(SelectionUtils.adjustDragOffset(rect, outsideBottomLeft, direction: TextDirection.rtl), rect.bottomLeft); + expect(SelectionUtils.adjustDragOffset(rect, outsideTop, direction: TextDirection.rtl), rect.topRight); + expect(SelectionUtils.adjustDragOffset(rect, outsideTopRight, direction: TextDirection.rtl), rect.topRight); + expect(SelectionUtils.adjustDragOffset(rect, outsideRight, direction: TextDirection.rtl), rect.bottomLeft); + expect(SelectionUtils.adjustDragOffset(rect, outsideBottomRight, direction: TextDirection.rtl), rect.bottomLeft); + expect(SelectionUtils.adjustDragOffset(rect, outsideBottom, direction: TextDirection.rtl), rect.bottomLeft); + expect(SelectionUtils.adjustDragOffset(rect, center, direction: TextDirection.rtl), center); + }); + }); +} diff --git a/packages/flutter/test/widgets/scrollable_selection_test.dart b/packages/flutter/test/widgets/scrollable_selection_test.dart new file mode 100644 index 000000000000..dd5d8056f540 --- /dev/null +++ b/packages/flutter/test/widgets/scrollable_selection_test.dart @@ -0,0 +1,652 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'clipboard_utils.dart'; + +Offset textOffsetToPosition(RenderParagraph paragraph, int offset) { + const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0); + final Offset localOffset = paragraph.getOffsetForCaret(TextPosition(offset: offset), caret); + return paragraph.localToGlobal(localOffset); +} + +Offset globalize(Offset point, RenderBox box) { + return box.localToGlobal(point); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final MockClipboard mockClipboard = MockClipboard(); + + setUp(() async { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall); + await Clipboard.setData(const ClipboardData(text: 'empty')); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); + + testWidgets('mouse can select multiple widgets', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); + await tester.pump(); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5)); + // Should select the rest of paragraph 1. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph3, 3)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3)); + + await gesture.up(); + }); + + testWidgets('mouse can select multiple widgets - horizontal', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); + await tester.pump(); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 5)); + // Should select the rest of paragraph 1. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + + await gesture.up(); + }); + + testWidgets('select to scroll forward', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + expect(controller.offset, 0.0); + double previousOffset = controller.offset; + + await gesture.moveTo(tester.getBottomRight(find.byType(ListView))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + previousOffset = controller.offset; + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + + // Scroll to the end. + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.offset, 4200.0); + final RenderParagraph paragraph99 = tester.renderObject(find.descendant(of: find.text('Item 99'), matching: find.byType(RichText))); + final RenderParagraph paragraph98 = tester.renderObject(find.descendant(of: find.text('Item 98'), matching: find.byType(RichText))); + final RenderParagraph paragraph97 = tester.renderObject(find.descendant(of: find.text('Item 97'), matching: find.byType(RichText))); + final RenderParagraph paragraph96 = tester.renderObject(find.descendant(of: find.text('Item 96'), matching: find.byType(RichText))); + expect(paragraph99.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); + expect(paragraph98.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); + expect(paragraph97.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); + expect(paragraph96.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); + + await gesture.up(); + }); + + testWidgets('select to scroll backward', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + + controller.jumpTo(4000); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView)), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + expect(controller.offset, 4000); + double previousOffset = controller.offset; + + await gesture.moveTo(tester.getTopLeft(find.byType(ListView))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset < previousOffset, isTrue); + previousOffset = controller.offset; + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset < previousOffset, isTrue); + + // Scroll to the beginning. + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.offset, 0.0); + final RenderParagraph paragraph0 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText))); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText))); + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText))); + expect(paragraph0.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); + }); + + testWidgets('select to scroll forward - horizontal', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + scrollDirection: Axis.horizontal, + controller: controller, + itemCount: 10, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + expect(controller.offset, 0.0); + double previousOffset = controller.offset; + + await gesture.moveTo(tester.getBottomRight(find.byType(ListView))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + previousOffset = controller.offset; + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + + // Scroll to the end. + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.offset, 2080.0); + final RenderParagraph paragraph9 = tester.renderObject(find.descendant(of: find.text('Item 9'), matching: find.byType(RichText))); + final RenderParagraph paragraph8 = tester.renderObject(find.descendant(of: find.text('Item 8'), matching: find.byType(RichText))); + final RenderParagraph paragraph7 = tester.renderObject(find.descendant(of: find.text('Item 7'), matching: find.byType(RichText))); + expect(paragraph9.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + expect(paragraph8.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + expect(paragraph7.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + + await gesture.up(); + }); + + testWidgets('select to scroll backward - horizontal', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + scrollDirection: Axis.horizontal, + controller: controller, + itemCount: 10, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + + controller.jumpTo(2080); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView)), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + expect(controller.offset, 2080); + double previousOffset = controller.offset; + + await gesture.moveTo(tester.getTopLeft(find.byType(ListView))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset < previousOffset, isTrue); + previousOffset = controller.offset; + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset < previousOffset, isTrue); + + // Scroll to the beginning. + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.offset, 0.0); + final RenderParagraph paragraph0 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText))); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText))); + expect(paragraph0.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); + + await gesture.up(); + }); + + testWidgets('preserve selection when out of view.', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + + controller.jumpTo(2000); + await tester.pumpAndSettle(); + expect(find.text('Item 50'), findsOneWidget); + RenderParagraph paragraph50 = tester.renderObject(find.descendant(of: find.text('Item 50'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph50, 2), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(textOffsetToPosition(paragraph50, 4)); + await gesture.up(); + expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(find.text('Item 50'), findsNothing); + + controller.jumpTo(2000); + await tester.pumpAndSettle(); + expect(find.text('Item 50'), findsOneWidget); + paragraph50 = tester.renderObject(find.descendant(of: find.text('Item 50'), matching: find.byType(RichText))); + expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + controller.jumpTo(4000); + await tester.pumpAndSettle(); + expect(find.text('Item 50'), findsNothing); + + controller.jumpTo(2000); + await tester.pumpAndSettle(); + expect(find.text('Item 50'), findsOneWidget); + paragraph50 = tester.renderObject(find.descendant(of: find.text('Item 50'), matching: find.byType(RichText))); + expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + }); + + testWidgets('can select all non-Apple', (WidgetTester tester) async { + final FocusNode node = FocusNode(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + focusNode: node, + selectionControls: materialTextSelectionControls, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + await tester.pumpAndSettle(); + node.requestFocus(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + for (int i = 0; i < 13; i += 1) { + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('Item $i'), matching: find.byType(RichText))); + expect(paragraph.selections[0], TextSelection(baseOffset: 0, extentOffset: 'Item $i'.length)); + } + expect(find.text('Item 13'), findsNothing); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia })); + + testWidgets('can select all - Apple', (WidgetTester tester) async { + final FocusNode node = FocusNode(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + focusNode: node, + selectionControls: materialTextSelectionControls, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + await tester.pumpAndSettle(); + node.requestFocus(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + await tester.pump(); + + for (int i = 0; i < 13; i += 1) { + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('Item $i'), matching: find.byType(RichText))); + expect(paragraph.selections[0], TextSelection(baseOffset: 0, extentOffset: 'Item $i'.length)); + } + expect(find.text('Item 13'), findsNothing); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('select to scroll by dragging selection handles forward', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + await tester.pumpAndSettle(); + + // Long press to bring up the selection handles. + final RenderParagraph paragraph0 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2)); + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); + + final List boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]); + expect(boxes.length, 1); + final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph0); + await gesture.down(handlePos); + + expect(controller.offset, 0.0); + double previousOffset = controller.offset; + + await gesture.moveTo(tester.getBottomRight(find.byType(ListView))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + previousOffset = controller.offset; + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + + // Scroll to the end. + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.offset, 4200.0); + final RenderParagraph paragraph99 = tester.renderObject(find.descendant(of: find.text('Item 99'), matching: find.byType(RichText))); + final RenderParagraph paragraph98 = tester.renderObject(find.descendant(of: find.text('Item 98'), matching: find.byType(RichText))); + final RenderParagraph paragraph97 = tester.renderObject(find.descendant(of: find.text('Item 97'), matching: find.byType(RichText))); + final RenderParagraph paragraph96 = tester.renderObject(find.descendant(of: find.text('Item 96'), matching: find.byType(RichText))); + expect(paragraph99.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); + expect(paragraph98.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); + expect(paragraph97.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); + expect(paragraph96.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); + await gesture.up(); + }); + + group('Complex cases', () { + testWidgets('selection starts outside of the scrollable', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: Column( + children: [ + const Text('Item 0'), + SizedBox( + height: 400, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Inner item $index'); + }, + ), + ), + const Text('Item 1'), + ], + ), + ), + )); + await tester.pumpAndSettle(); + + controller.jumpTo(1000); + await tester.pumpAndSettle(); + final RenderParagraph paragraph0 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5)); + await tester.pumpAndSettle(); + await gesture.up(); + + // The entire scrollable should be selected. + expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); + final RenderParagraph innerParagraph = tester.renderObject(find.descendant(of: find.text('Inner item 20'), matching: find.byType(RichText))); + expect(innerParagraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 13)); + // Should not scroll the inner scrollable. + expect(controller.offset, 1000.0); + }); + + testWidgets('nested scrollables keep selection alive', (WidgetTester tester) async { + final ScrollController outerController = ScrollController(); + final ScrollController innerController = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: outerController, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + if (index == 2) { + return SizedBox( + height: 700, + child: ListView.builder( + controller: innerController, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Iteminner $index'); + }, + ), + ); + } + return Text('Item $index'); + }, + ), + ), + )); + await tester.pumpAndSettle(); + + innerController.jumpTo(1000); + await tester.pumpAndSettle(); + RenderParagraph innerParagraph23 = tester.renderObject(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(innerParagraph23, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + RenderParagraph innerParagraph24 = tester.renderObject(find.descendant(of: find.text('Iteminner 24'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(innerParagraph24, 2) + const Offset(0, 5)); + await tester.pumpAndSettle(); + await gesture.up(); + expect(innerParagraph23.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); + expect(innerParagraph24.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); + + innerController.jumpTo(2000); + await tester.pumpAndSettle(); + expect(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)), findsNothing); + + outerController.jumpTo(2000); + await tester.pumpAndSettle(); + expect(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)), findsNothing); + + // Selected item is still kept alive. + expect(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText), skipOffstage: false), findsNothing); + + // Selection stays the same after scrolling back. + outerController.jumpTo(0); + await tester.pumpAndSettle(); + expect(innerController.offset, 2000.0); + innerController.jumpTo(1000); + await tester.pumpAndSettle(); + innerParagraph23 = tester.renderObject(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText))); + innerParagraph24 = tester.renderObject(find.descendant(of: find.text('Iteminner 24'), matching: find.byType(RichText))); + expect(innerParagraph23.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); + expect(innerParagraph24.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); + }); + + testWidgets('can copy off screen selection - Apple', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + focusNode: focusNode, + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + final RenderParagraph paragraph0 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5)); + await tester.pumpAndSettle(); + await gesture.up(); + expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); + + // Scroll the selected text out off the screen. + controller.jumpTo(1000); + await tester.pumpAndSettle(); + expect(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), findsNothing); + expect(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), findsNothing); + + // Start copying. + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + + final Map clipboardData = mockClipboard.clipboardData as Map; + expect(clipboardData['text'], 'em 0It'); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('can copy off screen selection - non-Apple', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + focusNode: focusNode, + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + final RenderParagraph paragraph0 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5)); + await tester.pumpAndSettle(); + await gesture.up(); + expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); + + // Scroll the selected text out off the screen. + controller.jumpTo(1000); + await tester.pumpAndSettle(); + expect(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), findsNothing); + expect(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), findsNothing); + + // Start copying. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + + final Map clipboardData = mockClipboard.clipboardData as Map; + expect(clipboardData['text'], 'em 0It'); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia })); + }); +} diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart new file mode 100644 index 000000000000..80e679159286 --- /dev/null +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -0,0 +1,1135 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; + +Offset textOffsetToPosition(RenderParagraph paragraph, int offset) { + const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0); + final Offset localOffset = paragraph.getOffsetForCaret(TextPosition(offset: offset), caret); + return paragraph.localToGlobal(localOffset); +} + +Offset globalize(Offset point, RenderBox box) { + return box.localToGlobal(point); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final MockClipboard mockClipboard = MockClipboard(); + + setUp(() async { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall); + await Clipboard.setData(const ClipboardData(text: 'empty')); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); + + group('SelectionArea', () { + testWidgets('mouse selection sends correct events', (WidgetTester tester) async { + final UniqueKey spy = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: SelectionSpy(key: spy), + ), + ) + ); + + final RenderSelectionSpy renderSelectionSpy = tester.renderObject(find.byKey(spy)); + final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + renderSelectionSpy.events.clear(); + + await gesture.moveTo(const Offset(200.0, 100.0)); + expect(renderSelectionSpy.events.length, 2); + expect(renderSelectionSpy.events[0].type, SelectionEventType.startEdgeUpdate); + final SelectionEdgeUpdateEvent startEdge = renderSelectionSpy.events[0] as SelectionEdgeUpdateEvent; + expect(startEdge.globalPosition, const Offset(200.0, 200.0)); + expect(renderSelectionSpy.events[1].type, SelectionEventType.endEdgeUpdate); + SelectionEdgeUpdateEvent endEdge = renderSelectionSpy.events[1] as SelectionEdgeUpdateEvent; + expect(endEdge.globalPosition, const Offset(200.0, 100.0)); + renderSelectionSpy.events.clear(); + + await gesture.moveTo(const Offset(100.0, 100.0)); + expect(renderSelectionSpy.events.length, 1); + expect(renderSelectionSpy.events[0].type, SelectionEventType.endEdgeUpdate); + endEdge = renderSelectionSpy.events[0] as SelectionEdgeUpdateEvent; + expect(endEdge.globalPosition, const Offset(100.0, 100.0)); + + await gesture.up(); + }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410. + + testWidgets('touch does not accept drag', (WidgetTester tester) async { + final UniqueKey spy = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: SelectionSpy(key: spy), + ), + ) + ); + + final RenderSelectionSpy renderSelectionSpy = tester.renderObject(find.byKey(spy)); + final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0)); + addTearDown(gesture.removePointer); + await gesture.moveTo(const Offset(200.0, 100.0)); + await gesture.up(); + expect( + renderSelectionSpy.events.every((SelectionEvent element) => element is ClearSelectionEvent), + isTrue + ); + }); + + testWidgets('mouse selection always cancels previous selection', (WidgetTester tester) async { + final UniqueKey spy = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: SelectionSpy(key: spy), + ), + ) + ); + + final RenderSelectionSpy renderSelectionSpy = tester.renderObject(find.byKey(spy)); + final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + expect(renderSelectionSpy.events.length, 1); + expect(renderSelectionSpy.events[0], isA()); + }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410. + + testWidgets('touch long press sends select-word event', (WidgetTester tester) async { + final UniqueKey spy = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: SelectionSpy(key: spy), + ), + ) + ); + + final RenderSelectionSpy renderSelectionSpy = tester.renderObject(find.byKey(spy)); + renderSelectionSpy.events.clear(); + final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0)); + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + expect(renderSelectionSpy.events.length, 1); + expect(renderSelectionSpy.events[0], isA()); + final SelectWordSelectionEvent selectionEvent = renderSelectionSpy.events[0] as SelectWordSelectionEvent; + expect(selectionEvent.globalPosition, const Offset(200.0, 200.0)); + }); + + testWidgets('touch long press and drag sends correct events', (WidgetTester tester) async { + final UniqueKey spy = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: SelectionSpy(key: spy), + ), + ) + ); + + final RenderSelectionSpy renderSelectionSpy = tester.renderObject(find.byKey(spy)); + renderSelectionSpy.events.clear(); + final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0)); + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + expect(renderSelectionSpy.events.length, 1); + expect(renderSelectionSpy.events[0], isA()); + final SelectWordSelectionEvent selectionEvent = renderSelectionSpy.events[0] as SelectWordSelectionEvent; + expect(selectionEvent.globalPosition, const Offset(200.0, 200.0)); + + renderSelectionSpy.events.clear(); + await gesture.moveTo(const Offset(200.0, 50.0)); + await gesture.up(); + expect(renderSelectionSpy.events.length, 1); + expect(renderSelectionSpy.events[0].type, SelectionEventType.endEdgeUpdate); + final SelectionEdgeUpdateEvent edgeEvent = renderSelectionSpy.events[0] as SelectionEdgeUpdateEvent; + expect(edgeEvent.globalPosition, const Offset(200.0, 50.0)); + }); + + testWidgets('mouse long press does not send select-word event', (WidgetTester tester) async { + final UniqueKey spy = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: SelectionSpy(key: spy), + ), + ), + ); + + final RenderSelectionSpy renderSelectionSpy = tester.renderObject(find.byKey(spy)); + renderSelectionSpy.events.clear(); + final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + expect( + renderSelectionSpy.events.every((SelectionEvent element) => element is ClearSelectionEvent), + isTrue, + ); + }); + }); + + group('SelectionArea integration', () { + testWidgets('mouse can select single text', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: const Center( + child: Text('How are you'), + ), + ), + ), + ); + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('How are you'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph, 4)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + await gesture.moveTo(textOffsetToPosition(paragraph, 6)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); + + // Check backward selection. + await gesture.moveTo(textOffsetToPosition(paragraph, 1)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 2, extentOffset: 1)); + + // Start a new drag. + await gesture.up(); + await gesture.down(textOffsetToPosition(paragraph, 5)); + expect(paragraph.selections.isEmpty, isTrue); + + // Selecting across line should select to the end. + await gesture.moveTo(textOffsetToPosition(paragraph, 5) + const Offset(0.0, 200.0)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 5, extentOffset: 11)); + + await gesture.up(); + }); + + testWidgets('mouse can select multiple widgets', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); + await tester.pump(); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5)); + // Should select the rest of paragraph 1. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph3, 6)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + + await gesture.up(); + }); + + testWidgets('mouse can work with disabled container', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + SelectionContainer.disabled(child: Text('Good, and you?')), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); + await tester.pump(); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5)); + // Should select the rest of paragraph 1. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); + // paragraph2 is in a disabled container. + expect(paragraph2.selections.isEmpty, isTrue); + + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph3, 6)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); + expect(paragraph2.selections.isEmpty, isTrue); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + + await gesture.up(); + }); + + testWidgets('mouse can reverse selection', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 10), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph3, 4)); + await tester.pump(); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 10, extentOffset: 4)); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 10, extentOffset: 0)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 14, extentOffset: 5)); + + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph1, 6)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 10, extentOffset: 0)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 14, extentOffset: 0)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 12, extentOffset: 6)); + + await gesture.up(); + }); + + testWidgets('can copy a selection made with the mouse', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + // Select from offset 2 of paragraph 1 to offset 6 of paragraph3. + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph3, 6)); + await gesture.up(); + + // keyboard copy. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + + final Map clipboardData = mockClipboard.clipboardData as Map; + expect(clipboardData['text'], 'w are you?Good, and you?Fine, '); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia })); + + testWidgets( + 'does not override TextField keyboard shortcuts if the TextField is focused - non apple', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'I am fine, thank you.'); + final FocusNode selectableRegionFocus = FocusNode(); + final FocusNode textFieldFocus = FocusNode(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SelectableRegion( + focusNode: selectableRegionFocus, + selectionControls: materialTextSelectionControls, + child: Column( + children: [ + const Text('How are you?'), + const Text('Good, and you?'), + TextField(controller: controller, focusNode: textFieldFocus), + ], + ), + ), + ), + ), + ); + textFieldFocus.requestFocus(); + await tester.pump(); + + // Make sure keyboard select all works on TextField. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 21)); + + // Make sure no selection in SelectableRegion. + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + expect(paragraph1.selections.isEmpty, isTrue); + expect(paragraph2.selections.isEmpty, isTrue); + + // Reset selection and focus selectable region. + controller.selection = const TextSelection.collapsed(offset: -1); + selectableRegionFocus.requestFocus(); + await tester.pump(); + + // Make sure keyboard select all will be handled by selectable region now. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(controller.selection, const TextSelection.collapsed(offset: -1)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12)); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }), + skip: kIsWeb, // [intended] the web handles this on its own. + ); + + testWidgets( + 'does not override TextField keyboard shortcuts if the TextField is focused - apple', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'I am fine, thank you.'); + final FocusNode selectableRegionFocus = FocusNode(); + final FocusNode textFieldFocus = FocusNode(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SelectableRegion( + focusNode: selectableRegionFocus, + selectionControls: materialTextSelectionControls, + child: Column( + children: [ + const Text('How are you?'), + const Text('Good, and you?'), + TextField(controller: controller, focusNode: textFieldFocus), + ], + ), + ), + ), + ), + ); + textFieldFocus.requestFocus(); + await tester.pump(); + + // Make sure keyboard select all works on TextField. + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 21)); + + // Make sure no selection in SelectableRegion. + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + expect(paragraph1.selections.isEmpty, isTrue); + expect(paragraph2.selections.isEmpty, isTrue); + + // Reset selection and focus selectable region. + controller.selection = const TextSelection.collapsed(offset: -1); + selectableRegionFocus.requestFocus(); + await tester.pump(); + + // Make sure keyboard select all will be handled by selectable region now. + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + expect(controller.selection, const TextSelection.collapsed(offset: -1)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12)); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + skip: kIsWeb, // [intended] the web handles this on its own. + ); + + testWidgets('select all', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + focusNode.requestFocus(); + + // keyboard select all. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 16)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12)); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia })); + + testWidgets( + 'mouse selection can handle widget span', (WidgetTester tester) async { + final UniqueKey outerText = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Center( + child: Text.rich( + const TextSpan( + children: [ + TextSpan(text: 'How are you?'), + WidgetSpan(child: Text('Good, and you?')), + TextSpan(text: 'Fine, thank you.'), + ] + ), + key: outerText, + ), + ), + ), + ), + ); + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(textOffsetToPosition(paragraph, 17)); // right after `Fine`. + await gesture.up(); + + // keyboard copy. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + final Map clipboardData = mockClipboard.clipboardData as Map; + expect(clipboardData['text'], 'w are you?Good, and you?Fine'); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }), + skip: isBrowser, // https://github.com/flutter/flutter/issues/61020 + ); + + testWidgets( + 'widget span is ignored if it does not contain text - non Apple', + (WidgetTester tester) async { + final UniqueKey outerText = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Center( + child: Text.rich( + const TextSpan( + children: [ + TextSpan(text: 'How are you?'), + WidgetSpan(child: Placeholder()), + TextSpan(text: 'Fine, thank you.'), + ] + ), + key: outerText, + ), + ), + ), + ), + ); + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(textOffsetToPosition(paragraph, 17)); // right after `Fine`. + await gesture.up(); + + // keyboard copy. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + final Map clipboardData = mockClipboard.clipboardData as Map; + expect(clipboardData['text'], 'w are you?Fine'); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }), + skip: isBrowser, // https://github.com/flutter/flutter/issues/61020 + ); + + testWidgets( + 'widget span is ignored if it does not contain text - Apple', + (WidgetTester tester) async { + final UniqueKey outerText = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Center( + child: Text.rich( + const TextSpan( + children: [ + TextSpan(text: 'How are you?'), + WidgetSpan(child: Placeholder()), + TextSpan(text: 'Fine, thank you.'), + ] + ), + key: outerText, + ), + ), + ), + ), + ); + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(textOffsetToPosition(paragraph, 17)); // right after `Fine`. + await gesture.up(); + + // keyboard copy. + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + final Map clipboardData = mockClipboard.clipboardData as Map; + expect(clipboardData['text'], 'w are you?Fine'); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + skip: isBrowser, // https://github.com/flutter/flutter/issues/61020 + ); + + testWidgets('mouse can select across bidi text', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('جيد وانت؟', textDirection: TextDirection.rtl), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); + await tester.pump(); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('جيد وانت؟'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5)); + // Should select the rest of paragraph 1. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + // Add a little offset to cross the boundary between paragraph 2 and 3. + await gesture.moveTo(textOffsetToPosition(paragraph3, 6) + const Offset(0, 1)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 8)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + + await gesture.up(); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020 + + testWidgets('long press and drag touch selection', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + // `are` is selected. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 12)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + await gesture.up(); + }); + + testWidgets('can drag end selection handle', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + final List boxes = paragraph1.getBoxesForSelection(paragraph1.selections[0]); + expect(boxes.length, 1); + + final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph1); + await gesture.down(handlePos); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + Offset(0, paragraph2.size.height / 2)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 12)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph3, 6) + Offset(0, paragraph3.size.height / 2)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 12)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + await gesture.up(); + }); + + testWidgets('can drag start selection handle', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 7)); // at the 'h' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11)); + final List boxes = paragraph3.getBoxesForSelection(paragraph3.selections[0]); + expect(boxes.length, 1); + + final Offset handlePos = globalize(boxes[0].toRect().bottomLeft, paragraph3); + await gesture.down(handlePos); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + Offset(0, paragraph2.size.height / 2)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 5, extentOffset: 14)); + + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + await gesture.moveTo(textOffsetToPosition(paragraph1, 6) + Offset(0, paragraph1.size.height / 2)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 6, extentOffset: 12)); + await gesture.up(); + }); + + testWidgets('can drag start selection handle across end selection handle', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 7)); // at the 'h' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11)); + final List boxes = paragraph3.getBoxesForSelection(paragraph3.selections[0]); + expect(boxes.length, 1); + + final Offset handlePos = globalize(boxes[0].toRect().bottomLeft, paragraph3); + await gesture.down(handlePos); + await gesture.moveTo(textOffsetToPosition(paragraph3, 14) + Offset(0, paragraph3.size.height / 2)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 14, extentOffset: 11)); + + await gesture.moveTo(textOffsetToPosition(paragraph3, 4) + Offset(0, paragraph3.size.height / 2)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 4, extentOffset: 11)); + await gesture.up(); + }); + + testWidgets('can drag end selection handle across start selection handle', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 7)); // at the 'h' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11)); + final List boxes = paragraph3.getBoxesForSelection(paragraph3.selections[0]); + expect(boxes.length, 1); + + final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph3); + await gesture.down(handlePos); + await gesture.moveTo(textOffsetToPosition(paragraph3, 4) + Offset(0, paragraph3.size.height / 2)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 4)); + + await gesture.moveTo(textOffsetToPosition(paragraph3, 12) + Offset(0, paragraph3.size.height / 2)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 12)); + await gesture.up(); + }); + + testWidgets('can select all from toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 7)); // at the 'h' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11)); + expect(find.text('Select all'), findsOneWidget); + + await tester.tap(find.text('Select all')); + await tester.pump(); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 16)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14)); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12)); + }, skip: kIsWeb); // [intended] Web uses its native context menu. + + testWidgets('can copy from toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: Column( + children: const [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 7)); // at the 'h' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11)); + expect(find.text('Copy'), findsOneWidget); + + await tester.tap(find.text('Copy')); + await tester.pump(); + + // Selection should be cleared. + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + expect(paragraph3.selections.isEmpty, isTrue); + expect(paragraph2.selections.isEmpty, isTrue); + expect(paragraph1.selections.isEmpty, isTrue); + + final Map clipboardData = mockClipboard.clipboardData as Map; + expect(clipboardData['text'], 'thank'); + }, skip: kIsWeb); // [intended] Web uses its native context menu. + }); +} + +class SelectionSpy extends LeafRenderObjectWidget { + const SelectionSpy({ + super.key, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderSelectionSpy( + SelectionContainer.maybeOf(context), + ); + } + + @override + void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { } +} + +class RenderSelectionSpy extends RenderProxyBox + with Selectable, SelectionRegistrant { + RenderSelectionSpy( + SelectionRegistrar? registrar, + ) { + this.registrar = registrar; + } + + final Set listeners = {}; + List events = []; + + @override + void addListener(VoidCallback listener) => listeners.add(listener); + + @override + void removeListener(VoidCallback listener) => listeners.remove(listener); + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + events.add(event); + return SelectionResult.end; + } + + @override + SelectedContent? getSelectedContent() { + return const SelectedContent(plainText: 'content'); + } + + @override + SelectionGeometry get value => _value; + SelectionGeometry _value = SelectionGeometry( + hasContent: true, + status: SelectionStatus.uncollapsed, + startSelectionPoint: const SelectionPoint( + localPosition: Offset.zero, + lineHeight: 0.0, + handleType: TextSelectionHandleType.left, + ), + endSelectionPoint: const SelectionPoint( + localPosition: Offset.zero, + lineHeight: 0.0, + handleType: TextSelectionHandleType.left, + ), + ); + set value(SelectionGeometry other) { + if (other == _value) + return; + _value = other; + for (final VoidCallback callback in listeners) { + callback(); + } + } + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { } +} + + +class TextTextSelectionControls extends TextSelectionControls { + static final UniqueKey leftHandle = UniqueKey(); + static final UniqueKey rightHandle = UniqueKey(); + static final UniqueKey toolbar = UniqueKey(); + + @override + Size getHandleSize(double textLineHeight) => Size(textLineHeight, textLineHeight); + + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List endpoints, + TextSelectionDelegate delegate, + ClipboardStatusNotifier? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + return TestToolbar( + key: toolbar, + globalEditableRegion: globalEditableRegion, + textLineHeight: textLineHeight, + selectionMidpoint: selectionMidpoint, + endpoints: endpoints, + delegate: delegate, + clipboardStatus: clipboardStatus, + lastSecondaryTapDownPosition: lastSecondaryTapDownPosition, + ); + } + + @override + Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight, [VoidCallback? onTap]) { + return TestHandle( + key: type == TextSelectionHandleType.left ? leftHandle : rightHandle, + type: type, + textHeight: textHeight, + ); + } + + @override + Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { + return Offset.zero; + } + + @override + bool canSelectAll(TextSelectionDelegate delegate) => true; +} + +class TestHandle extends StatelessWidget { + const TestHandle({ + super.key, + required this.type, + required this.textHeight, + }); + + final TextSelectionHandleType type; + final double textHeight; + + @override + Widget build(BuildContext context) { + return SizedBox(width: textHeight, height: textHeight); + } +} + +class TestToolbar extends StatelessWidget { + const TestToolbar({ + super.key, + required this.globalEditableRegion, + required this.textLineHeight, + required this.selectionMidpoint, + required this.endpoints, + required this.delegate, + required this.clipboardStatus, + required this.lastSecondaryTapDownPosition, + }); + + final Rect globalEditableRegion; + final double textLineHeight; + final Offset selectionMidpoint; + final List endpoints; + final TextSelectionDelegate delegate; + final ClipboardStatusNotifier? clipboardStatus; + final Offset? lastSecondaryTapDownPosition; + + @override + Widget build(BuildContext context) { + return SizedBox(width: textLineHeight, height: textLineHeight); + } +} diff --git a/packages/flutter/test/widgets/selection_container_test.dart b/packages/flutter/test/widgets/selection_container_test.dart new file mode 100644 index 000000000000..c4992bfc6340 --- /dev/null +++ b/packages/flutter/test/widgets/selection_container_test.dart @@ -0,0 +1,146 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + + Future pumpContainer(WidgetTester tester, Widget child) async { + await tester.pumpWidget( + DefaultSelectionStyle( + selectionColor: Colors.red, + child: child, + ), + ); + } + + testWidgets('updates its registrar and delegate based on the number of selectables', (WidgetTester tester) async { + final TestSelectionRegistrar registrar = TestSelectionRegistrar(); + final TestContainerDelegate delegate = TestContainerDelegate(); + await pumpContainer( + tester, + SelectionContainer( + registrar: registrar, + delegate: delegate, + child: Column( + children: const [ + Text('column1', textDirection: TextDirection.ltr), + Text('column2', textDirection: TextDirection.ltr), + Text('column3', textDirection: TextDirection.ltr), + ], + ), + ), + ); + expect(registrar.selectables.length, 1); + expect(delegate.selectables.length, 3); + }); + + testWidgets('disabled container', (WidgetTester tester) async { + final TestSelectionRegistrar registrar = TestSelectionRegistrar(); + final TestContainerDelegate delegate = TestContainerDelegate(); + await pumpContainer( + tester, + SelectionContainer( + registrar: registrar, + delegate: delegate, + child: SelectionContainer.disabled( + child: Column( + children: const [ + Text('column1', textDirection: TextDirection.ltr), + Text('column2', textDirection: TextDirection.ltr), + Text('column3', textDirection: TextDirection.ltr), + ], + ), + ), + ), + ); + expect(registrar.selectables.length, 0); + expect(delegate.selectables.length, 0); + }); + + testWidgets('selection container registers itself if there is a selectable child', (WidgetTester tester) async { + final TestSelectionRegistrar registrar = TestSelectionRegistrar(); + final TestContainerDelegate delegate = TestContainerDelegate(); + await pumpContainer( + tester, + SelectionContainer( + registrar: registrar, + delegate: delegate, + child: Column( + ), + ), + ); + expect(registrar.selectables.length, 0); + + await pumpContainer( + tester, + SelectionContainer( + registrar: registrar, + delegate: delegate, + child: Column( + children: const [ + Text('column1', textDirection: TextDirection.ltr), + ], + ), + ), + ); + expect(registrar.selectables.length, 1); + + await pumpContainer( + tester, + SelectionContainer( + registrar: registrar, + delegate: delegate, + child: Column( + ), + ), + ); + expect(registrar.selectables.length, 0); + }); + + testWidgets('selection container gets registrar from context if not provided', (WidgetTester tester) async { + final TestSelectionRegistrar registrar = TestSelectionRegistrar(); + final TestContainerDelegate delegate = TestContainerDelegate(); + + await pumpContainer( + tester, + SelectionRegistrarScope( + registrar: registrar, + child: SelectionContainer( + delegate: delegate, + child: Column( + children: const [ + Text('column1', textDirection: TextDirection.ltr), + ], + ), + ), + ), + ); + expect(registrar.selectables.length, 1); + }); +} + +class TestContainerDelegate extends MultiSelectableSelectionContainerDelegate { + @override + SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) { + throw UnimplementedError(); + } + + @override + void ensureChildUpdated(Selectable selectable) { + throw UnimplementedError(); + } +} + +class TestSelectionRegistrar extends SelectionRegistrar { + final Set selectables = {}; + + @override + void add(Selectable selectable) => selectables.add(selectable); + + @override + void remove(Selectable selectable) => selectables.remove(selectable); +}