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();
} }
......
...@@ -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