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);
......
This diff is 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,
......
This diff is collapsed.
This diff is collapsed.
......@@ -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);
});
});
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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