Unverified Commit 5671a97f authored by Renzo Olivares's avatar Renzo Olivares Committed by GitHub

Scale selection handles for rich text on iOS (#85789)

parent b81165d2
...@@ -67,13 +67,13 @@ class _CupertinoDesktopTextSelectionControls extends TextSelectionControls { ...@@ -67,13 +67,13 @@ class _CupertinoDesktopTextSelectionControls extends TextSelectionControls {
/// Builds the text selection handles, but desktop has none. /// Builds the text selection handles, but desktop has none.
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
/// Gets the position for the text selection handles, but desktop has none. /// Gets the position for the text selection handles, but desktop has none.
@override @override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
return Offset.zero; return Offset.zero;
} }
} }
......
...@@ -247,18 +247,19 @@ class CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -247,18 +247,19 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
/// Builder for iOS text selection edges. /// Builder for iOS text selection edges.
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
// iOS selection handles do not respond to taps. // iOS selection handles do not respond to taps.
// We want a size that's a vertical line the height of the text plus a 18.0 // We want a size that's a vertical line the height of the text plus a 18.0
// padding in every direction that will constitute the selection drag area. // padding in every direction that will constitute the selection drag area.
final Size desiredSize = getHandleSize(textLineHeight); startGlyphHeight = startGlyphHeight ?? textLineHeight;
endGlyphHeight = endGlyphHeight ?? textLineHeight;
final Widget handle = SizedBox.fromSize( final Size desiredSize;
size: desiredSize, final Widget handle;
child: CustomPaint(
painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor), final Widget customPaint = CustomPaint(
), painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
); );
// [buildHandle]'s widget is positioned at the selection cursor's bottom // [buildHandle]'s widget is positioned at the selection cursor's bottom
...@@ -266,9 +267,18 @@ class CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -266,9 +267,18 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
// on top of the text selection endpoints. // on top of the text selection endpoints.
switch (type) { switch (type) {
case TextSelectionHandleType.left: case TextSelectionHandleType.left:
desiredSize = getHandleSize(startGlyphHeight);
handle = SizedBox.fromSize(
size: desiredSize,
child: customPaint,
);
return handle; return handle;
case TextSelectionHandleType.right: case TextSelectionHandleType.right:
// Right handle is a vertical mirror of the left. desiredSize = getHandleSize(endGlyphHeight);
handle = SizedBox.fromSize(
size: desiredSize,
child: customPaint,
);
return Transform( return Transform(
transform: Matrix4.identity() transform: Matrix4.identity()
..translate(desiredSize.width / 2, desiredSize.height / 2) ..translate(desiredSize.width / 2, desiredSize.height / 2)
...@@ -286,12 +296,17 @@ class CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -286,12 +296,17 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
/// ///
/// See [TextSelectionControls.getHandleAnchor]. /// See [TextSelectionControls.getHandleAnchor].
@override @override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
final Size handleSize = getHandleSize(textLineHeight); startGlyphHeight = startGlyphHeight ?? textLineHeight;
endGlyphHeight = endGlyphHeight ?? textLineHeight;
final Size handleSize;
switch (type) { switch (type) {
// The circle is at the top for the left handle, and the anchor point is // The circle is at the top for the left handle, and the anchor point is
// all the way at the bottom of the line. // all the way at the bottom of the line.
case TextSelectionHandleType.left: case TextSelectionHandleType.left:
handleSize = getHandleSize(startGlyphHeight);
return Offset( return Offset(
handleSize.width / 2, handleSize.width / 2,
handleSize.height, handleSize.height,
...@@ -299,12 +314,14 @@ class CupertinoTextSelectionControls extends TextSelectionControls { ...@@ -299,12 +314,14 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
// The right handle is vertically flipped, and the anchor point is near // The right handle is vertically flipped, and the anchor point is near
// the top of the circle to give slight overlap. // the top of the circle to give slight overlap.
case TextSelectionHandleType.right: case TextSelectionHandleType.right:
handleSize = getHandleSize(endGlyphHeight);
return Offset( return Offset(
handleSize.width / 2, handleSize.width / 2,
handleSize.height - 2 * _kSelectionHandleRadius + _kSelectionHandleOverlap, handleSize.height - 2 * _kSelectionHandleRadius + _kSelectionHandleOverlap,
); );
// A collapsed handle anchors itself so that it's centered. // A collapsed handle anchors itself so that it's centered.
case TextSelectionHandleType.collapsed: case TextSelectionHandleType.collapsed:
handleSize = getHandleSize(textLineHeight);
return Offset( return Offset(
handleSize.width / 2, handleSize.width / 2,
textLineHeight + (handleSize.height - textLineHeight) / 2, textLineHeight + (handleSize.height - textLineHeight) / 2,
......
...@@ -53,13 +53,13 @@ class _DesktopTextSelectionControls extends TextSelectionControls { ...@@ -53,13 +53,13 @@ class _DesktopTextSelectionControls extends TextSelectionControls {
/// Builds the text selection handles, but desktop has none. /// Builds the text selection handles, but desktop has none.
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
/// Gets the position for the text selection handles, but desktop has none. /// Gets the position for the text selection handles, but desktop has none.
@override @override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
return Offset.zero; return Offset.zero;
} }
......
...@@ -54,7 +54,7 @@ class MaterialTextSelectionControls extends TextSelectionControls { ...@@ -54,7 +54,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
/// Builder for material-style text selection handles. /// Builder for material-style text selection handles.
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight, [VoidCallback? onTap]) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
final ThemeData theme = Theme.of(context); final ThemeData theme = Theme.of(context);
final Color handleColor = TextSelectionTheme.of(context).selectionHandleColor ?? theme.colorScheme.primary; final Color handleColor = TextSelectionTheme.of(context).selectionHandleColor ?? theme.colorScheme.primary;
final Widget handle = SizedBox( final Widget handle = SizedBox(
...@@ -94,7 +94,7 @@ class MaterialTextSelectionControls extends TextSelectionControls { ...@@ -94,7 +94,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
/// ///
/// See [TextSelectionControls.getHandleAnchor]. /// See [TextSelectionControls.getHandleAnchor].
@override @override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
switch (type) { switch (type) {
case TextSelectionHandleType.left: case TextSelectionHandleType.left:
return const Offset(_kHandleSize, 0); return const Offset(_kHandleSize, 0);
......
...@@ -3088,7 +3088,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -3088,7 +3088,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
final Offset paintOffset = _paintOffset; final Offset paintOffset = _paintOffset;
final List<ui.TextBox> boxes = selection.isCollapsed ? final List<ui.TextBox> boxes = selection.isCollapsed ?
<ui.TextBox>[] : _textPainter.getBoxesForSelection(selection); <ui.TextBox>[] : _textPainter.getBoxesForSelection(selection, boxHeightStyle: selectionHeightStyle, boxWidthStyle: selectionWidthStyle);
if (boxes.isEmpty) { if (boxes.isEmpty) {
// TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary. // TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
final Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype); final Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype);
...@@ -3119,6 +3119,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -3119,6 +3119,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
final List<ui.TextBox> boxes = _textPainter.getBoxesForSelection( final List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(
TextSelection(baseOffset: range.start, extentOffset: range.end), TextSelection(baseOffset: range.start, extentOffset: range.end),
boxHeightStyle: selectionHeightStyle,
boxWidthStyle: selectionWidthStyle,
); );
return boxes.fold( return boxes.fold(
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
...@@ -119,12 +120,12 @@ abstract class TextSelectionControls { ...@@ -119,12 +120,12 @@ abstract class TextSelectionControls {
/// interaction is allowed. As a counterexample, the default selection handle /// interaction is allowed. As a counterexample, the default selection handle
/// on iOS [cupertinoTextSelectionControls] does not call [onTap] at all, /// on iOS [cupertinoTextSelectionControls] does not call [onTap] at all,
/// since its handles are not meant to be tapped. /// since its handles are not meant to be tapped.
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]); Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]);
/// Get the anchor point of the handle relative to itself. The anchor point is /// Get the anchor point of the handle relative to itself. The anchor point is
/// the point that is aligned with a specific point in the text. A handle /// the point that is aligned with a specific point in the text. A handle
/// often visually "points to" that location. /// often visually "points to" that location.
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight); Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]);
/// Builds a toolbar near a text selection. /// Builds a toolbar near a text selection.
/// ///
...@@ -824,9 +825,47 @@ class _TextSelectionHandleOverlayState ...@@ -824,9 +825,47 @@ class _TextSelectionHandleOverlayState
break; break;
} }
// On some platforms we may want to calculate the start and end handles
// separately so they scale for the selected content.
//
// For the start handle we compute the rectangles that encompass the range
// of the first full selected grapheme cluster at the beginning of the selection.
//
// For the end handle we compute the rectangles that encompass the range
// of the last full selected grapheme cluster at the end of the selection.
final InlineSpan span = widget.renderObject.text!;
final String text = span.toPlainText();
final int firstSelectedGraphemeExtent;
final int lastSelectedGraphemeExtent;
final TextSelection? selection = widget.renderObject.selection;
if (selection != null && selection.isValid && !selection.isCollapsed) {
final String selectedGraphemes = selection.textInside(text);
firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
assert(firstSelectedGraphemeExtent <= selectedGraphemes.length && lastSelectedGraphemeExtent <= selectedGraphemes.length);
} else {
// The call to selectedGraphemes.characters.first/last will throw a state
// error if the given text is empty, so fall back to first/last character
// range in this case.
//
// The call to widget.selection.textInside(text) will return a RangeError
// for a collapsed selection, fall back to this case when that happens.
firstSelectedGraphemeExtent = 0;
lastSelectedGraphemeExtent = 0;
}
final Rect? startHandleRect = widget.renderObject.getRectForComposingRange(TextRange(start: widget.selection.start, end: widget.selection.start + firstSelectedGraphemeExtent));
final Rect? endHandleRect = widget.renderObject.getRectForComposingRange(TextRange(start: widget.selection.end - lastSelectedGraphemeExtent, end: widget.selection.end));
assert(!(firstSelectedGraphemeExtent > 0 && widget.selection.isValid && !widget.selection.isCollapsed) || startHandleRect != null);
assert(!(lastSelectedGraphemeExtent > 0 && widget.selection.isValid && !widget.selection.isCollapsed) || endHandleRect != null);
final Offset handleAnchor = widget.selectionControls.getHandleAnchor( final Offset handleAnchor = widget.selectionControls.getHandleAnchor(
type, type,
widget.renderObject.preferredLineHeight, widget.renderObject.preferredLineHeight,
startHandleRect?.height ?? widget.renderObject.preferredLineHeight,
endHandleRect?.height ?? widget.renderObject.preferredLineHeight,
); );
final Size handleSize = widget.selectionControls.getHandleSize( final Size handleSize = widget.selectionControls.getHandleSize(
widget.renderObject.preferredLineHeight, widget.renderObject.preferredLineHeight,
...@@ -877,6 +916,8 @@ class _TextSelectionHandleOverlayState ...@@ -877,6 +916,8 @@ class _TextSelectionHandleOverlayState
type, type,
widget.renderObject.preferredLineHeight, widget.renderObject.preferredLineHeight,
widget.onSelectionHandleTapped, widget.onSelectionHandleTapped,
startHandleRect?.height ?? widget.renderObject.preferredLineHeight,
endHandleRect?.height ?? widget.renderObject.preferredLineHeight,
), ),
), ),
), ),
......
...@@ -35,7 +35,7 @@ class MockClipboard { ...@@ -35,7 +35,7 @@ class MockClipboard {
class MockTextSelectionControls extends TextSelectionControls { class MockTextSelectionControls extends TextSelectionControls {
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
throw UnimplementedError(); throw UnimplementedError();
} }
...@@ -54,7 +54,7 @@ class MockTextSelectionControls extends TextSelectionControls { ...@@ -54,7 +54,7 @@ class MockTextSelectionControls extends TextSelectionControls {
} }
@override @override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
throw UnimplementedError(); throw UnimplementedError();
} }
......
...@@ -2,13 +2,16 @@ ...@@ -2,13 +2,16 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui' as ui show BoxHeightStyle;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition; import '../widgets/editable_text_utils.dart' show textOffsetToPosition, findRenderEditable;
class MockClipboard { class MockClipboard {
Object _clipboardData = <String, dynamic>{ Object _clipboardData = <String, dynamic>{
...@@ -80,6 +83,15 @@ void main() { ...@@ -80,6 +83,15 @@ void main() {
return button.pressedOpacity! < 1.0; return button.pressedOpacity! < 1.0;
} }
List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
return points.map<TextSelectionPoint>((TextSelectionPoint point) {
return TextSelectionPoint(
box.localToGlobal(point.point),
point.direction,
);
}).toList();
}
group('canSelectAll', () { group('canSelectAll', () {
Widget createEditableText({ Widget createEditableText({
Key? key, Key? key,
...@@ -588,4 +600,249 @@ void main() { ...@@ -588,4 +600,249 @@ void main() {
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }), variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
); );
}); });
testWidgets('iOS selection handles scale with rich text (selection style 1)', (WidgetTester tester) async {
await tester.pumpWidget(
const CupertinoApp(
home: Center(
child: SelectableText.rich(
TextSpan(
children: <InlineSpan>[
TextSpan(text: 'abc ', style: TextStyle(fontSize: 100.0)),
TextSpan(text: 'def ', style: TextStyle(fontSize: 50.0)),
TextSpan(text: 'hij', style: TextStyle(fontSize: 25.0)),
],
),
),
),
),
);
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
final EditableTextState editableTextState = tester.state(find.byType(EditableText));
final TextEditingController controller = editableTextWidget.controller;
// Double tap to select the second word.
const int index = 4;
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue);
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 7);
// Drag the right handle 2 letters to the right. Placing the end handle on
// the third word. We use a small offset because the endpoint is on the very
// corner of the handle.
final TextSelection selection = controller.selection;
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(selection),
renderEditable,
);
expect(endpoints.length, 2);
final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
final Offset newHandlePos = textOffsetToPosition(tester, 11);
final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 11);
// Find start and end handles and verify their sizes.
expect(find.byType(Overlay), findsOneWidget);
expect(find.descendant(
of: find.byType(Overlay),
matching: find.byType(CustomPaint),
), findsNWidgets(2));
final Iterable<RenderBox> handles = tester.renderObjectList(find.descendant(
of: find.byType(Overlay),
matching: find.byType(CustomPaint),
));
// The handle height is determined by the formula:
// textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap .
// The text line height will be the value of the fontSize.
// The constant _kSelectionHandleRadius has the value of 6.
// The constant _kSelectionHandleOverlap has the value of 1.5.
// In the case of the start handle, which is located on the word 'def',
// 50.0 + 6 * 2 - 1.5 = 60.5 .
expect(handles.first.size.height, 60.5);
expect(handles.last.size.height, 35.5);
},
skip: isBrowser, // We do not use Flutter-rendered context menu on the Web
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
testWidgets('iOS selection handles scale with rich text (selection style 2)', (WidgetTester tester) async {
await tester.pumpWidget(
const CupertinoApp(
home: Center(
child: SelectableText.rich(
TextSpan(
children: <InlineSpan>[
TextSpan(text: 'abc ', style: TextStyle(fontSize: 100.0)),
TextSpan(text: 'def ', style: TextStyle(fontSize: 50.0)),
TextSpan(text: 'hij', style: TextStyle(fontSize: 25.0)),
],
),
selectionHeightStyle: ui.BoxHeightStyle.max,
),
),
),
);
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
final EditableTextState editableTextState = tester.state(find.byType(EditableText));
final TextEditingController controller = editableTextWidget.controller;
// Double tap to select the second word.
const int index = 4;
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue);
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 7);
// Drag the right handle 2 letters to the right. Placing the end handle on
// the third word. We use a small offset because the endpoint is on the very
// corner of the handle.
final TextSelection selection = controller.selection;
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(selection),
renderEditable,
);
expect(endpoints.length, 2);
final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
final Offset newHandlePos = textOffsetToPosition(tester, 11);
final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 11);
// Find start and end handles and verify their sizes.
expect(find.byType(Overlay), findsOneWidget);
expect(find.descendant(
of: find.byType(Overlay),
matching: find.byType(CustomPaint),
), findsNWidgets(2));
final Iterable<RenderBox> handles = tester.renderObjectList(find.descendant(
of: find.byType(Overlay),
matching: find.byType(CustomPaint),
));
// The handle height is determined by the formula:
// textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap .
// The text line height will be the value of the fontSize, of the largest word on the line.
// The constant _kSelectionHandleRadius has the value of 6.
// The constant _kSelectionHandleOverlap has the value of 1.5.
// In the case of the start handle, which is located on the word 'def',
// 100 + 6 * 2 - 1.5 = 110.5 .
// In this case both selection handles are the same size because the selection
// height style is set to BoxHeightStyle.max which means that the height of
// the selection highlight will be the height of the largest word on the line.
expect(handles.first.size.height, 110.5);
expect(handles.last.size.height, 110.5);
},
skip: isBrowser, // We do not use Flutter-rendered context menu on the Web
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
testWidgets('iOS selection handles scale with rich text (grapheme clusters)', (WidgetTester tester) async {
await tester.pumpWidget(
const CupertinoApp(
home: Center(
child: SelectableText.rich(
TextSpan(
children: <InlineSpan>[
TextSpan(text: 'abc ', style: TextStyle(fontSize: 100.0)),
TextSpan(text: 'def ', style: TextStyle(fontSize: 50.0)),
TextSpan(text: '👨‍👩‍👦 ', style: TextStyle(fontSize: 35.0)),
TextSpan(text: 'hij', style: TextStyle(fontSize: 25.0)),
],
),
),
),
),
);
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
final EditableTextState editableTextState = tester.state(find.byType(EditableText));
final TextEditingController controller = editableTextWidget.controller;
// Double tap to select the second word.
const int index = 4;
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue);
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 7);
// Drag the right handle 2 letters to the right. Placing the end handle on
// the third word. We use a small offset because the endpoint is on the very
// corner of the handle.
final TextSelection selection = controller.selection;
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(selection),
renderEditable,
);
expect(endpoints.length, 2);
final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
final Offset newHandlePos = textOffsetToPosition(tester, 16);
final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 16);
// Find start and end handles and verify their sizes.
expect(find.byType(Overlay), findsOneWidget);
expect(find.descendant(
of: find.byType(Overlay),
matching: find.byType(CustomPaint),
), findsNWidgets(2));
final Iterable<RenderBox> handles = tester.renderObjectList(find.descendant(
of: find.byType(Overlay),
matching: find.byType(CustomPaint),
));
// The handle height is determined by the formula:
// textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap .
// The text line height will be the value of the fontSize.
// The constant _kSelectionHandleRadius has the value of 6.
// The constant _kSelectionHandleOverlap has the value of 1.5.
// In the case of the end handle, which is located on the grapheme cluster '👨‍👩‍👦',
// 35.0 + 6 * 2 - 1.5 = 45.5 .
expect(handles.first.size.height, 60.5);
expect(handles.last.size.height, 45.5);
},
skip: isBrowser, // We do not use Flutter-rendered context menu on the Web
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
} }
...@@ -833,13 +833,28 @@ void main() { ...@@ -833,13 +833,28 @@ void main() {
), ),
), ),
); );
final EditableTextState editableTextState = tester.state(find.byType(EditableText));
final Offset textfieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); // Double tap to select the first word.
const int index = 4;
await tester.longPressAt(textfieldStart + const Offset(50.0, 2.0)); await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50)); await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textfieldStart + const Offset(100.0, 107.0)); await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 300)); await tester.pumpAndSettle();
expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue);
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 7);
// Use toolbar to select all text.
if (isContextMenuProvidedByPlatform) {
controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length);
expect(controller.selection.extentOffset, controller.text.length);
} else {
await tester.tap(find.text('Select all'));
await tester.pump();
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, controller.text.length);
}
await expectLater( await expectLater(
find.byType(MaterialApp), find.byType(MaterialApp),
......
...@@ -7797,7 +7797,7 @@ class MockTextSelectionControls extends Fake implements TextSelectionControls { ...@@ -7797,7 +7797,7 @@ class MockTextSelectionControls extends Fake implements TextSelectionControls {
} }
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
return Container(); return Container();
} }
...@@ -7807,7 +7807,7 @@ class MockTextSelectionControls extends Fake implements TextSelectionControls { ...@@ -7807,7 +7807,7 @@ class MockTextSelectionControls extends Fake implements TextSelectionControls {
} }
@override @override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
return Offset.zero; return Offset.zero;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment