diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 06f17c0216a7..59d1f539d902 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -422,7 +422,9 @@ class _SelectableRegionState extends State with TextSelectionD void _handleSelectionStartHandleDragStart(DragStartDetails details) { assert(_selectionDelegate.value.startSelectionPoint != null); - _selectionStartHandleDragPosition = _selectionDelegate.value.startSelectionPoint!.localPosition; + final Offset localPosition = _selectionDelegate.value.startSelectionPoint!.localPosition; + final Matrix4 globalTransform = _selectable!.getTransformTo(null); + _selectionStartHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition); } void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { @@ -435,7 +437,9 @@ class _SelectableRegionState extends State with TextSelectionD void _handleSelectionEndHandleDragStart(DragStartDetails details) { assert(_selectionDelegate.value.endSelectionPoint != null); - _selectionEndHandleDragPosition = _selectionDelegate.value.endSelectionPoint!.localPosition; + final Offset localPosition = _selectionDelegate.value.endSelectionPoint!.localPosition; + final Matrix4 globalTransform = _selectable!.getTransformTo(null); + _selectionEndHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition); } void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { @@ -995,6 +999,19 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai /// Gets the list of selectables this delegate is managing. List selectables = []; + /// The number of additional pixels added to the selection handle drawable + /// area. + /// + /// Selection handles that are outside of the drawable area will be hidden. + /// That logic prevents handles that get scrolled off the viewport from being + /// drawn on the screen. + /// + /// The drawable area = current rectangle of [SelectionContainer] + + /// _kSelectionHandleDrawableAreaPadding on each side. + /// + /// This was an eyeballed value to create smooth user experiences. + static const double _kSelectionHandleDrawableAreaPadding = 5.0; + /// The current selectable that contains the selection end edge. @protected int currentSelectionEndIndex = -1; @@ -1322,9 +1339,11 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai 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); + final Rect drawableArea = Rect + .fromLTWH(0, 0, containerSize.width, containerSize.height) + .inflate(_kSelectionHandleDrawableAreaPadding); + final bool hideStartHandle = value.startSelectionPoint == null || !drawableArea.contains(value.startSelectionPoint!.localPosition); + final bool hideEndHandle = value.endSelectionPoint == null || !drawableArea.contains(value.endSelectionPoint!.localPosition); effectiveStartHandle = hideStartHandle ? null : _startHandleLayer; effectiveEndHandle = hideEndHandle ? null : _endHandleLayer; } diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index 0450525df7a1..833e58cf5647 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -72,6 +72,34 @@ void main() { await gesture.up(); }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410. + testWidgets('can draw handles when they are at rect boundaries', (WidgetTester tester) async { + final UniqueKey spy = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + const Text('How are you?'), + SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: SelectAllWidget(key: spy, child: const SizedBox(width: 100, height: 100)), + ), + const Text('Fine, thank you.'), + ], + ), + ), + ); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(spy))); + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(); + + final RenderSelectAll renderSpy = tester.renderObject(find.byKey(spy)); + expect(renderSpy.startHandle, isNotNull); + expect(renderSpy.endHandle, isNotNull); + }); + testWidgets('touch does not accept drag', (WidgetTester tester) async { final UniqueKey spy = UniqueKey(); await tester.pumpWidget( @@ -815,44 +843,73 @@ void main() { await gesture.up(); }); - testWidgets('can drag end selection handle', (WidgetTester tester) async { + testWidgets('can drag end handle when not covering entire screen', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/104620. await tester.pumpWidget( MaterialApp( - home: SelectableRegion( - focusNode: FocusNode(), - selectionControls: materialTextSelectionControls, - child: Column( - children: const [ - Text('How are you?'), - Text('Good, and you?'), - Text('Fine, thank you.'), - ], - ), + home: Column( + children: [ + const Text('How are you?'), + SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: const Text('Good, and you?'), + ), + const Text('Fine, thank you.'), + ], ), ), ); - final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); - final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r' + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph2, 7)); // at the 'a' addTearDown(gesture.removePointer); await tester.pump(const Duration(milliseconds: 500)); await gesture.up(); await tester.pump(const Duration(milliseconds: 500)); - expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); - final List boxes = paragraph1.getBoxesForSelection(paragraph1.selections[0]); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 9)); + final List boxes = paragraph2.getBoxesForSelection(paragraph2.selections[0]); expect(boxes.length, 1); - final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph1); + final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph2); await gesture.down(handlePos); + + await gesture.moveTo(textOffsetToPosition(paragraph2, 11) + Offset(0, paragraph2.size.height / 2)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11)); + await gesture.up(); + }); + + testWidgets('can drag start handle when not covering entire screen', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/104620. + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + const Text('How are you?'), + SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: const Text('Good, and you?'), + ), + const Text('Fine, thank you.'), + ], + ), + ), + ); final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); - await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + Offset(0, paragraph2.size.height / 2)); - expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 12)); - expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph2, 7)); // at the 'a' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 9)); + final List boxes = paragraph2.getBoxesForSelection(paragraph2.selections[0]); + expect(boxes.length, 1); - final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); - await gesture.moveTo(textOffsetToPosition(paragraph3, 6) + Offset(0, paragraph3.size.height / 2)); - expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 12)); - expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14)); - expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); + final Offset handlePos = globalize(boxes[0].toRect().bottomLeft, paragraph2); + await gesture.down(handlePos); + + await gesture.moveTo(textOffsetToPosition(paragraph2, 11) + Offset(0, paragraph2.size.height / 2)); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 11, extentOffset: 9)); await gesture.up(); }); @@ -1047,7 +1104,7 @@ void main() { class SelectionSpy extends LeafRenderObjectWidget { const SelectionSpy({ - super.key, + super.key, }); @override @@ -1118,3 +1175,94 @@ class RenderSelectionSpy extends RenderProxyBox @override void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { } } + +class SelectAllWidget extends SingleChildRenderObjectWidget { + const SelectAllWidget({ + super.key, + super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderSelectAll( + SelectionContainer.maybeOf(context), + ); + } + + @override + void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { } +} + +class RenderSelectAll extends RenderProxyBox + with Selectable, SelectionRegistrant { + RenderSelectAll( + SelectionRegistrar? registrar, + ) { + this.registrar = registrar; + } + + final Set listeners = {}; + LayerLink? startHandle; + LayerLink? endHandle; + + @override + void addListener(VoidCallback listener) => listeners.add(listener); + + @override + void removeListener(VoidCallback listener) => listeners.remove(listener); + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + value = SelectionGeometry( + hasContent: true, + status: SelectionStatus.uncollapsed, + startSelectionPoint: SelectionPoint( + localPosition: Offset(0, size.height), + lineHeight: 0.0, + handleType: TextSelectionHandleType.left, + ), + endSelectionPoint: SelectionPoint( + localPosition: Offset(size.width, size.height), + lineHeight: 0.0, + handleType: TextSelectionHandleType.left, + ), + ); + 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) { + this.startHandle = startHandle; + this.endHandle = endHandle; + } +}