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 {
/// Builds the text selection handles, but desktop has none.
@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();
}
/// Gets the position for the text selection handles, but desktop has none.
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
return Offset.zero;
}
}
......
......@@ -247,18 +247,19 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
/// Builder for iOS text selection edges.
@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.
// 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.
final Size desiredSize = getHandleSize(textLineHeight);
startGlyphHeight = startGlyphHeight ?? textLineHeight;
endGlyphHeight = endGlyphHeight ?? textLineHeight;
final Widget handle = SizedBox.fromSize(
size: desiredSize,
child: CustomPaint(
painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
),
final Size desiredSize;
final Widget handle;
final Widget customPaint = CustomPaint(
painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
);
// [buildHandle]'s widget is positioned at the selection cursor's bottom
......@@ -266,9 +267,18 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
// on top of the text selection endpoints.
switch (type) {
case TextSelectionHandleType.left:
desiredSize = getHandleSize(startGlyphHeight);
handle = SizedBox.fromSize(
size: desiredSize,
child: customPaint,
);
return handle;
case TextSelectionHandleType.right:
// Right handle is a vertical mirror of the left.
desiredSize = getHandleSize(endGlyphHeight);
handle = SizedBox.fromSize(
size: desiredSize,
child: customPaint,
);
return Transform(
transform: Matrix4.identity()
..translate(desiredSize.width / 2, desiredSize.height / 2)
......@@ -286,12 +296,17 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
///
/// See [TextSelectionControls.getHandleAnchor].
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
final Size handleSize = getHandleSize(textLineHeight);
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
startGlyphHeight = startGlyphHeight ?? textLineHeight;
endGlyphHeight = endGlyphHeight ?? textLineHeight;
final Size handleSize;
switch (type) {
// The circle is at the top for the left handle, and the anchor point is
// all the way at the bottom of the line.
case TextSelectionHandleType.left:
handleSize = getHandleSize(startGlyphHeight);
return Offset(
handleSize.width / 2,
handleSize.height,
......@@ -299,12 +314,14 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
// The right handle is vertically flipped, and the anchor point is near
// the top of the circle to give slight overlap.
case TextSelectionHandleType.right:
handleSize = getHandleSize(endGlyphHeight);
return Offset(
handleSize.width / 2,
handleSize.height - 2 * _kSelectionHandleRadius + _kSelectionHandleOverlap,
);
// A collapsed handle anchors itself so that it's centered.
case TextSelectionHandleType.collapsed:
handleSize = getHandleSize(textLineHeight);
return Offset(
handleSize.width / 2,
textLineHeight + (handleSize.height - textLineHeight) / 2,
......
......@@ -53,13 +53,13 @@ class _DesktopTextSelectionControls extends TextSelectionControls {
/// Builds the text selection handles, but desktop has none.
@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();
}
/// Gets the position for the text selection handles, but desktop has none.
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
return Offset.zero;
}
......
......@@ -54,7 +54,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
/// Builder for material-style text selection handles.
@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 Color handleColor = TextSelectionTheme.of(context).selectionHandleColor ?? theme.colorScheme.primary;
final Widget handle = SizedBox(
......@@ -94,7 +94,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
///
/// See [TextSelectionControls.getHandleAnchor].
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
switch (type) {
case TextSelectionHandleType.left:
return const Offset(_kHandleSize, 0);
......
......@@ -3088,7 +3088,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
final Offset paintOffset = _paintOffset;
final List<ui.TextBox> boxes = selection.isCollapsed ?
<ui.TextBox>[] : _textPainter.getBoxesForSelection(selection);
<ui.TextBox>[] : _textPainter.getBoxesForSelection(selection, boxHeightStyle: selectionHeightStyle, boxWidthStyle: selectionWidthStyle);
if (boxes.isEmpty) {
// TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
final Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype);
......@@ -3119,6 +3119,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
final List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(
TextSelection(baseOffset: range.start, extentOffset: range.end),
boxHeightStyle: selectionHeightStyle,
boxWidthStyle: selectionWidthStyle,
);
return boxes.fold(
......
......@@ -5,6 +5,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
......@@ -119,12 +120,12 @@ abstract class TextSelectionControls {
/// interaction is allowed. As a counterexample, the default selection handle
/// on iOS [cupertinoTextSelectionControls] does not call [onTap] at all,
/// 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
/// the point that is aligned with a specific point in the text. A handle
/// 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.
///
......@@ -824,9 +825,47 @@ class _TextSelectionHandleOverlayState
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(
type,
widget.renderObject.preferredLineHeight,
startHandleRect?.height ?? widget.renderObject.preferredLineHeight,
endHandleRect?.height ?? widget.renderObject.preferredLineHeight,
);
final Size handleSize = widget.selectionControls.getHandleSize(
widget.renderObject.preferredLineHeight,
......@@ -877,6 +916,8 @@ class _TextSelectionHandleOverlayState
type,
widget.renderObject.preferredLineHeight,
widget.onSelectionHandleTapped,
startHandleRect?.height ?? widget.renderObject.preferredLineHeight,
endHandleRect?.height ?? widget.renderObject.preferredLineHeight,
),
),
),
......
......@@ -35,7 +35,7 @@ class MockClipboard {
class MockTextSelectionControls extends TextSelectionControls {
@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();
}
......@@ -54,7 +54,7 @@ class MockTextSelectionControls extends TextSelectionControls {
}
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
throw UnimplementedError();
}
......
......@@ -833,13 +833,28 @@ void main() {
),
),
);
final EditableTextState editableTextState = tester.state(find.byType(EditableText));
final Offset textfieldStart = tester.getTopLeft(find.byKey(const Key('field0')));
await tester.longPressAt(textfieldStart + const Offset(50.0, 2.0));
// Double tap to select the first word.
const int index = 4;
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textfieldStart + const Offset(100.0, 107.0));
await tester.pump(const Duration(milliseconds: 300));
await tester.tapAt(textOffsetToPosition(tester, index));
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(
find.byType(MaterialApp),
......
......@@ -7797,7 +7797,7 @@ class MockTextSelectionControls extends Fake implements TextSelectionControls {
}
@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();
}
......@@ -7807,7 +7807,7 @@ class MockTextSelectionControls extends Fake implements TextSelectionControls {
}
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) {
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