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();
}
......
......@@ -2,13 +2,16 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' as ui show BoxHeightStyle;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.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 {
Object _clipboardData = <String, dynamic>{
......@@ -80,6 +83,15 @@ void main() {
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', () {
Widget createEditableText({
Key? key,
......@@ -588,4 +600,249 @@ void main() {
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() {
),
),
);
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