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:
parent
0ca9f51227
commit
0737dbfcb6
@ -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.');
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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(
|
||||
|
@ -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 =
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user