Skip to content

Commit 0737dbf

Browse files
Renzo-OlivaresRenzo Olivares
andauthored
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>
1 parent 0ca9f51 commit 0737dbf

File tree

8 files changed

+189
-24
lines changed

8 files changed

+189
-24
lines changed

packages/flutter/lib/src/cupertino/magnifier.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
library;
77

88
import 'dart:math' as math;
9+
910
import 'package:flutter/widgets.dart';
1011

12+
import 'theme.dart';
13+
1114
/// A [CupertinoMagnifier] used for magnifying text in cases where a user's
1215
/// finger may be blocking the point of interest, like a selection handle.
1316
///
@@ -215,6 +218,7 @@ class _CupertinoTextMagnifierState extends State<CupertinoTextMagnifier>
215218

216219
@override
217220
Widget build(BuildContext context) {
221+
final CupertinoThemeData themeData = CupertinoTheme.of(context);
218222
return AnimatedPositioned(
219223
duration: CupertinoTextMagnifier._kDragAnimationDuration,
220224
curve: widget.animationCurve,
@@ -223,6 +227,10 @@ class _CupertinoTextMagnifierState extends State<CupertinoTextMagnifier>
223227
child: CupertinoMagnifier(
224228
inOutAnimation: _ioAnimation,
225229
additionalFocalPointOffset: Offset(0, _verticalFocalPointAdjustment),
230+
borderSide: BorderSide(
231+
color: themeData.primaryColor,
232+
width: 2.0,
233+
),
226234
),
227235
);
228236
}
@@ -252,7 +260,7 @@ class CupertinoMagnifier extends StatelessWidget {
252260
/// Creates a [RawMagnifier] in the Cupertino style.
253261
///
254262
/// The default constructor parameters and constants were eyeballed on
255-
/// an iPhone XR iOS v15.5.
263+
/// an iPhone 16 iOS v18.1.
256264
const CupertinoMagnifier({
257265
super.key,
258266
this.size = kDefaultSize,
@@ -268,7 +276,10 @@ class CupertinoMagnifier extends StatelessWidget {
268276
],
269277
this.clipBehavior = Clip.none,
270278
this.borderSide =
271-
const BorderSide(color: Color.fromARGB(255, 232, 232, 232)),
279+
const BorderSide(
280+
color: Color.fromARGB(255, 0, 124, 255),
281+
width: 2.0,
282+
),
272283
this.inOutAnimation,
273284
this.magnificationScale = 1.0,
274285
}) : assert(magnificationScale > 0, 'The magnification scale should be greater than zero.');

packages/flutter/lib/src/rendering/editable.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2550,6 +2550,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
25502550
super.paint,
25512551
Offset.zero,
25522552
);
2553+
} else if (selection!.isCollapsed) {
2554+
context.pushLayer(
2555+
LeaderLayer(link: endHandleLayerLink, offset: startPoint + offset),
2556+
super.paint,
2557+
Offset.zero,
2558+
);
25532559
}
25542560
}
25552561

packages/flutter/lib/src/rendering/paragraph.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,19 +1394,29 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem
13941394
for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) {
13951395
selectionRects.add(textBox.toRect());
13961396
}
1397+
final bool selectionCollapsed = selectionStart == selectionEnd;
1398+
final (
1399+
TextSelectionHandleType startSelectionHandleType,
1400+
TextSelectionHandleType endSelectionHandleType,
1401+
) = switch ((selectionCollapsed, flipHandles)) {
1402+
// Always prefer collapsed handle when selection is collapsed.
1403+
(true, _) => (TextSelectionHandleType.collapsed, TextSelectionHandleType.collapsed),
1404+
(false, true) => (TextSelectionHandleType.right, TextSelectionHandleType.left),
1405+
(false, false) => (TextSelectionHandleType.left, TextSelectionHandleType.right),
1406+
};
13971407
return SelectionGeometry(
13981408
startSelectionPoint: SelectionPoint(
13991409
localPosition: startOffsetInParagraphCoordinates,
14001410
lineHeight: paragraph._textPainter.preferredLineHeight,
1401-
handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left
1411+
handleType: startSelectionHandleType,
14021412
),
14031413
endSelectionPoint: SelectionPoint(
14041414
localPosition: endOffsetInParagraphCoordinates,
14051415
lineHeight: paragraph._textPainter.preferredLineHeight,
1406-
handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right,
1416+
handleType: endSelectionHandleType,
14071417
),
14081418
selectionRects: selectionRects,
1409-
status: _textSelectionStart!.offset == _textSelectionEnd!.offset
1419+
status: selectionCollapsed
14101420
? SelectionStatus.collapsed
14111421
: SelectionStatus.uncollapsed,
14121422
hasContent: true,

packages/flutter/lib/src/widgets/selectable_region.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,12 +1199,12 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
11991199
_selectionOverlay = SelectionOverlay(
12001200
context: context,
12011201
debugRequiredFor: widget,
1202-
startHandleType: start?.handleType ?? TextSelectionHandleType.left,
1202+
startHandleType: start?.handleType ?? TextSelectionHandleType.collapsed,
12031203
lineHeightAtStart: start?.lineHeight ?? end!.lineHeight,
12041204
onStartHandleDragStart: _handleSelectionStartHandleDragStart,
12051205
onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
12061206
onStartHandleDragEnd: _onAnyDragEnd,
1207-
endHandleType: end?.handleType ?? TextSelectionHandleType.right,
1207+
endHandleType: end?.handleType ?? TextSelectionHandleType.collapsed,
12081208
lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight,
12091209
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
12101210
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,

packages/flutter/lib/src/widgets/text_selection.dart

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,9 @@ class TextSelectionOverlay {
696696
// corresponds to, in global coordinates.
697697
late double _endHandleDragTarget;
698698

699+
// The initial selection when a selection handle drag has started.
700+
TextSelection? _dragStartSelection;
701+
699702
void _handleSelectionEndHandleDragStart(DragStartDetails details) {
700703
if (!renderObject.attached) {
701704
return;
@@ -721,6 +724,7 @@ class TextSelectionOverlay {
721724
centerOfLineGlobal,
722725
),
723726
);
727+
_dragStartSelection ??= _selection;
724728

725729
_selectionOverlay.showMagnifier(
726730
_buildMagnifier(
@@ -760,6 +764,7 @@ class TextSelectionOverlay {
760764
if (!renderObject.attached) {
761765
return;
762766
}
767+
assert(_dragStartSelection != null);
763768

764769
// This is NOT the same as details.localPosition. That is relative to the
765770
// selection handle, whereas this is relative to the RenderEditable.
@@ -780,7 +785,7 @@ class TextSelectionOverlay {
780785

781786
final TextPosition position = renderObject.getPositionForPoint(handleTargetGlobal);
782787

783-
if (_selection.isCollapsed) {
788+
if (_dragStartSelection!.isCollapsed) {
784789
_selectionOverlay.updateMagnifier(_buildMagnifier(
785790
currentTextPosition: position,
786791
globalGesturePosition: details.globalPosition,
@@ -797,13 +802,15 @@ class TextSelectionOverlay {
797802
// On Apple platforms, dragging the base handle makes it the extent.
798803
case TargetPlatform.iOS:
799804
case TargetPlatform.macOS:
805+
// Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized
806+
// always returns true for a TextSelection.
807+
final bool dragStartSelectionNormalized = _dragStartSelection!.extentOffset >= _dragStartSelection!.baseOffset;
800808
newSelection = TextSelection(
809+
baseOffset: dragStartSelectionNormalized
810+
? _dragStartSelection!.baseOffset
811+
: _dragStartSelection!.extentOffset,
801812
extentOffset: position.offset,
802-
baseOffset: _selection.start,
803813
);
804-
if (position.offset <= _selection.start) {
805-
return; // Don't allow order swapping.
806-
}
807814
case TargetPlatform.android:
808815
case TargetPlatform.fuchsia:
809816
case TargetPlatform.linux:
@@ -859,6 +866,7 @@ class TextSelectionOverlay {
859866
centerOfLineGlobal,
860867
),
861868
);
869+
_dragStartSelection ??= _selection;
862870

863871
_selectionOverlay.showMagnifier(
864872
_buildMagnifier(
@@ -873,6 +881,7 @@ class TextSelectionOverlay {
873881
if (!renderObject.attached) {
874882
return;
875883
}
884+
assert(_dragStartSelection != null);
876885

877886
// This is NOT the same as details.localPosition. That is relative to the
878887
// selection handle, whereas this is relative to the RenderEditable.
@@ -890,7 +899,7 @@ class TextSelectionOverlay {
890899
);
891900
final TextPosition position = renderObject.getPositionForPoint(handleTargetGlobal);
892901

893-
if (_selection.isCollapsed) {
902+
if (_dragStartSelection!.isCollapsed) {
894903
_selectionOverlay.updateMagnifier(_buildMagnifier(
895904
currentTextPosition: position,
896905
globalGesturePosition: details.globalPosition,
@@ -907,13 +916,15 @@ class TextSelectionOverlay {
907916
// On Apple platforms, dragging the base handle makes it the extent.
908917
case TargetPlatform.iOS:
909918
case TargetPlatform.macOS:
919+
// Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized
920+
// always returns true for a TextSelection.
921+
final bool dragStartSelectionNormalized = _dragStartSelection!.extentOffset >= _dragStartSelection!.baseOffset;
910922
newSelection = TextSelection(
923+
baseOffset: dragStartSelectionNormalized
924+
? _dragStartSelection!.extentOffset
925+
: _dragStartSelection!.baseOffset,
911926
extentOffset: position.offset,
912-
baseOffset: _selection.end,
913927
);
914-
if (newSelection.extentOffset >= _selection.end) {
915-
return; // Don't allow order swapping.
916-
}
917928
case TargetPlatform.android:
918929
case TargetPlatform.fuchsia:
919930
case TargetPlatform.linux:
@@ -940,6 +951,7 @@ class TextSelectionOverlay {
940951
if (!context.mounted) {
941952
return;
942953
}
954+
_dragStartSelection = null;
943955
if (selectionControls is! TextSelectionHandleControls) {
944956
_selectionOverlay.hideMagnifier();
945957
if (!_selection.isCollapsed) {
@@ -1603,7 +1615,10 @@ class SelectionOverlay {
16031615
Widget _buildStartHandle(BuildContext context) {
16041616
final Widget handle;
16051617
final TextSelectionControls? selectionControls = this.selectionControls;
1606-
if (selectionControls == null) {
1618+
if (selectionControls == null
1619+
|| (_startHandleType == TextSelectionHandleType.collapsed && _isDraggingEndHandle)) {
1620+
// Hide the start handle when dragging the end handle and collapsing
1621+
// the selection.
16071622
handle = const SizedBox.shrink();
16081623
} else {
16091624
handle = _SelectionHandleOverlay(
@@ -1629,8 +1644,11 @@ class SelectionOverlay {
16291644
Widget _buildEndHandle(BuildContext context) {
16301645
final Widget handle;
16311646
final TextSelectionControls? selectionControls = this.selectionControls;
1632-
if (selectionControls == null || _startHandleType == TextSelectionHandleType.collapsed) {
1633-
// Hide the second handle when collapsed.
1647+
if (selectionControls == null
1648+
|| (_endHandleType == TextSelectionHandleType.collapsed && _isDraggingStartHandle)
1649+
|| (_endHandleType == TextSelectionHandleType.collapsed && !_isDraggingStartHandle && !_isDraggingEndHandle)) {
1650+
// Hide the end handle when dragging the start handle and collapsing the selection
1651+
// or when the selection is collapsed and no handle is being dragged.
16341652
handle = const SizedBox.shrink();
16351653
} else {
16361654
handle = _SelectionHandleOverlay(

packages/flutter/test/cupertino/magnifier_test.dart

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,52 @@ void main() {
3939
});
4040

4141
group('CupertinoTextEditingMagnifier', () {
42+
testWidgets('Magnifier border color inherits from parent CupertinoTheme', (WidgetTester tester) async {
43+
final Key fakeTextFieldKey = UniqueKey();
44+
45+
await tester.pumpWidget(
46+
MaterialApp(
47+
home: SizedBox.square(
48+
key: fakeTextFieldKey,
49+
dimension: 10,
50+
child: CupertinoTheme(
51+
data: const CupertinoThemeData(primaryColor: Colors.green),
52+
child: Builder(
53+
builder: (BuildContext context) {
54+
return const Placeholder();
55+
},
56+
),
57+
),
58+
),
59+
),
60+
);
61+
final BuildContext context = tester.element(find.byType(Placeholder));
62+
63+
// Magnifier should be positioned directly over the red square.
64+
final RenderBox tapPointRenderBox =
65+
tester.firstRenderObject(find.byKey(fakeTextFieldKey)) as RenderBox;
66+
final Rect fakeTextFieldRect =
67+
tapPointRenderBox.localToGlobal(Offset.zero) & tapPointRenderBox.size;
68+
69+
final ValueNotifier<MagnifierInfo> magnifier =
70+
ValueNotifier<MagnifierInfo>(
71+
MagnifierInfo(
72+
currentLineBoundaries: fakeTextFieldRect,
73+
fieldBounds: fakeTextFieldRect,
74+
caretRect: fakeTextFieldRect,
75+
// The tap position is dragBelow units below the text field.
76+
globalGesturePosition: fakeTextFieldRect.center,
77+
),
78+
);
79+
addTearDown(magnifier.dispose);
80+
81+
await showCupertinoMagnifier(context, tester, magnifier);
82+
83+
// Magnifier border color should inherit from CupertinoTheme.of(context).primaryColor.
84+
final Color magnifierBorderColor = tester.widget<CupertinoMagnifier>(find.byType(CupertinoMagnifier)).borderSide.color;
85+
expect(magnifierBorderColor, equals(Colors.green));
86+
});
87+
4288
group('position', () {
4389
Offset getMagnifierPosition(WidgetTester tester) {
4490
final AnimatedPositioned animatedPositioned =

0 commit comments

Comments
 (0)