Unverified Commit 525e76bc authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Positioning IME bars on iOS (#61981)

parent 78e54dd4
......@@ -1587,6 +1587,30 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}
}
/// Returns the smallest [Rect], in the local coordinate system, that covers
/// the text within the [TextRange] specified.
///
/// This method is used to calculate the approximate position of the IME bar
/// on iOS.
///
/// Returns null if [TextRange.isValid] is false for the given `range`, or the
/// given `range` is collapsed.
Rect? getRectForComposingRange(TextRange range) {
assert(constraints != null);
if (!range.isValid || range.isCollapsed)
return null;
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
final List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(
TextSelection(baseOffset: range.start, extentOffset: range.end),
);
return boxes.fold(
null,
(Rect? accum, TextBox incoming) => accum?.expandToInclude(incoming.toRect()) ?? incoming.toRect(),
)?.shift(_paintOffset);
}
/// Returns the position in the text for the given global coordinate.
///
/// See also:
......
......@@ -9,6 +9,7 @@ import 'dart:ui' show
FontWeight,
Offset,
Size,
Rect,
TextAffinity,
TextAlign,
TextDirection,
......@@ -839,6 +840,7 @@ class TextInputConnection {
Size? _cachedSize;
Matrix4? _cachedTransform;
Rect? _cachedRect;
static int _nextId = 1;
final int _id;
......@@ -916,6 +918,29 @@ class TextInputConnection {
}
}
/// Send the smallest rect that covers the text in the client that's currently
/// being composed.
///
/// The given `rect` can not be null. If any of the 4 coordinates of the given
/// [Rect] is not finite, a [Rect] of size (-1, -1) will be sent instead.
///
/// The information is currently only used on iOS, for positioning the IME bar.
void setComposingRect(Rect rect) {
assert(rect != null);
if (rect == _cachedRect)
return;
_cachedRect = rect;
final Rect validRect = rect.isFinite ? rect : Offset.zero & const Size(-1, -1);
TextInput._instance._setComposingTextRect(
<String, dynamic>{
'width': validRect.width,
'height': validRect.height,
'x': validRect.left,
'y': validRect.top,
},
);
}
/// Send text styling information.
///
/// This information is used by the Flutter Web Engine to change the style
......@@ -1249,6 +1274,13 @@ class TextInput {
);
}
void _setComposingTextRect(Map<String, dynamic> args) {
_channel.invokeMethod<void>(
'TextInput.setMarkedTextRect',
args,
);
}
void _setStyle(Map<String, dynamic> args) {
_channel.invokeMethod<void>(
'TextInput.setStyle',
......
......@@ -903,7 +903,7 @@ class TextSelectionGestureDetectorBuilder {
/// The [RenderObject] of the [EditableText] for which the builder will
/// provide a [TextSelectionGestureDetector].
@protected
RenderEditable get renderEditable => editableText!.renderEditable!;
RenderEditable get renderEditable => editableText!.renderEditable;
/// Handler for [TextSelectionGestureDetector.onTapDown].
///
......
......@@ -1003,6 +1003,59 @@ void main() {
});
});
group('getRectForComposingRange', () {
const TextSpan emptyTextSpan = TextSpan(text: '\u200e');
final TextSelectionDelegate delegate = FakeEditableTextState();
final RenderEditable editable = RenderEditable(
maxLines: null,
textAlign: TextAlign.start,
textDirection: TextDirection.ltr,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
);
test('returns null when no composing range', () {
editable.text = const TextSpan(text: '123');
editable.layout(const BoxConstraints.tightFor(width: 200));
// Invalid range.
expect(editable.getRectForComposingRange(const TextRange(start: -1, end: 2)), isNull);
// Collapsed range.
expect(editable.getRectForComposingRange(const TextRange.collapsed(2)), isNull);
// Empty Editable.
editable.text = emptyTextSpan;
editable.layout(const BoxConstraints.tightFor(width: 200));
expect(
editable.getRectForComposingRange(const TextRange(start: 0, end: 1)),
// On web this evaluates to a zero-width Rect.
anyOf(isNull, (Rect rect) => rect.width == 0));
});
test('more than 1 run on the same line', () {
const TextStyle tinyText = TextStyle(fontSize: 1, fontFamily: 'Ahem');
const TextStyle normalText = TextStyle(fontSize: 10, fontFamily: 'Ahem');
editable.text = TextSpan(
children: <TextSpan>[
const TextSpan(text: 'A', style: tinyText),
TextSpan(text: 'A' * 20, style: normalText),
const TextSpan(text: 'A', style: tinyText)
],
);
// Give it a width that forces the editable to wrap.
editable.layout(const BoxConstraints.tightFor(width: 200));
final Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 0, end: 20 + 2));
// Since the range covers an entire line, the Rect should also be almost
// as wide as the entire paragraph (give or take 1 character).
expect(composingRect?.width, greaterThan(200 - 10));
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/66089
});
group('previousCharacter', () {
test('handles normal strings correctly', () {
expect(RenderEditable.previousCharacter(8, '01234567'), 7);
......
......@@ -18,6 +18,30 @@ import '../rendering/mock_canvas.dart';
import 'editable_text_utils.dart';
import 'semantics_tester.dart';
Matcher matchesMethodCall(String method, { dynamic args }) => _MatchesMethodCall(method, arguments: args == null ? null : wrapMatcher(args));
class _MatchesMethodCall extends Matcher {
const _MatchesMethodCall(this.name, {this.arguments});
final String name;
final Matcher arguments;
@override
bool matches(dynamic item, Map<dynamic, dynamic> matchState) {
if (item is MethodCall && item.method == name)
return arguments?.matches(item.arguments, matchState) ?? true;
return false;
}
@override
Description describe(Description description) {
final Description newDescription = description.add('has method name: ').addDescriptionOf(name);
if (arguments != null)
newDescription.add(' with arguments: ').addDescriptionOf(arguments);
return newDescription;
}
}
TextEditingController controller;
final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Node');
final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: 'EditableText Scope Node');
......@@ -3326,6 +3350,98 @@ void main() {
);
});
group('setMarkedTextRect', () {
Widget builder() {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: FocusNode(),
style: textStyle,
cursorColor: Colors.blue,
selectionControls: materialTextSelectionControls,
keyboardType: TextInputType.text,
onChanged: (String value) {},
),
),
),
),
),
);
}
testWidgets(
'called when the composing range changes',
(WidgetTester tester) async {
controller.value = TextEditingValue(text: 'a' * 100);
await tester.pumpWidget(builder());
await tester.showKeyboard(find.byType(EditableText));
expect(tester.testTextInput.log, contains(
matchesMethodCall(
'TextInput.setMarkedTextRect',
args: allOf(
// No composing text so the width should not be too wide because
// it's empty.
containsPair('width', lessThanOrEqualTo(5)),
containsPair('x', lessThanOrEqualTo(1)),
),
),
));
tester.testTextInput.log.clear();
controller.value = TextEditingValue(text: 'a' * 100, composing: const TextRange(start: 0, end: 10));
await tester.pump();
expect(tester.testTextInput.log, contains(
matchesMethodCall(
'TextInput.setMarkedTextRect',
// Now the composing range is not empty.
args: containsPair('width', greaterThanOrEqualTo(10)),
),
));
}, skip: isBrowser); // Related to https://github.com/flutter/flutter/issues/66089
testWidgets(
'only send updates when necessary',
(WidgetTester tester) async {
controller.value = TextEditingValue(text: 'a' * 100, composing: const TextRange(start: 0, end: 10));
await tester.pumpWidget(builder());
await tester.showKeyboard(find.byType(EditableText));
expect(tester.testTextInput.log, contains(matchesMethodCall('TextInput.setMarkedTextRect')));
tester.testTextInput.log.clear();
// Should not send updates every frame.
await tester.pump();
expect(tester.testTextInput.log, isNot(contains(matchesMethodCall('TextInput.setMarkedTextRect'))));
});
testWidgets(
'does not throw when sending infinite Rect',
(WidgetTester tester) async {
controller.value = TextEditingValue(text: 'a' * 100, composing: const TextRange(start: 0, end: 10));
await tester.pumpWidget(FittedBox(child: SizedBox.fromSize(size: Size.zero, child: builder())));
await tester.showKeyboard(find.byType(EditableText));
expect(tester.testTextInput.log, contains(matchesMethodCall('TextInput.setMarkedTextRect', args: <String, dynamic> {
'width': -1,
'height': -1,
'x': 0,
'y': 0,
})));
});
});
testWidgets('custom keyboardAppearance is respected', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/22212.
......@@ -4645,17 +4761,16 @@ void main() {
'TextInput.setClient',
'TextInput.show',
'TextInput.setEditableSizeAndTransform',
'TextInput.setMarkedTextRect',
'TextInput.setStyle',
'TextInput.setEditingState',
'TextInput.setEditingState',
'TextInput.show',
];
expect(tester.testTextInput.log.length, 7);
int index = 0;
for (final MethodCall m in tester.testTextInput.log) {
expect(m.method, logOrder[index]);
index++;
}
expect(
tester.testTextInput.log.map((MethodCall m) => m.method),
logOrder,
);
});
testWidgets('setEditingState is not called when text changes', (WidgetTester tester) async {
......@@ -4689,6 +4804,7 @@ void main() {
'TextInput.setClient',
'TextInput.show',
'TextInput.setEditableSizeAndTransform',
'TextInput.setMarkedTextRect',
'TextInput.setStyle',
'TextInput.setEditingState',
'TextInput.setEditingState',
......@@ -4736,18 +4852,18 @@ void main() {
'TextInput.setClient',
'TextInput.show',
'TextInput.setEditableSizeAndTransform',
'TextInput.setMarkedTextRect',
'TextInput.setStyle',
'TextInput.setEditingState',
'TextInput.setEditingState',
'TextInput.show',
'TextInput.setEditingState',
];
expect(tester.testTextInput.log.length, logOrder.length);
int index = 0;
for (final MethodCall m in tester.testTextInput.log) {
expect(m.method, logOrder[index]);
index++;
}
expect(
tester.testTextInput.log.map((MethodCall m) => m.method),
logOrder,
);
expect(tester.testTextInput.editingState['text'], 'flutter is the best!...');
});
......
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