Unverified Commit efb93685 authored by chunhtai's avatar chunhtai Committed by GitHub

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
parent bd7d34f0
// 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 <Widget>[
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<StatefulWidget> createState() => _SelectionAllOrNoneContainerState();
}
class _SelectionAllOrNoneContainerState extends State<SelectionAllOrNoneContainer> {
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);
}
}
// 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 <Widget>[
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<SelectionGeometry>(_noSelection) {
this.registrar = registrar;
_geometry.addListener(markNeedsPaint);
}
static const SelectionGeometry _noSelection = SelectionGeometry(status: SelectionStatus.none, hasContent: true);
final ValueNotifier<SelectionGeometry> _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();
}
}
// 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 <Widget>[
Text('Selectable text'),
SelectionContainer.disabled(child: Text('Non-selectable text')),
Text('Selectable text'),
],
),
),
),
),
);
}
}
// 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 <Widget>[
Text('Row 1'),
Text('Row 2'),
Text('Row 3'),
],
),
),
),
),
);
}
}
......@@ -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';
......
......@@ -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';
......
// 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<StatefulWidget> createState() => _SelectionAreaState();
}
class _SelectionAreaState extends State<SelectionArea> {
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,
);
}
}
......@@ -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
......
......@@ -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);
......
......@@ -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<RenderBox>? 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<InlineSpanSemanticsInformation>? _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<TextSelection> get selections {
if (_lastSelectableFragments == null)
return const <TextSelection>[];
final List<TextSelection> results = <TextSelection>[];
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<PlaceholderSpan> _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<TextBox> 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,
);
}
}
}
// 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<SelectionGeometry> {
/// 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 &lt;quick brown&gt; fox':
///
/// The '&lt;' is drawn with the [left] type, the '&gt;' with the [right]
///
/// * RTL text: 'XOF &lt;NWORB KCIUQ&gt; EHT':
///
/// Same as above.
///
/// * mixed text: '&lt;the NWOR&lt;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,
}
......@@ -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.
......
......@@ -42,6 +42,8 @@ class AutomaticKeepAlive extends StatefulWidget {
class _AutomaticKeepAliveState extends State<AutomaticKeepAlive> {
Map<Listenable, VoidCallback>? _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;
......
......@@ -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
......
......@@ -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<SliverReorderableList> 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<SliverReorderableList> 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<SliverReorderableList> 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 = <distance of overscroll> * [_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<void> _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,
......
......@@ -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<Scrollable> 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<Scrollable> 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 = <distance of overscroll> * [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 = <distance of overscroll> * 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<void> _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<Selectable, double> _selectableStartEdgeUpdateRecords = <Selectable, double>{};
final Map<Selectable, double> _selectableEndEdgeUpdateRecords = <Selectable, double>{};
@override
void didChangeSelectables() {
final Set<Selectable> 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.
///
......
// 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<PointerDeviceKind> _kLongPressSelectionDevices = <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
};
/// A widget that introduces an area for user selections.
///
/// Flutter widgets are not selectable by default. 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 <Widget>[
/// Text('Item 0', style: TextStyle(fontSize: 50.0)),
/// Text('Item 1', style: TextStyle(fontSize: 50.0)),
/// ],
/// ),
/// ),
/// ),
/// )
/// ```
/// {@end-tool}
///
/// ```
///
/// SelectionContainer
/// (SelectableRegion)
/// / \
/// / \
/// / \
/// Selectable \
/// ("Flutter Code Sample") \
/// \
/// SelectionContainer
/// (ListView)
/// / \
/// / \
/// / \
/// Selectable Selectable
/// ("Item 0") ("Item 1")
///
///```
///
/// ## Making a widget selectable
///
/// Some leaf widgets, such as [Text], have all of the selection logic wired up
/// automatically and can be selected as long as they are under a
/// [SelectableRegion].
///
/// To make a custom selectable widget, its render object needs to mix in
/// [Selectable] and implement the required APIs to handle [SelectionEvent]s
/// as well as paint appropriate selection highlights.
///
/// The render object also needs to register itself to a [SelectionRegistrar].
/// For the most cases, one can use [SelectionRegistrant] to auto-register
/// itself with the register returned from [SelectionContainer.maybeOf] as
/// seen in the example below.
///
/// {@tool dartpad}
/// This sample demonstrates how to create an adapter widget that makes any
/// child widget selectable.
///
/// ** See code in examples/api/lib/material/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<StatefulWidget> createState() => _SelectableRegionState();
}
class _SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar {
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
};
final Map<Type, GestureRecognizerFactory> _gestureRecognizers = <Type, GestureRecognizerFactory>{};
SelectionOverlay? _selectionOverlay;
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
final LayerLink _toolbarLayerLink = LayerLink();
final _SelectableRegionContainerDelegate _selectionDelegate = _SelectableRegionContainerDelegate();
// there should only ever be one selectable, which is the SelectionContainer.
Selectable? _selectable;
bool get _hasSelectionOverlayGeometry => _selectionDelegate.value.startSelectionPoint != null
|| _selectionDelegate.value.endSelectionPoint != null;
@override
void initState() {
super.initState();
widget.focusNode.addListener(_handleFocusChanged);
_initMouseGestureRecognizer();
_initTouchGestureRecognizer();
// Taps and right clicks.
_gestureRecognizers[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance.onTap = _clearSelection;
instance.onSecondaryTapDown = _handleRightClickDown;
},
);
}
@override
void didUpdateWidget(SelectableRegion oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged);
widget.focusNode.addListener(_handleFocusChanged);
if (widget.focusNode.hasFocus != oldWidget.focusNode.hasFocus)
_handleFocusChanged();
}
}
Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
return Action<T>.overridable(context: context, defaultAction: defaultAction);
}
void _handleFocusChanged() {
if (!widget.focusNode.hasFocus) {
_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>(
() => PanGestureRecognizer(debugOwner:this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }),
(PanGestureRecognizer instance) {
instance
..onDown = _startNewMouseSelectionGesture
..onStart = _handleMouseDragStart
..onUpdate = _handleMouseDragUpdate
..onEnd = _handleMouseDragEnd
..onCancel = _clearSelection
..dragStartBehavior = DragStartBehavior.down;
},
);
}
void _initTouchGestureRecognizer() {
_gestureRecognizers[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(debugOwner: this, supportedDevices: _kLongPressSelectionDevices),
(LongPressGestureRecognizer instance) {
instance
..onLongPressStart = _handleTouchLongPressStart
..onLongPressMoveUpdate = _handleTouchLongPressMoveUpdate
..onLongPressEnd = _handleTouchLongPressEnd
..onLongPressCancel = _clearSelection;
},
);
}
void _startNewMouseSelectionGesture(DragDownDetails details) {
widget.focusNode.requestFocus();
hideToolbar();
_clearSelection();
}
void _handleMouseDragStart(DragStartDetails details) {
_selectStartTo(offset: details.globalPosition);
}
void _handleMouseDragUpdate(DragUpdateDetails details) {
_selectEndTo(offset: details.globalPosition, continuous: true);
}
void _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<TextSelectionPoint> points;
final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
if (startLocalPosition.dy > endLocalPosition.dy) {
points = <TextSelectionPoint>[
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
];
} else {
points = <TextSelectionPoint>[
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
];
}
_selectionOverlay = 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<TextSelectionPoint> points;
final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
if (startLocalPosition.dy > endLocalPosition.dy) {
points = <TextSelectionPoint>[
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
];
} else {
points = <TextSelectionPoint>[
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
];
}
_selectionOverlay!
..startHandleType = start?.handleType ?? TextSelectionHandleType.left
..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight
..endHandleType = end?.handleType ?? TextSelectionHandleType.right
..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight
..selectionEndPoints = points;
}
/// Shows the selection handles.
///
/// Returns true if the handles are shown, false if the handles can't be
/// shown.
bool _showHandles() {
if (_selectionOverlay != null) {
_selectionOverlay!.showHandles();
return true;
}
if (!_hasSelectionOverlayGeometry)
return false;
_createSelectionOverlay();
_selectionOverlay!.showHandles();
return true;
}
/// Shows the text selection toolbar.
///
/// If the parameter `location` is set, the toolbar will be shown at the
/// location. Otherwise, the toolbar location will be calculated based on the
/// handles' locations. The `location` is in the coordinates system of the
/// [Overlay].
///
/// Returns true if the toolbar is shown, false if the toolbar can't be shown.
bool _showToolbar({Offset? location}) {
if (!_hasSelectionOverlayGeometry && _selectionOverlay == null)
return false;
// Web is using native dom elements to enable clipboard functionality of the
// toolbar: copy, paste, select, cut. It might also provide additional
// functionality depending on the browser (such as translate). Due to this
// we should not show a Flutter toolbar for the editable text elements.
if (kIsWeb)
return false;
if (_selectionOverlay == null)
_createSelectionOverlay();
_selectionOverlay!.toolbarLocation = location;
_selectionOverlay!.showToolbar();
return true;
}
/// Sets or updates selection end edge to the `offset` location.
///
/// A selection always contains a select start edge and selection end edge.
/// They can be created by calling both [_selectStartTo] and [_selectEndTo], or
/// use other selection APIs, such as [_selectWordAt] or [selectAll].
///
/// This method sets or updates the selection end edge by sending
/// [SelectionEdgeUpdateEvent]s to the child [Selectable]s.
///
/// If `continuous` is set to true and the update causes scrolling, the
/// method will continue sending the same [SelectionEdgeUpdateEvent]s to the
/// child [Selectable]s every frame until the scrolling finishes or a
/// [_finalizeSelection] is called.
///
/// The `continuous` argument defaults to false.
///
/// The `offset` is in global coordinates.
///
/// See also:
/// * [_selectStartTo], which sets or updates selection start edge.
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location.
/// * [selectAll], which selects the entire content.
void _selectEndTo({required Offset offset, bool continuous = false}) {
if (!continuous) {
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: offset));
return;
}
if (_selectionEndPosition != offset) {
_selectionEndPosition = offset;
_triggerSelectionEndEdgeUpdate();
}
}
/// Sets or updates selection start edge to the `offset` location.
///
/// A selection always contains a select start edge and selection end edge.
/// They can be created by calling both [_selectStartTo] and [_selectEndTo], or
/// use other selection APIs, such as [_selectWordAt] or [selectAll].
///
/// This method sets or updates the selection start edge by sending
/// [SelectionEdgeUpdateEvent]s to the child [Selectable]s.
///
/// If `continuous` is set to true and the update causes scrolling, the
/// method will continue sending the same [SelectionEdgeUpdateEvent]s to the
/// child [Selectable]s every frame until the scrolling finishes or a
/// [_finalizeSelection] is called.
///
/// The `continuous` argument defaults to false.
///
/// The `offset` is in global coordinates.
///
/// See also:
/// * [_selectEndTo], which sets or updates selection end edge.
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location.
/// * [selectAll], which selects the entire content.
void _selectStartTo({required Offset offset, bool continuous = false}) {
if (!continuous) {
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: offset));
return;
}
if (_selectionStartPosition != offset) {
_selectionStartPosition = offset;
_triggerSelectionStartEdgeUpdate();
}
}
/// Selects a whole word at the `offset` location.
///
/// If the whole word is already in the current selection, selection won't
/// change. One call [_clearSelection] first if the selection needs to be
/// updated even if the word is already covered by the current selection.
///
/// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection
/// edges after calling this method.
///
/// See also:
/// * [_selectStartTo], which sets or updates selection start edge.
/// * [_selectEndTo], which sets or updates selection end edge.
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection.
/// * [selectAll], which selects the entire content.
void _selectWordAt({required Offset offset}) {
// There may be other selection ongoing.
_finalizeSelection();
_selectable?.dispatchSelectionEvent(SelectWordSelectionEvent(globalPosition: offset));
}
/// Stops any ongoing selection updates.
///
/// This method is different from [_clearSelection] that it does not remove
/// the current selection. It only stops the continuous updates.
///
/// A continuous update can happen as result of calling [_selectStartTo] or
/// [_selectEndTo] with `continuous` sets to true which causes a [Selectable]
/// to scroll. Calling this method will stop the update as well as the
/// scrolling.
void _finalizeSelection() {
_stopSelectionEndEdgeUpdate();
_stopSelectionStartEdgeUpdate();
}
/// Removes the ongoing selection.
void _clearSelection() {
_finalizeSelection();
_selectable?.dispatchSelectionEvent(const ClearSelectionEvent());
}
Future<void> _copy() async {
final SelectedContent? data = _selectable?.getSelectedContent();
if (data == null) {
return;
}
await Clipboard.setData(ClipboardData(text: data.plainText));
}
// [TextSelectionDelegate] overrides.
@override
bool get cutEnabled => false;
@override
bool get pasteEnabled => false;
@override
void hideToolbar([bool hideHandles = true]) {
_selectionOverlay?.hideToolbar();
if (hideHandles) {
_selectionOverlay?.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<void> pasteText(SelectionChangedCause cause) async {
assert(false);
}
// [SelectionRegistrar] override.
@override
void add(Selectable selectable) {
assert(_selectable == null);
_selectable = selectable;
_selectable!.addListener(_updateSelectionStatus);
_selectable!.pushHandleLayers(_startHandleLayerLink, _endHandleLayerLink);
}
@override
void remove(Selectable selectable) {
assert(_selectable == selectable);
_selectable!.removeListener(_updateSelectionStatus);
_selectable!.pushHandleLayers(null, null);
_selectable = null;
}
@override
void dispose() {
_selectable?.removeListener(_updateSelectionStatus);
_selectable?.pushHandleLayers(null, null);
_selectionDelegate.dispose();
_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<T extends Intent> extends ContextAction<T> {
Object? invokeAction(T intent, [BuildContext? context]);
@override
Object? invoke(T intent, [BuildContext? context]) {
if (callingAction != null)
return callingAction!.invoke(intent);
return invokeAction(intent, context);
}
}
class _SelectAllAction extends _NonOverrideAction<SelectAllTextIntent> {
_SelectAllAction(this.state);
final _SelectableRegionState state;
@override
void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) {
state.selectAll(SelectionChangedCause.keyboard);
}
}
class _CopySelectionAction extends _NonOverrideAction<CopySelectionTextIntent> {
_CopySelectionAction(this.state);
final _SelectableRegionState state;
@override
void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) {
state._copy();
}
}
class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContainerDelegate {
final Set<Selectable> _hasReceivedStartEvent = <Selectable>{};
final Set<Selectable> _hasReceivedEndEvent = <Selectable>{};
Offset? _lastStartEdgeUpdateGlobalPosition;
Offset? _lastEndEdgeUpdateGlobalPosition;
@override
void remove(Selectable selectable) {
_hasReceivedStartEvent.remove(selectable);
_hasReceivedEndEvent.remove(selectable);
super.remove(selectable);
}
void _updateLastEdgeEventsFromGeometries() {
if (currentSelectionStartIndex != -1) {
final Selectable start = selectables[currentSelectionStartIndex];
final Offset localStartEdge = start.value.startSelectionPoint!.localPosition +
Offset(0, - start.value.startSelectionPoint!.lineHeight / 2);
_lastStartEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(start.getTransformTo(null), localStartEdge);
}
if (currentSelectionEndIndex != -1) {
final Selectable end = selectables[currentSelectionEndIndex];
final Offset localEndEdge = end.value.endSelectionPoint!.localPosition +
Offset(0, -end.value.endSelectionPoint!.lineHeight / 2);
_lastEndEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(end.getTransformTo(null), localEndEdge);
}
}
@override
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
final SelectionResult result = super.handleSelectAll(event);
for (final Selectable selectable in selectables) {
_hasReceivedStartEvent.add(selectable);
_hasReceivedEndEvent.add(selectable);
}
// Synthesize last update event so the edge updates continue to work.
_updateLastEdgeEventsFromGeometries();
return result;
}
/// Selects a word in a selectable at the location
/// [SelectWordSelectionEvent.globalPosition].
@override
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
final SelectionResult result = super.handleSelectWord(event);
if (currentSelectionStartIndex != -1)
_hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]);
if (currentSelectionEndIndex != -1)
_hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]);
_updateLastEdgeEventsFromGeometries();
return result;
}
@override
SelectionResult handleClearSelection(ClearSelectionEvent event) {
final SelectionResult result = super.handleClearSelection(event);
_hasReceivedStartEvent.clear();
_hasReceivedEndEvent.clear();
_lastStartEdgeUpdateGlobalPosition = null;
_lastEndEdgeUpdateGlobalPosition = null;
return result;
}
@override
SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) {
if (event.type == SelectionEventType.endEdgeUpdate) {
_lastEndEdgeUpdateGlobalPosition = event.globalPosition;
} else {
_lastStartEdgeUpdateGlobalPosition = event.globalPosition;
}
return super.handleSelectionEdgeUpdate(event);
}
@override
void dispose() {
_hasReceivedStartEvent.clear();
_hasReceivedEndEvent.clear();
super.dispose();
}
@override
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
switch (event.type) {
case SelectionEventType.startEdgeUpdate:
_hasReceivedStartEvent.add(selectable);
ensureChildUpdated(selectable);
break;
case SelectionEventType.endEdgeUpdate:
_hasReceivedEndEvent.add(selectable);
ensureChildUpdated(selectable);
break;
case SelectionEventType.clear:
_hasReceivedStartEvent.remove(selectable);
_hasReceivedEndEvent.remove(selectable);
break;
case SelectionEventType.selectAll:
case SelectionEventType.selectWord:
break;
}
return super.dispatchSelectionEventToChild(selectable, event);
}
@override
void ensureChildUpdated(Selectable selectable) {
if (_lastEndEdgeUpdateGlobalPosition != null && _hasReceivedEndEvent.add(selectable)) {
final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forEnd(
globalPosition: _lastEndEdgeUpdateGlobalPosition!,
);
if (currentSelectionEndIndex == -1) {
handleSelectionEdgeUpdate(synthesizedEvent);
}
selectable.dispatchSelectionEvent(synthesizedEvent);
}
if (_lastStartEdgeUpdateGlobalPosition != null && _hasReceivedStartEvent.add(selectable)) {
final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forStart(
globalPosition: _lastStartEdgeUpdateGlobalPosition!,
);
if (currentSelectionStartIndex == -1) {
handleSelectionEdgeUpdate(synthesizedEvent);
}
selectable.dispatchSelectionEvent(synthesizedEvent);
}
}
@override
void didChangeSelectables() {
if (_lastEndEdgeUpdateGlobalPosition != null) {
handleSelectionEdgeUpdate(
SelectionEdgeUpdateEvent.forEnd(
globalPosition: _lastEndEdgeUpdateGlobalPosition!,
),
);
}
if (_lastStartEdgeUpdateGlobalPosition != null) {
handleSelectionEdgeUpdate(
SelectionEdgeUpdateEvent.forStart(
globalPosition: _lastStartEdgeUpdateGlobalPosition!,
),
);
}
final Set<Selectable> selectableSet = selectables.toSet();
_hasReceivedEndEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable));
_hasReceivedStartEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable));
super.didChangeSelectables();
}
}
/// An abstract base class for updating multiple selectable children.
///
/// This class provide basic [SelectionEvent] handling and child [Selectable]
/// updating. The subclass needs to implement [ensureChildUpdated] to ensure
/// child [Selectable] is updated properly.
///
/// This class optimize the selection update by keeping track of the
/// [Selectable]s that currently contain the selection edges.
abstract class MultiSelectableSelectionContainerDelegate extends SelectionContainerDelegate with ChangeNotifier {
/// Gets the list of selectables this delegate is managing.
List<Selectable> selectables = <Selectable>[];
/// The current selectable that contains the selection end edge.
@protected
int currentSelectionEndIndex = -1;
/// The current selectable that contains the selection start edge.
@protected
int currentSelectionStartIndex = -1;
LayerLink? _startHandleLayer;
Selectable? _startHandleLayerOwner;
LayerLink? _endHandleLayer;
Selectable? _endHandleLayerOwner;
bool _isHandlingSelectionEvent = false;
bool _scheduledSelectableUpdate = false;
bool _selectionInProgress = false;
Set<Selectable> _additions = <Selectable>{};
@override
void add(Selectable selectable) {
assert(!selectables.contains(selectable));
_additions.add(selectable);
_scheduleSelectableUpdate();
}
@override
void remove(Selectable selectable) {
if (_additions.remove(selectable)) {
return;
}
_removeSelectable(selectable);
_scheduleSelectableUpdate();
}
/// Notifies this delegate that layout of the container has changed.
void layoutDidChange() {
_updateSelectionGeometry();
}
void _scheduleSelectableUpdate() {
if (!_scheduledSelectableUpdate) {
_scheduledSelectableUpdate = true;
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!_scheduledSelectableUpdate)
return;
_scheduledSelectableUpdate = false;
_updateSelectables();
});
}
}
void _updateSelectables() {
// Remove offScreen selectable.
if (_additions.isNotEmpty) {
_flushAdditions();
}
didChangeSelectables();
}
void _flushAdditions() {
final List<Selectable> mergingSelectables = _additions.toList()..sort(compareOrder);
final List<Selectable> existingSelectables = selectables;
selectables = <Selectable>[];
int mergingIndex = 0;
int existingIndex = 0;
int selectionStartIndex = currentSelectionStartIndex;
int selectionEndIndex = currentSelectionEndIndex;
// Merge two sorted lists.
while (mergingIndex < mergingSelectables.length || existingIndex < existingSelectables.length) {
if (mergingIndex >= mergingSelectables.length ||
(existingIndex < existingSelectables.length &&
compareOrder(existingSelectables[existingIndex], mergingSelectables[mergingIndex]) < 0)) {
if (existingIndex == currentSelectionStartIndex) {
selectionStartIndex = selectables.length;
}
if (existingIndex == currentSelectionEndIndex) {
selectionEndIndex = selectables.length;
}
selectables.add(existingSelectables[existingIndex]);
existingIndex += 1;
continue;
}
// If the merging selectable falls in the selection range, their selection
// needs to be updated.
final Selectable mergingSelectable = mergingSelectables[mergingIndex];
if (existingIndex < max(currentSelectionStartIndex, currentSelectionEndIndex) &&
existingIndex > min(currentSelectionStartIndex, currentSelectionEndIndex)) {
ensureChildUpdated(mergingSelectable);
}
mergingSelectable.addListener(_handleSelectableGeometryChange);
selectables.add(mergingSelectable);
mergingIndex += 1;
}
assert(mergingIndex == mergingSelectables.length &&
existingIndex == existingSelectables.length &&
selectables.length == existingIndex + mergingIndex);
assert(selectionStartIndex >= -1 || selectionStartIndex < selectables.length);
assert(selectionEndIndex >= -1 || selectionEndIndex < selectables.length);
// selection indices should not be set to -1 unless they originally were.
assert((currentSelectionStartIndex == -1) == (selectionStartIndex == -1));
assert((currentSelectionEndIndex == -1) == (selectionEndIndex == -1));
currentSelectionEndIndex = selectionEndIndex;
currentSelectionStartIndex = selectionStartIndex;
_additions = <Selectable>{};
}
void _removeSelectable(Selectable selectable) {
assert(selectables.contains(selectable), 'The selectable is not in this registrar.');
final int index = selectables.indexOf(selectable);
selectables.removeAt(index);
if (index <= currentSelectionEndIndex) {
currentSelectionEndIndex -= 1;
}
if (index <= currentSelectionStartIndex) {
currentSelectionStartIndex -= 1;
}
selectable.removeListener(_handleSelectableGeometryChange);
}
/// Called when this delegate finishes updating the selectables.
@protected
@mustCallSuper
void didChangeSelectables() {
_updateSelectionGeometry();
}
@override
SelectionGeometry get value => _selectionGeometry;
SelectionGeometry _selectionGeometry = const SelectionGeometry(
hasContent: false,
status: SelectionStatus.none,
);
/// Updates the [value] in this class and notifies listeners if necessary.
void _updateSelectionGeometry() {
final SelectionGeometry newValue = getSelectionGeometry();
if (_selectionGeometry != newValue) {
_selectionGeometry = newValue;
notifyListeners();
}
_updateHandleLayersAndOwners();
}
/// The compare function this delegate used for determining the selection
/// order of the selectables.
///
/// Defaults to screen order.
@protected
Comparator<Selectable> get compareOrder => _compareScreenOrder;
int _compareScreenOrder(Selectable a, Selectable b) {
final Rect rectA = MatrixUtils.transformRect(
a.getTransformTo(null),
Rect.fromLTWH(0, 0, a.size.width, a.size.height),
);
final Rect rectB = MatrixUtils.transformRect(
b.getTransformTo(null),
Rect.fromLTWH(0, 0, b.size.width, b.size.height),
);
final int result = _compareVertically(rectA, rectB);
if (result != 0)
return result;
return _compareHorizontally(rectA, rectB);
}
/// Compares two rectangles in the screen order solely by their vertical
/// positions.
///
/// Returns positive if a is lower, negative if a is higher, 0 if their
/// order can't be determine solely by their vertical position.
static int _compareVertically(Rect a, Rect b) {
if ((a.top - b.top < precisionErrorTolerance && a.bottom - b.bottom > - precisionErrorTolerance) ||
(b.top - a.top < precisionErrorTolerance && b.bottom - a.bottom > - precisionErrorTolerance)) {
return 0;
}
if ((a.top - b.top).abs() > precisionErrorTolerance)
return a.top > b.top ? 1 : -1;
return a.bottom > b.bottom ? 1 : -1;
}
/// Compares two rectangles in the screen order by their horizontal positions
/// assuming one of the rectangles enclose the other rect vertically.
///
/// Returns positive if a is lower, negative if a is higher.
static int _compareHorizontally(Rect a, Rect b) {
if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) {
// a encloses b.
return -1;
}
if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) {
// b encloses a.
return 1;
}
if ((a.left - b.left).abs() > precisionErrorTolerance)
return a.left > b.left ? 1 : -1;
return a.right > b.right ? 1 : -1;
}
void _handleSelectableGeometryChange() {
// Geometries of selectable children may change multiple times when handling
// selection events. Ignore these updates since the selection geometry of
// this delegate will be updated after handling the selection events.
if (_isHandlingSelectionEvent)
return;
_updateSelectionGeometry();
}
/// Gets the combined selection geometry for child selectables.
@protected
SelectionGeometry getSelectionGeometry() {
if (currentSelectionEndIndex == -1 ||
currentSelectionStartIndex == -1 ||
selectables.isEmpty) {
// There is no valid selection.
return SelectionGeometry(
status: SelectionStatus.none,
hasContent: selectables.isNotEmpty,
);
}
currentSelectionStartIndex = _adjustSelectionIndexBasedOnSelectionGeometry(
currentSelectionStartIndex,
currentSelectionEndIndex,
);
currentSelectionEndIndex = _adjustSelectionIndexBasedOnSelectionGeometry(
currentSelectionEndIndex,
currentSelectionStartIndex,
);
// Need to find the non-null start selection point.
SelectionGeometry startGeometry = selectables[currentSelectionStartIndex].value;
final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex;
int startIndexWalker = currentSelectionStartIndex;
while (startIndexWalker != currentSelectionEndIndex && startGeometry.startSelectionPoint == null) {
startIndexWalker += forwardSelection ? 1 : -1;
startGeometry = selectables[startIndexWalker].value;
}
SelectionPoint? startPoint;
if (startGeometry.startSelectionPoint != null) {
final Matrix4 startTransform = getTransformFrom(selectables[startIndexWalker]);
final Offset start = MatrixUtils.transformPoint(startTransform, startGeometry.startSelectionPoint!.localPosition);
// It can be NaN if it is detached or off-screen.
if (start.isFinite) {
startPoint = SelectionPoint(
localPosition: start,
lineHeight: startGeometry.startSelectionPoint!.lineHeight,
handleType: startGeometry.startSelectionPoint!.handleType,
);
}
}
// Need to find the non-null end selection point.
SelectionGeometry endGeometry = selectables[currentSelectionEndIndex].value;
int endIndexWalker = currentSelectionEndIndex;
while (endIndexWalker != currentSelectionStartIndex && endGeometry.endSelectionPoint == null) {
endIndexWalker += forwardSelection ? -1 : 1;
endGeometry = selectables[endIndexWalker].value;
}
SelectionPoint? endPoint;
if (endGeometry.endSelectionPoint != null) {
final Matrix4 endTransform = getTransformFrom(selectables[endIndexWalker]);
final Offset end = MatrixUtils.transformPoint(endTransform, endGeometry.endSelectionPoint!.localPosition);
// It can be NaN if it is detached or off-screen.
if (end.isFinite) {
endPoint = SelectionPoint(
localPosition: end,
lineHeight: endGeometry.endSelectionPoint!.lineHeight,
handleType: endGeometry.endSelectionPoint!.handleType,
);
}
}
return SelectionGeometry(
startSelectionPoint: startPoint,
endSelectionPoint: endPoint,
status: startGeometry != endGeometry
? SelectionStatus.uncollapsed
: startGeometry.status,
// Would have at least one selectable child.
hasContent: true,
);
}
// The currentSelectionStartIndex or currentSelectionEndIndex may not be
// the current index that contains selection edges. This can happen if the
// selection edge is in between two selectables. One of the selectable will
// have its selection collapsed at the index 0 or contentLength depends on
// whether the selection is reversed or not. The current selection index can
// be point to either one.
//
// This method adjusts the index to point to selectable with valid selection.
int _adjustSelectionIndexBasedOnSelectionGeometry(int currentIndex, int towardIndex) {
final bool forward = towardIndex > currentIndex;
while (currentIndex != towardIndex &&
selectables[currentIndex].value.status != SelectionStatus.uncollapsed) {
currentIndex += forward ? 1 : -1;
}
return currentIndex;
}
@override
void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) {
if (_startHandleLayer == startHandle && _endHandleLayer == endHandle)
return;
_startHandleLayer = startHandle;
_endHandleLayer = endHandle;
_updateHandleLayersAndOwners();
}
/// Pushes both handle layers to the selectables that contain selection edges.
///
/// This method needs to be called every time the selectables that contain the
/// selection edges change, i.e. [currentSelectionStartIndex] or
/// [currentSelectionEndIndex] changes. Otherwise, the handle may be painted
/// in the wrong place.
void _updateHandleLayersAndOwners() {
LayerLink? effectiveStartHandle = _startHandleLayer;
LayerLink? effectiveEndHandle = _endHandleLayer;
if (effectiveStartHandle != null || effectiveEndHandle != null) {
final Rect 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<SelectedContent> selections = <SelectedContent>[];
for (final Selectable selectable in selectables) {
final SelectedContent? data = selectable.getSelectedContent();
if (data != null)
selections.add(data);
}
if (selections.isEmpty)
return null;
final StringBuffer buffer = StringBuffer();
for (final SelectedContent selection in selections) {
buffer.write(selection.plainText);
}
return SelectedContent(
plainText: buffer.toString(),
);
}
/// Selects all contents of all selectables.
@protected
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
for (final Selectable selectable in selectables) {
dispatchSelectionEventToChild(selectable, event);
}
currentSelectionStartIndex = 0;
currentSelectionEndIndex = selectables.length - 1;
return SelectionResult.none;
}
/// Selects a word in a selectable at the location
/// [SelectWordSelectionEvent.globalPosition].
@protected
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
for (int index = 0; index < selectables.length; index += 1) {
final Rect localRect = Rect.fromLTWH(0, 0, selectables[index].size.width, selectables[index].size.height);
final Matrix4 transform = selectables[index].getTransformTo(null);
final Rect globalRect = MatrixUtils.transformRect(transform, localRect);
if (globalRect.contains(event.globalPosition)) {
final SelectionGeometry existingGeometry = selectables[index].value;
dispatchSelectionEventToChild(selectables[index], event);
if (selectables[index].value != existingGeometry) {
// Geometry has changed as a result of select word, need to clear the
// selection of other selectables to keep selection in sync.
selectables
.where((Selectable target) => target != selectables[index])
.forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent()));
currentSelectionStartIndex = currentSelectionEndIndex = index;
}
return SelectionResult.end;
}
}
return SelectionResult.none;
}
/// Removes the selection of all selectables this delegate manages.
@protected
SelectionResult handleClearSelection(ClearSelectionEvent event) {
for (final Selectable selectable in selectables) {
dispatchSelectionEventToChild(selectable, event);
}
currentSelectionEndIndex = -1;
currentSelectionStartIndex = -1;
return SelectionResult.none;
}
/// Updates the selection edges.
@protected
SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) {
if (event.type == SelectionEventType.endEdgeUpdate) {
return currentSelectionEndIndex == -1 ? _initSelection(event, isEnd: true) : _adjustSelection(event, isEnd: true);
}
return currentSelectionStartIndex == -1 ? _initSelection(event, isEnd: false) : _adjustSelection(event, isEnd: false);
}
@override
SelectionResult dispatchSelectionEvent(SelectionEvent event) {
final bool selectionWillbeInProgress = event is! ClearSelectionEvent;
if (!_selectionInProgress && selectionWillbeInProgress) {
// Sort the selectable every time a selection start.
selectables.sort(compareOrder);
}
_selectionInProgress = selectionWillbeInProgress;
_isHandlingSelectionEvent = true;
late SelectionResult result;
switch (event.type) {
case SelectionEventType.startEdgeUpdate:
case SelectionEventType.endEdgeUpdate:
result = handleSelectionEdgeUpdate(event as SelectionEdgeUpdateEvent);
break;
case SelectionEventType.clear:
result = handleClearSelection(event as ClearSelectionEvent);
break;
case SelectionEventType.selectAll:
result = handleSelectAll(event as SelectAllSelectionEvent);
break;
case SelectionEventType.selectWord:
result = handleSelectWord(event as SelectWordSelectionEvent);
break;
}
_isHandlingSelectionEvent = false;
_updateSelectionGeometry();
return result;
}
@override
void dispose() {
for (final Selectable selectable in selectables) {
selectable.removeListener(_handleSelectableGeometryChange);
}
selectables = const <Selectable>[];
_scheduledSelectableUpdate = false;
super.dispose();
}
/// Ensures the selectable child has received up to date selection event.
///
/// This method is called when a new [Selectable] is added to the delegate,
/// and its screen location falls into the previous selection.
///
/// Subclasses are responsible for updating the selection of this newly added
/// [Selectable].
@protected
void ensureChildUpdated(Selectable selectable);
/// Dispatches a selection event to a specific selectable.
///
/// Override this method if subclasses need to generate additional events or
/// treatments prior to sending the selection events.
@protected
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
return selectable.dispatchSelectionEvent(event);
}
/// Initializes the selection of the selectable children.
///
/// The goal is to find the selectable child that contains the selection edge.
/// Returns [SelectionResult.end] if the selection edge ends on any of the
/// children. Otherwise, it returns [SelectionResult.previous] if the selection
/// does not reach any of its children. Returns [SelectionResult.next]
/// if the selection reaches the end of its children.
///
/// Ideally, this method should only be called twice at the beginning of the
/// drag selection, once for start edge update event, once for end edge update
/// event.
SelectionResult _initSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) {
assert((isEnd && currentSelectionEndIndex == -1) || (!isEnd && currentSelectionStartIndex == -1));
int newIndex = -1;
bool hasFoundEdgeIndex = false;
SelectionResult? result;
for (int index = 0; index < selectables.length && !hasFoundEdgeIndex; index += 1) {
final Selectable child = selectables[index];
final SelectionResult childResult = dispatchSelectionEventToChild(child, event);
switch (childResult) {
case SelectionResult.next:
case SelectionResult.none:
newIndex = index;
break;
case SelectionResult.end:
newIndex = index;
result = SelectionResult.end;
hasFoundEdgeIndex = true;
break;
case SelectionResult.previous:
hasFoundEdgeIndex = true;
if (index == 0) {
newIndex = 0;
result = SelectionResult.previous;
}
result ??= SelectionResult.end;
break;
case SelectionResult.pending:
newIndex = index;
result = SelectionResult.pending;
hasFoundEdgeIndex = true;
break;
}
}
if (newIndex == -1) {
assert(selectables.isEmpty);
return SelectionResult.none;
}
if (isEnd) {
currentSelectionEndIndex = newIndex;
} else {
currentSelectionStartIndex = newIndex;
}
// The result can only be null if the loop went through the entire list
// without any of the selection returned end or previous. In this case, the
// caller of this method needs to find the next selectable in their list.
return result ?? SelectionResult.next;
}
/// Adjusts the selection based on the drag selection update event if there
/// is already a selectable child that contains the selection edge.
///
/// This method starts by sending the selection event to the current
/// selectable that contains the selection edge, and finds forward or backward
/// if that selectable no longer contains the selection edge.
SelectionResult _adjustSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) {
assert(() {
if (isEnd) {
assert(currentSelectionEndIndex < selectables.length && currentSelectionEndIndex >= 0);
return true;
}
assert(currentSelectionStartIndex < selectables.length && currentSelectionStartIndex >= 0);
return true;
}());
SelectionResult? finalResult;
int newIndex = isEnd ? currentSelectionEndIndex : currentSelectionStartIndex;
bool? forward;
late SelectionResult currentSelectableResult;
// This loop sends the selection event to the
// currentSelectionEndIndex/currentSelectionStartIndex to determine the
// direction of the search. If the result is `SelectionResult.next`, this
// loop look backward. Otherwise, it looks forward.
//
// The terminate condition are:
// 1. the selectable returns end, pending, none.
// 2. the selectable returns previous when looking forward.
// 2. the selectable returns next when looking backward.
while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) {
currentSelectableResult = dispatchSelectionEventToChild(selectables[newIndex], event);
switch (currentSelectableResult) {
case SelectionResult.end:
case SelectionResult.pending:
case SelectionResult.none:
finalResult = currentSelectableResult;
break;
case SelectionResult.next:
if (forward == false) {
newIndex += 1;
finalResult = SelectionResult.end;
} else if (newIndex == selectables.length - 1) {
finalResult = currentSelectableResult;
} else {
forward = true;
newIndex += 1;
}
break;
case SelectionResult.previous:
if (forward ?? false) {
newIndex -= 1;
finalResult = SelectionResult.end;
} else if (newIndex == 0) {
finalResult = currentSelectableResult;
} else {
forward = false;
newIndex -= 1;
}
break;
}
}
if (isEnd) {
currentSelectionEndIndex = newIndex;
} else {
currentSelectionStartIndex = newIndex;
}
return finalResult!;
}
}
// 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<SelectionRegistrarScope>();
return scope?.registrar;
}
bool get _disabled => delegate == null;
@override
State<SelectionContainer> createState() => _SelectionContainerState();
}
class _SelectionContainerState extends State<SelectionContainer> with Selectable, SelectionRegistrant {
final Set<VoidCallback> _listeners = <VoidCallback>{};
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;
}
}
......@@ -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<Selectable>? _selectablesWithSelections;
Map<Selectable, VoidCallback>? _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 ??= <Selectable>{};
_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 ??= <Selectable, VoidCallback>{};
_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.
///
......
......@@ -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 ? <InlineSpan>[textSpan!] : null,
),
);
if (registrar != null) {
result = MouseRegion(
cursor: SystemMouseCursors.text,
child: result,
);
}
if (semanticsLabel != null) {
result = Semantics(
textDirection: textDirection,
......
......@@ -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 &lt;quick brown&gt; fox':
///
/// The '&lt;' is drawn with the [left] type, the '&gt;' with the [right]
///
/// * RTL text: 'XOF &lt;NWORB KCIUQ&gt; EHT':
///
/// Same as above.
///
/// * mixed text: '&lt;the NWOR&lt;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
......
......@@ -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';
......
// 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<SelectableRegion>(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());
}
......@@ -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(), <SemanticsNode>[]);
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<RenderBox> renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'widget'), textDirection: TextDirection.ltr),
];
final RenderParagraph paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
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<Selectable> selectables = <Selectable>[];
@override
void add(Selectable selectable) {
selectables.add(selectable);
}
@override
void remove(Selectable selectable) {
expect(selectables.remove(selectable), isTrue);
}
}
// 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);
});
});
}
// 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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(find.descendant(of: find.text('Item 99'), matching: find.byType(RichText)));
final RenderParagraph paragraph98 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 98'), matching: find.byType(RichText)));
final RenderParagraph paragraph97 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 97'), matching: find.byType(RichText)));
final RenderParagraph paragraph96 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)));
final RenderParagraph paragraph8 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 8'), matching: find.byType(RichText)));
final RenderParagraph paragraph7 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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>{ 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<RenderParagraph>(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>{ 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<RenderParagraph>(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<TextBox> 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<RenderParagraph>(find.descendant(of: find.text('Item 99'), matching: find.byType(RichText)));
final RenderParagraph paragraph98 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 98'), matching: find.byType(RichText)));
final RenderParagraph paragraph97 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 97'), matching: find.byType(RichText)));
final RenderParagraph paragraph96 = tester.renderObject<RenderParagraph>(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: <Widget>[
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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)));
innerParagraph24 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'em 0It');
}, variant: const TargetPlatformVariant(<TargetPlatform>{ 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<RenderParagraph>(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<RenderParagraph>(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<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'em 0It');
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }));
});
}
// 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<RenderSelectionSpy>(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<RenderSelectionSpy>(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<RenderSelectionSpy>(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<ClearSelectionEvent>());
}, 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<RenderSelectionSpy>(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<SelectWordSelectionEvent>());
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<RenderSelectionSpy>(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<SelectWordSelectionEvent>());
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<RenderSelectionSpy>(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<RenderParagraph>(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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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 <Widget>[
Text('How are you?'),
SelectionContainer.disabled(child: Text('Good, and you?')),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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 <Widget>[
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<RenderParagraph>(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<RenderParagraph>(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<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'w are you?Good, and you?Fine, ');
}, variant: const TargetPlatformVariant(<TargetPlatform>{ 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: <Widget>[
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<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(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>{ 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: <Widget>[
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<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(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>{ 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 <Widget>[
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<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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>{ 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: <InlineSpan>[
TextSpan(text: 'How are you?'),
WidgetSpan(child: Text('Good, and you?')),
TextSpan(text: 'Fine, thank you.'),
]
),
key: outerText,
),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(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<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'w are you?Good, and you?Fine');
},
variant: const TargetPlatformVariant(<TargetPlatform>{ 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: <InlineSpan>[
TextSpan(text: 'How are you?'),
WidgetSpan(child: Placeholder()),
TextSpan(text: 'Fine, thank you.'),
]
),
key: outerText,
),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(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<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'w are you?Fine');
},
variant: const TargetPlatformVariant(<TargetPlatform>{ 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: <InlineSpan>[
TextSpan(text: 'How are you?'),
WidgetSpan(child: Placeholder()),
TextSpan(text: 'Fine, thank you.'),
]
),
key: outerText,
),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(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<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'w are you?Fine');
},
variant: const TargetPlatformVariant(<TargetPlatform>{ 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 <Widget>[
Text('How are you?'),
Text('جيد وانت؟', textDirection: TextDirection.rtl),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(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<RenderParagraph>(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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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<TextBox> 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<RenderParagraph>(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<RenderParagraph>(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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(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<TextBox> 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<RenderParagraph>(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<RenderParagraph>(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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(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<TextBox> 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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(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<TextBox> 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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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 <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(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<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
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<VoidCallback> listeners = <VoidCallback>{};
List<SelectionEvent> events = <SelectionEvent>[];
@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<TextSelectionPoint> 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<TextSelectionPoint> endpoints;
final TextSelectionDelegate delegate;
final ClipboardStatusNotifier? clipboardStatus;
final Offset? lastSecondaryTapDownPosition;
@override
Widget build(BuildContext context) {
return SizedBox(width: textLineHeight, height: textLineHeight);
}
}
// 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<void> 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 <Widget>[
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 <Widget>[
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 <Widget>[
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 <Widget>[
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<Selectable> selectables = <Selectable>{};
@override
void add(Selectable selectable) => selectables.add(selectable);
@override
void remove(Selectable selectable) => selectables.remove(selectable);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment