diff --git a/packages/flutter/lib/src/cupertino/magnifier.dart b/packages/flutter/lib/src/cupertino/magnifier.dart index cf749f5ca60..9713a97f7af 100644 --- a/packages/flutter/lib/src/cupertino/magnifier.dart +++ b/packages/flutter/lib/src/cupertino/magnifier.dart @@ -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 @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 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.'); diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 4b2a973bb70..d6f5eddc3c6 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -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, + ); } } diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 47d4d3e8c2f..c17cf524080 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -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, diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index a0f2df72df9..ee31e303cb4 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -1199,12 +1199,12 @@ class SelectableRegionState extends State 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, diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index b39a8bf0943..d794c054ae1 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -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( diff --git a/packages/flutter/test/cupertino/magnifier_test.dart b/packages/flutter/test/cupertino/magnifier_test.dart index cc241a31a4d..bb777a6a596 100644 --- a/packages/flutter/test/cupertino/magnifier_test.dart +++ b/packages/flutter/test/cupertino/magnifier_test.dart @@ -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 magnifier = + ValueNotifier( + 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(find.byType(CupertinoMagnifier)).borderSide.color; + expect(magnifierBorderColor, equals(Colors.green)); + }); + group('position', () { Offset getMagnifierPosition(WidgetTester tester) { final AnimatedPositioned animatedPositioned = diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 9ea584addaa..8fdd70b7fe7 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -5223,7 +5223,7 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); }, variant: const TargetPlatformVariant({ 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.iOS })); + }, variant: TargetPlatformVariant.all(excluding: { 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 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( diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index 4253499dc90..5662be4f925 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -197,8 +197,9 @@ void main() { editable.paint(context, paintOffset); final List leaderLayers = context.pushedLayers.whereType().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