iOS Selection Handle Improvements (#157815)

Fixes #110306 


https://github.com/user-attachments/assets/d0a20ae9-912c-4ddc-bd6a-a21409468078

This change:
* Allows selection handles on iOS to swap with each other when inverting
on `TextField`.
* Allows selection handles to visually collapse when inverting on
`SelectableRegion`/`SelectionArea`, previously they showed both left and
right handles when collapsed, instead of the collapsed handles.
* Adds a border to the CupertinoTextMagnifier, the same color as the
selection handles to match native iOS behavior.

`SelectionOverlay`:
* Previously would build an empty end handle when the selection was
collapsed. Now it builds an empty end handle when the selection is being
collapsed and the start handle is being dragged, and when the selection
is collapsed and no handle is being dragged.
* Hides start handle when the selection is being collapsed and the end
handle is being dragged.
* Keeps the handles from overlapping.

`TextSelectionOverlay`:
* Removes guards against swapping handles for iOS and macOS.
* Tracks `_oppositeEdge` used to maintain selection as handles invert.

`RenderParagraph`:
* Send collapsed selection handle state in `SelectionGeometry`,
previously we wouldn't so the collapsed state would show both start and
end handles.

`CupertinoTextMagnifier`:
* Inherit border color from parent
`CupertinoTheme.of(context).primaryColor`. Selection handles also uses
`primaryColor`.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

---------

Co-authored-by: Renzo Olivares <roliv@google.com>
This commit is contained in:
Renzo Olivares 2024-12-09 12:54:04 -08:00 committed by GitHub
parent 0ca9f51227
commit 0737dbfcb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 189 additions and 24 deletions

View File

@ -6,8 +6,11 @@
library;
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'theme.dart';
/// A [CupertinoMagnifier] used for magnifying text in cases where a user's
/// finger may be blocking the point of interest, like a selection handle.
///
@ -215,6 +218,7 @@ class _CupertinoTextMagnifierState extends State<CupertinoTextMagnifier>
@override
Widget build(BuildContext context) {
final CupertinoThemeData themeData = CupertinoTheme.of(context);
return AnimatedPositioned(
duration: CupertinoTextMagnifier._kDragAnimationDuration,
curve: widget.animationCurve,
@ -223,6 +227,10 @@ class _CupertinoTextMagnifierState extends State<CupertinoTextMagnifier>
child: CupertinoMagnifier(
inOutAnimation: _ioAnimation,
additionalFocalPointOffset: Offset(0, _verticalFocalPointAdjustment),
borderSide: BorderSide(
color: themeData.primaryColor,
width: 2.0,
),
),
);
}
@ -252,7 +260,7 @@ class CupertinoMagnifier extends StatelessWidget {
/// Creates a [RawMagnifier] in the Cupertino style.
///
/// The default constructor parameters and constants were eyeballed on
/// an iPhone XR iOS v15.5.
/// an iPhone 16 iOS v18.1.
const CupertinoMagnifier({
super.key,
this.size = kDefaultSize,
@ -268,7 +276,10 @@ class CupertinoMagnifier extends StatelessWidget {
],
this.clipBehavior = Clip.none,
this.borderSide =
const BorderSide(color: Color.fromARGB(255, 232, 232, 232)),
const BorderSide(
color: Color.fromARGB(255, 0, 124, 255),
width: 2.0,
),
this.inOutAnimation,
this.magnificationScale = 1.0,
}) : assert(magnificationScale > 0, 'The magnification scale should be greater than zero.');

View File

@ -2550,6 +2550,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
super.paint,
Offset.zero,
);
} else if (selection!.isCollapsed) {
context.pushLayer(
LeaderLayer(link: endHandleLayerLink, offset: startPoint + offset),
super.paint,
Offset.zero,
);
}
}

View File

@ -1394,19 +1394,29 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem
for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) {
selectionRects.add(textBox.toRect());
}
final bool selectionCollapsed = selectionStart == selectionEnd;
final (
TextSelectionHandleType startSelectionHandleType,
TextSelectionHandleType endSelectionHandleType,
) = switch ((selectionCollapsed, flipHandles)) {
// Always prefer collapsed handle when selection is collapsed.
(true, _) => (TextSelectionHandleType.collapsed, TextSelectionHandleType.collapsed),
(false, true) => (TextSelectionHandleType.right, TextSelectionHandleType.left),
(false, false) => (TextSelectionHandleType.left, TextSelectionHandleType.right),
};
return SelectionGeometry(
startSelectionPoint: SelectionPoint(
localPosition: startOffsetInParagraphCoordinates,
lineHeight: paragraph._textPainter.preferredLineHeight,
handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left
handleType: startSelectionHandleType,
),
endSelectionPoint: SelectionPoint(
localPosition: endOffsetInParagraphCoordinates,
lineHeight: paragraph._textPainter.preferredLineHeight,
handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right,
handleType: endSelectionHandleType,
),
selectionRects: selectionRects,
status: _textSelectionStart!.offset == _textSelectionEnd!.offset
status: selectionCollapsed
? SelectionStatus.collapsed
: SelectionStatus.uncollapsed,
hasContent: true,

View File

@ -1199,12 +1199,12 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
_selectionOverlay = SelectionOverlay(
context: context,
debugRequiredFor: widget,
startHandleType: start?.handleType ?? TextSelectionHandleType.left,
startHandleType: start?.handleType ?? TextSelectionHandleType.collapsed,
lineHeightAtStart: start?.lineHeight ?? end!.lineHeight,
onStartHandleDragStart: _handleSelectionStartHandleDragStart,
onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
onStartHandleDragEnd: _onAnyDragEnd,
endHandleType: end?.handleType ?? TextSelectionHandleType.right,
endHandleType: end?.handleType ?? TextSelectionHandleType.collapsed,
lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight,
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,

View File

@ -696,6 +696,9 @@ class TextSelectionOverlay {
// corresponds to, in global coordinates.
late double _endHandleDragTarget;
// The initial selection when a selection handle drag has started.
TextSelection? _dragStartSelection;
void _handleSelectionEndHandleDragStart(DragStartDetails details) {
if (!renderObject.attached) {
return;
@ -721,6 +724,7 @@ class TextSelectionOverlay {
centerOfLineGlobal,
),
);
_dragStartSelection ??= _selection;
_selectionOverlay.showMagnifier(
_buildMagnifier(
@ -760,6 +764,7 @@ class TextSelectionOverlay {
if (!renderObject.attached) {
return;
}
assert(_dragStartSelection != null);
// This is NOT the same as details.localPosition. That is relative to the
// selection handle, whereas this is relative to the RenderEditable.
@ -780,7 +785,7 @@ class TextSelectionOverlay {
final TextPosition position = renderObject.getPositionForPoint(handleTargetGlobal);
if (_selection.isCollapsed) {
if (_dragStartSelection!.isCollapsed) {
_selectionOverlay.updateMagnifier(_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
@ -797,13 +802,15 @@ class TextSelectionOverlay {
// On Apple platforms, dragging the base handle makes it the extent.
case TargetPlatform.iOS:
case TargetPlatform.macOS:
// Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized
// always returns true for a TextSelection.
final bool dragStartSelectionNormalized = _dragStartSelection!.extentOffset >= _dragStartSelection!.baseOffset;
newSelection = TextSelection(
baseOffset: dragStartSelectionNormalized
? _dragStartSelection!.baseOffset
: _dragStartSelection!.extentOffset,
extentOffset: position.offset,
baseOffset: _selection.start,
);
if (position.offset <= _selection.start) {
return; // Don't allow order swapping.
}
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
@ -859,6 +866,7 @@ class TextSelectionOverlay {
centerOfLineGlobal,
),
);
_dragStartSelection ??= _selection;
_selectionOverlay.showMagnifier(
_buildMagnifier(
@ -873,6 +881,7 @@ class TextSelectionOverlay {
if (!renderObject.attached) {
return;
}
assert(_dragStartSelection != null);
// This is NOT the same as details.localPosition. That is relative to the
// selection handle, whereas this is relative to the RenderEditable.
@ -890,7 +899,7 @@ class TextSelectionOverlay {
);
final TextPosition position = renderObject.getPositionForPoint(handleTargetGlobal);
if (_selection.isCollapsed) {
if (_dragStartSelection!.isCollapsed) {
_selectionOverlay.updateMagnifier(_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
@ -907,13 +916,15 @@ class TextSelectionOverlay {
// On Apple platforms, dragging the base handle makes it the extent.
case TargetPlatform.iOS:
case TargetPlatform.macOS:
// Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized
// always returns true for a TextSelection.
final bool dragStartSelectionNormalized = _dragStartSelection!.extentOffset >= _dragStartSelection!.baseOffset;
newSelection = TextSelection(
baseOffset: dragStartSelectionNormalized
? _dragStartSelection!.extentOffset
: _dragStartSelection!.baseOffset,
extentOffset: position.offset,
baseOffset: _selection.end,
);
if (newSelection.extentOffset >= _selection.end) {
return; // Don't allow order swapping.
}
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
@ -940,6 +951,7 @@ class TextSelectionOverlay {
if (!context.mounted) {
return;
}
_dragStartSelection = null;
if (selectionControls is! TextSelectionHandleControls) {
_selectionOverlay.hideMagnifier();
if (!_selection.isCollapsed) {
@ -1603,7 +1615,10 @@ class SelectionOverlay {
Widget _buildStartHandle(BuildContext context) {
final Widget handle;
final TextSelectionControls? selectionControls = this.selectionControls;
if (selectionControls == null) {
if (selectionControls == null
|| (_startHandleType == TextSelectionHandleType.collapsed && _isDraggingEndHandle)) {
// Hide the start handle when dragging the end handle and collapsing
// the selection.
handle = const SizedBox.shrink();
} else {
handle = _SelectionHandleOverlay(
@ -1629,8 +1644,11 @@ class SelectionOverlay {
Widget _buildEndHandle(BuildContext context) {
final Widget handle;
final TextSelectionControls? selectionControls = this.selectionControls;
if (selectionControls == null || _startHandleType == TextSelectionHandleType.collapsed) {
// Hide the second handle when collapsed.
if (selectionControls == null
|| (_endHandleType == TextSelectionHandleType.collapsed && _isDraggingStartHandle)
|| (_endHandleType == TextSelectionHandleType.collapsed && !_isDraggingStartHandle && !_isDraggingEndHandle)) {
// Hide the end handle when dragging the start handle and collapsing the selection
// or when the selection is collapsed and no handle is being dragged.
handle = const SizedBox.shrink();
} else {
handle = _SelectionHandleOverlay(

View File

@ -39,6 +39,52 @@ void main() {
});
group('CupertinoTextEditingMagnifier', () {
testWidgets('Magnifier border color inherits from parent CupertinoTheme', (WidgetTester tester) async {
final Key fakeTextFieldKey = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: SizedBox.square(
key: fakeTextFieldKey,
dimension: 10,
child: CupertinoTheme(
data: const CupertinoThemeData(primaryColor: Colors.green),
child: Builder(
builder: (BuildContext context) {
return const Placeholder();
},
),
),
),
),
);
final BuildContext context = tester.element(find.byType(Placeholder));
// Magnifier should be positioned directly over the red square.
final RenderBox tapPointRenderBox =
tester.firstRenderObject(find.byKey(fakeTextFieldKey)) as RenderBox;
final Rect fakeTextFieldRect =
tapPointRenderBox.localToGlobal(Offset.zero) & tapPointRenderBox.size;
final ValueNotifier<MagnifierInfo> magnifier =
ValueNotifier<MagnifierInfo>(
MagnifierInfo(
currentLineBoundaries: fakeTextFieldRect,
fieldBounds: fakeTextFieldRect,
caretRect: fakeTextFieldRect,
// The tap position is dragBelow units below the text field.
globalGesturePosition: fakeTextFieldRect.center,
),
);
addTearDown(magnifier.dispose);
await showCupertinoMagnifier(context, tester, magnifier);
// Magnifier border color should inherit from CupertinoTheme.of(context).primaryColor.
final Color magnifierBorderColor = tester.widget<CupertinoMagnifier>(find.byType(CupertinoMagnifier)).borderSide.color;
expect(magnifierBorderColor, equals(Colors.green));
});
group('position', () {
Offset getMagnifierPosition(WidgetTester tester) {
final AnimatedPositioned animatedPositioned =

View File

@ -5223,7 +5223,7 @@ void main() {
expect(find.byType(CupertinoButton), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async {
testWidgets('Cannot drag one handle past the other on non-Apple platform', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
@ -5231,6 +5231,8 @@ void main() {
// On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
// On macOS, we select the precise position of the tap.
final bool isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS;
// Provide a [TextSelectionControls] that builds selection handles.
final TextSelectionControls selectionControls = CupertinoTextSelectionHandleControls();
await tester.pumpWidget(
CupertinoApp(
@ -5238,6 +5240,7 @@ void main() {
child: CupertinoTextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
selectionControls: selectionControls,
style: const TextStyle(fontSize: 10.0),
),
),
@ -5289,7 +5292,77 @@ void main() {
// The selection doesn't move beyond the left handle. There's always at
// least 1 char selected.
expect(controller.selection.extentOffset, 5);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('Can drag one handle past the other on iOS', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
addTearDown(controller.dispose);
// On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
// On macOS, we select the precise position of the tap.
final bool isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS;
// Provide a [TextSelectionControls] that builds selection handles.
final TextSelectionControls selectionControls = CupertinoTextSelectionHandleControls();
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
selectionControls: selectionControls,
style: const TextStyle(fontSize: 10.0),
),
),
),
);
// Double tap on 'e' to select 'def'.
final Offset ePos = textOffsetToPosition(tester, 5);
await tester.tapAt(ePos, pointer: 7);
await tester.pump(const Duration(milliseconds: 50));
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, isTargetPlatformIOS ? 7 : 5);
await tester.tapAt(ePos, pointer: 7);
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 7);
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection),
renderEditable,
);
expect(endpoints.length, 2);
// On Mac, the toolbar blocks the drag on the right handle, so hide it.
final EditableTextState editableTextState = tester.state(find.byType(EditableText));
editableTextState.hideToolbar(false);
await tester.pumpAndSettle();
// Drag the right handle until there's only 1 char selected.
// We use a small offset because the endpoint is on the very corner
// of the handle.
final Offset handlePos = endpoints[1].point;
Offset newHandlePos = textOffsetToPosition(tester, 5); // Position of 'e'.
final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 5);
newHandlePos = textOffsetToPosition(tester, 2); // Position of 'c'.
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
// The selection inverts moving beyond the left handle.
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 2);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('Dragging between multiple lines keeps the contact point at the same place on the handle on Android', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(

View File

@ -197,8 +197,9 @@ void main() {
editable.paint(context, paintOffset);
final List<LeaderLayer> leaderLayers = context.pushedLayers.whereType<LeaderLayer>().toList();
expect(leaderLayers, hasLength(1), reason: '_paintHandleLayers will paint a LeaderLayer');
expect(leaderLayers.single.offset, endpoint + paintOffset, reason: 'offset should respect paintOffset');
expect(leaderLayers, hasLength(2), reason: '_paintHandleLayers will paint LeaderLayers');
expect(leaderLayers.first.offset, endpoint + paintOffset, reason: 'offset should respect paintOffset');
expect(leaderLayers.last.offset, endpoint + paintOffset, reason: 'offset should respect paintOffset');
});
// Test that clipping will be used even when the text fits within the visible