Unverified Commit af89a25e authored by Jami Couch's avatar Jami Couch Committed by GitHub

Re-land "Support Scribble Handwriting" (#96615) (#97437)

parent e5cf7d18
...@@ -297,6 +297,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -297,6 +297,7 @@ class CupertinoTextField extends StatefulWidget {
this.autofillHints = const <String>[], this.autofillHints = const <String>[],
this.clipBehavior = Clip.hardEdge, this.clipBehavior = Clip.hardEdge,
this.restorationId, this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(readOnly != null), assert(readOnly != null),
...@@ -468,6 +469,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -468,6 +469,7 @@ class CupertinoTextField extends StatefulWidget {
this.autofillHints = const <String>[], this.autofillHints = const <String>[],
this.clipBehavior = Clip.hardEdge, this.clipBehavior = Clip.hardEdge,
this.restorationId, this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(readOnly != null), assert(readOnly != null),
...@@ -826,6 +828,9 @@ class CupertinoTextField extends StatefulWidget { ...@@ -826,6 +828,9 @@ class CupertinoTextField extends StatefulWidget {
/// {@macro flutter.material.textfield.restorationId} /// {@macro flutter.material.textfield.restorationId}
final String? restorationId; final String? restorationId;
/// {@macro flutter.widgets.editableText.scribbleEnabled}
final bool scribbleEnabled;
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning; final bool enableIMEPersonalizedLearning;
...@@ -871,6 +876,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -871,6 +876,7 @@ class CupertinoTextField extends StatefulWidget {
properties.add(DiagnosticsProperty<TextAlignVertical>('textAlignVertical', textAlignVertical, defaultValue: null)); properties.add(DiagnosticsProperty<TextAlignVertical>('textAlignVertical', textAlignVertical, defaultValue: null));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge)); properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge));
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
} }
} }
...@@ -991,6 +997,9 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -991,6 +997,9 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
if (cause == SelectionChangedCause.keyboard) if (cause == SelectionChangedCause.keyboard)
return false; return false;
if (cause == SelectionChangedCause.scribble)
return true;
if (_effectiveController.text.isNotEmpty) if (_effectiveController.text.isNotEmpty)
return true; return true;
...@@ -1320,6 +1329,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1320,6 +1329,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
autofillClient: this, autofillClient: this,
clipBehavior: widget.clipBehavior, clipBehavior: widget.clipBehavior,
restorationId: 'editable', restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
), ),
), ),
......
...@@ -329,6 +329,7 @@ class TextField extends StatefulWidget { ...@@ -329,6 +329,7 @@ class TextField extends StatefulWidget {
this.autofillHints = const <String>[], this.autofillHints = const <String>[],
this.clipBehavior = Clip.hardEdge, this.clipBehavior = Clip.hardEdge,
this.restorationId, this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(readOnly != null), assert(readOnly != null),
...@@ -781,6 +782,9 @@ class TextField extends StatefulWidget { ...@@ -781,6 +782,9 @@ class TextField extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final String? restorationId; final String? restorationId;
/// {@macro flutter.widgets.editableText.scribbleEnabled}
final bool scribbleEnabled;
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning; final bool enableIMEPersonalizedLearning;
...@@ -825,6 +829,7 @@ class TextField extends StatefulWidget { ...@@ -825,6 +829,7 @@ class TextField extends StatefulWidget {
properties.add(DiagnosticsProperty<ScrollController>('scrollController', scrollController, defaultValue: null)); properties.add(DiagnosticsProperty<ScrollController>('scrollController', scrollController, defaultValue: null));
properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null)); properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge)); properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge));
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
} }
} }
...@@ -1042,7 +1047,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1042,7 +1047,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
if (!_isEnabled) if (!_isEnabled)
return false; return false;
if (cause == SelectionChangedCause.longPress) if (cause == SelectionChangedCause.longPress || cause == SelectionChangedCause.scribble)
return true; return true;
if (_effectiveController.text.isNotEmpty) if (_effectiveController.text.isNotEmpty)
...@@ -1286,6 +1291,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1286,6 +1291,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
autocorrectionTextRectColor: autocorrectionTextRectColor, autocorrectionTextRectColor: autocorrectionTextRectColor,
clipBehavior: widget.clipBehavior, clipBehavior: widget.clipBehavior,
restorationId: 'editable', restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
), ),
), ),
......
...@@ -1265,6 +1265,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -1265,6 +1265,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
// [assembleSemanticsNode] invocations. // [assembleSemanticsNode] invocations.
Queue<SemanticsNode>? _cachedChildNodes; Queue<SemanticsNode>? _cachedChildNodes;
/// Returns a list of rects that bound the given selection.
///
/// See [TextPainter.getBoxesForSelection] for more details.
List<Rect> getBoxesForSelection(TextSelection selection) {
_computeTextMetricsIfNeeded();
return _textPainter.getBoxesForSelection(selection)
.map((TextBox textBox) => textBox.toRect().shift(_paintOffset))
.toList();
}
@override @override
void describeSemanticsConfiguration(SemanticsConfiguration config) { void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config); super.describeSemanticsConfiguration(config);
......
...@@ -955,6 +955,9 @@ enum SelectionChangedCause { ...@@ -955,6 +955,9 @@ enum SelectionChangedCause {
/// The user used the mouse to change the selection by dragging over a piece /// The user used the mouse to change the selection by dragging over a piece
/// of text. /// of text.
drag, drag,
/// The user used iPadOS 14+ Scribble to change the selection.
scribble,
} }
/// A mixin for manipulating the selection, provided for toolbar or shortcut /// A mixin for manipulating the selection, provided for toolbar or shortcut
...@@ -1105,6 +1108,76 @@ abstract class TextInputClient { ...@@ -1105,6 +1108,76 @@ abstract class TextInputClient {
/// ///
/// [TextInputClient] should cleanup its connection and finalize editing. /// [TextInputClient] should cleanup its connection and finalize editing.
void connectionClosed(); void connectionClosed();
/// Requests that the client show the editing toolbar, for example when the
/// platform changes the selection through a non-flutter method such as
/// scribble.
void showToolbar() {}
/// Requests that the client add a text placeholder to reserve visual space
/// in the text.
///
/// For example, this is called when responding to UIKit requesting
/// a text placeholder be added at the current selection, such as when
/// requesting additional writing space with iPadOS14 Scribble.
void insertTextPlaceholder(Size size) {}
/// Requests that the client remove the text placeholder.
void removeTextPlaceholder() {}
}
/// An interface to receive focus from the engine.
///
/// This is currently only used to handle UIIndirectScribbleInteraction.
abstract class ScribbleClient {
/// A unique identifier for this element.
String get elementIdentifier;
/// Called by the engine when the [ScribbleClient] should receive focus.
///
/// For example, this method is called during a UIIndirectScribbleInteraction.
void onScribbleFocus(Offset offset);
/// Tests whether the [ScribbleClient] overlaps the given rectangle bounds.
bool isInScribbleRect(Rect rect);
/// The current bounds of the [ScribbleClient].
Rect get bounds;
}
/// Represents a selection rect for a character and it's position in the text.
///
/// This is used to report the current text selection rect and position data
/// to the engine for Scribble support on iPadOS 14.
@immutable
class SelectionRect {
/// Constructor for creating a [SelectionRect] from a text [position] and
/// [bounds].
const SelectionRect({required this.position, required this.bounds});
/// The position of this selection rect within the text String.
final int position;
/// The rectangle representing the bounds of this selection rect within the
/// currently focused [RenderEditable]'s coordinate space.
final Rect bounds;
@override
bool operator ==(Object other) {
if (identical(this, other))
return true;
if (runtimeType != other.runtimeType)
return false;
return other is SelectionRect
&& other.position == position
&& other.bounds == bounds;
}
@override
int get hashCode => hashValues(position, bounds);
@override
String toString() => 'SelectionRect($position, $bounds)';
} }
/// An interface to receive granular information from [TextInput]. /// An interface to receive granular information from [TextInput].
...@@ -1154,6 +1227,7 @@ class TextInputConnection { ...@@ -1154,6 +1227,7 @@ class TextInputConnection {
Matrix4? _cachedTransform; Matrix4? _cachedTransform;
Rect? _cachedRect; Rect? _cachedRect;
Rect? _cachedCaretRect; Rect? _cachedCaretRect;
List<SelectionRect> _cachedSelectionRects = <SelectionRect>[];
static int _nextId = 1; static int _nextId = 1;
final int _id; final int _id;
...@@ -1176,6 +1250,12 @@ class TextInputConnection { ...@@ -1176,6 +1250,12 @@ class TextInputConnection {
/// Whether this connection is currently interacting with the text input control. /// Whether this connection is currently interacting with the text input control.
bool get attached => TextInput._instance._currentConnection == this; bool get attached => TextInput._instance._currentConnection == this;
/// Whether there is currently a Scribble interaction in progress.
///
/// This is used to make sure selection handles are shown when UIKit changes
/// the selection during a Scribble interaction.
bool get scribbleInProgress => TextInput._instance.scribbleInProgress;
/// Requests that the text input control become visible. /// Requests that the text input control become visible.
void show() { void show() {
assert(attached); assert(attached);
...@@ -1274,6 +1354,19 @@ class TextInputConnection { ...@@ -1274,6 +1354,19 @@ class TextInputConnection {
); );
} }
/// Send the bounding boxes of the current selected glyphs in the client to
/// the platform's text input plugin.
///
/// These are used by the engine during a UIDirectScribbleInteraction.
void setSelectionRects(List<SelectionRect> selectionRects) {
if (!listEquals(_cachedSelectionRects, selectionRects)) {
_cachedSelectionRects = selectionRects;
TextInput._instance._setSelectionRects(selectionRects.map((SelectionRect rect) {
return <num>[rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position];
}).toList());
}
}
/// Send text styling information. /// Send text styling information.
/// ///
/// This information is used by the Flutter Web Engine to change the style /// This information is used by the Flutter Web Engine to change the style
...@@ -1535,10 +1628,43 @@ class TextInput { ...@@ -1535,10 +1628,43 @@ class TextInput {
TextInputConnection? _currentConnection; TextInputConnection? _currentConnection;
late TextInputConfiguration _currentConfiguration; late TextInputConfiguration _currentConfiguration;
final Map<String, ScribbleClient> _scribbleClients = <String, ScribbleClient>{};
bool _scribbleInProgress = false;
/// Used for testing within the Flutter SDK to get the currently registered [ScribbleClient] list.
@visibleForTesting
static Map<String, ScribbleClient> get scribbleClients => TextInput._instance._scribbleClients;
/// Returns true if a scribble interaction is currently happening.
bool get scribbleInProgress => _scribbleInProgress;
Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async { Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
final String method = methodCall.method;
if (method == 'TextInputClient.focusElement') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
_scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble()));
return;
} else if (method == 'TextInputClient.requestElementsInRect') {
final List<double> args = (methodCall.arguments as List<dynamic>).cast<num>().map<double>((num value) => value.toDouble()).toList();
return _scribbleClients.keys.where((String elementIdentifier) {
final Rect rect = Rect.fromLTWH(args[0], args[1], args[2], args[3]);
if (!(_scribbleClients[elementIdentifier]?.isInScribbleRect(rect) ?? false))
return false;
final Rect bounds = _scribbleClients[elementIdentifier]?.bounds ?? Rect.zero;
return !(bounds == Rect.zero || bounds.hasNaN || bounds.isInfinite);
}).map((String elementIdentifier) {
final Rect bounds = _scribbleClients[elementIdentifier]!.bounds;
return <dynamic>[elementIdentifier, ...<dynamic>[bounds.left, bounds.top, bounds.width, bounds.height]];
}).toList();
} else if (method == 'TextInputClient.scribbleInteractionBegan') {
_scribbleInProgress = true;
return;
} else if (method == 'TextInputClient.scribbleInteractionFinished') {
_scribbleInProgress = false;
return;
}
if (_currentConnection == null) if (_currentConnection == null)
return; return;
final String method = methodCall.method;
// The requestExistingInputState request needs to be handled regardless of // The requestExistingInputState request needs to be handled regardless of
// the client ID, as long as we have a _currentConnection. // the client ID, as long as we have a _currentConnection.
...@@ -1630,6 +1756,15 @@ class TextInput { ...@@ -1630,6 +1756,15 @@ class TextInput {
case 'TextInputClient.showAutocorrectionPromptRect': case 'TextInputClient.showAutocorrectionPromptRect':
_currentConnection!._client.showAutocorrectionPromptRect(args[1] as int, args[2] as int); _currentConnection!._client.showAutocorrectionPromptRect(args[1] as int, args[2] as int);
break; break;
case 'TextInputClient.showToolbar':
_currentConnection!._client.showToolbar();
break;
case 'TextInputClient.insertTextPlaceholder':
_currentConnection!._client.insertTextPlaceholder(Size((args[1] as num).toDouble(), (args[2] as num).toDouble()));
break;
case 'TextInputClient.removeTextPlaceholder':
_currentConnection!._client.removeTextPlaceholder();
break;
default: default:
throw MissingPluginException(); throw MissingPluginException();
} }
...@@ -1703,6 +1838,13 @@ class TextInput { ...@@ -1703,6 +1838,13 @@ class TextInput {
); );
} }
void _setSelectionRects(List<List<num>> args) {
_channel.invokeMethod<void>(
'TextInput.setSelectionRects',
args,
);
}
void _setStyle(Map<String, dynamic> args) { void _setStyle(Map<String, dynamic> args) {
_channel.invokeMethod<void>( _channel.invokeMethod<void>(
'TextInput.setStyle', 'TextInput.setStyle',
...@@ -1765,4 +1907,18 @@ class TextInput { ...@@ -1765,4 +1907,18 @@ class TextInput {
shouldSave, shouldSave,
); );
} }
/// Registers a [ScribbleClient] with [elementIdentifier] that can be focused
/// by the engine.
///
/// For example, the registered [ScribbleClient] list is used to respond to
/// UIIndirectScribbleInteraction on an iPad.
static void registerScribbleElement(String elementIdentifier, ScribbleClient scribbleClient) {
TextInput._instance._scribbleClients[elementIdentifier] = scribbleClient;
}
/// Unregisters a [ScribbleClient] with [elementIdentifier].
static void unregisterScribbleElement(String elementIdentifier) {
TextInput._instance._scribbleClients.remove(elementIdentifier);
}
} }
...@@ -2454,7 +2454,7 @@ void main() { ...@@ -2454,7 +2454,7 @@ void main() {
); );
final RenderEditable renderEditable = tester.renderObject<RenderEditable>( final RenderEditable renderEditable = tester.renderObject<RenderEditable>(
find.byElementPredicate((Element element) => element.renderObject is RenderEditable), find.byElementPredicate((Element element) => element.renderObject is RenderEditable).last,
); );
List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection( List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection(
...@@ -3252,7 +3252,7 @@ void main() { ...@@ -3252,7 +3252,7 @@ void main() {
expect( expect(
tester.renderObject<RenderEditable>( tester.renderObject<RenderEditable>(
find.byElementPredicate((Element element) => element.renderObject is RenderEditable), find.byElementPredicate((Element element) => element.renderObject is RenderEditable).last,
).text!.style!.color, ).text!.style!.color,
isSameColorAs(CupertinoColors.white), isSameColorAs(CupertinoColors.white),
); );
......
...@@ -9179,6 +9179,38 @@ void main() { ...@@ -9179,6 +9179,38 @@ void main() {
expect(right.opacity.value, equals(1.0)); expect(right.opacity.value, equals(1.0));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('iPad Scribble selection change shows selection handles', (WidgetTester tester) async {
const String testText = 'lorem ipsum';
final TextEditingController controller = TextEditingController(text: testText);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);
await tester.showKeyboard(find.byType(EditableText));
await tester.testTextInput.startScribbleInteraction();
tester.testTextInput.updateEditingValue(const TextEditingValue(
text: testText,
selection: TextSelection(baseOffset: 2, extentOffset: 7),
));
await tester.pumpAndSettle();
final List<FadeTransition> transitions =
find.byType(FadeTransition).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
expect(transitions.length, 2);
final FadeTransition left = transitions[0];
final FadeTransition right = transitions[1];
expect(left.opacity.value, equals(1.0));
expect(right.opacity.value, equals(1.0));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
testWidgets('Tap shows handles but not toolbar', (WidgetTester tester) async { testWidgets('Tap shows handles but not toolbar', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController( final TextEditingController controller = TextEditingController(
text: 'abc def ghi', text: 'abc def ghi',
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// 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';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -141,6 +143,21 @@ class FakeAutofillClient implements TextInputClient, AutofillClient { ...@@ -141,6 +143,21 @@ class FakeAutofillClient implements TextInputClient, AutofillClient {
@override @override
void autofill(TextEditingValue newEditingValue) => updateEditingValue(newEditingValue); void autofill(TextEditingValue newEditingValue) => updateEditingValue(newEditingValue);
@override
void showToolbar() {
latestMethodCall = 'showToolbar';
}
@override
void insertTextPlaceholder(Size size) {
latestMethodCall = 'insertTextPlaceholder';
}
@override
void removeTextPlaceholder() {
latestMethodCall = 'removeTextPlaceholder';
}
} }
class FakeAutofillScope with AutofillScopeMixin implements AutofillScope { class FakeAutofillScope with AutofillScopeMixin implements AutofillScope {
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:convert' show jsonDecode; import 'dart:convert' show jsonDecode;
import 'dart:ui';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -114,5 +115,20 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient { ...@@ -114,5 +115,20 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient {
latestMethodCall = 'showAutocorrectionPromptRect'; latestMethodCall = 'showAutocorrectionPromptRect';
} }
@override
void insertTextPlaceholder(Size size) {
latestMethodCall = 'insertTextPlaceholder';
}
@override
void removeTextPlaceholder() {
latestMethodCall = 'removeTextPlaceholder';
}
@override
void showToolbar() {
latestMethodCall = 'showToolbar';
}
TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true); TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true);
} }
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:convert' show jsonDecode; import 'dart:convert' show jsonDecode;
import 'dart:ui';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -496,6 +497,148 @@ void main() { ...@@ -496,6 +497,148 @@ void main() {
expect(client.latestMethodCall, 'showAutocorrectionPromptRect'); expect(client.latestMethodCall, 'showAutocorrectionPromptRect');
}); });
test('TextInputClient showToolbar method is called', () async {
// Assemble a TextInputConnection so we can verify its change in state.
final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty);
const TextInputConfiguration configuration = TextInputConfiguration();
TextInput.attach(client, configuration);
expect(client.latestMethodCall, isEmpty);
// Send showToolbar message.
final ByteData? messageBytes =
const JSONMessageCodec().encodeMessage(<String, dynamic>{
'args': <dynamic>[1, 0, 1],
'method': 'TextInputClient.showToolbar',
});
await ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
expect(client.latestMethodCall, 'showToolbar');
});
});
group('Scribble interactions', () {
tearDown(() {
TextInputConnection.debugResetId();
});
test('TextInputClient scribbleInteractionBegan and scribbleInteractionFinished', () async {
// Assemble a TextInputConnection so we can verify its change in state.
final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty);
const TextInputConfiguration configuration = TextInputConfiguration();
final TextInputConnection connection = TextInput.attach(client, configuration);
expect(connection.scribbleInProgress, false);
// Send scribbleInteractionBegan message.
ByteData? messageBytes =
const JSONMessageCodec().encodeMessage(<String, dynamic>{
'args': <dynamic>[1, 0, 1],
'method': 'TextInputClient.scribbleInteractionBegan',
});
await ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
expect(connection.scribbleInProgress, true);
// Send scribbleInteractionFinished message.
messageBytes =
const JSONMessageCodec().encodeMessage(<String, dynamic>{
'args': <dynamic>[1, 0, 1],
'method': 'TextInputClient.scribbleInteractionFinished',
});
await ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
expect(connection.scribbleInProgress, false);
});
test('TextInputClient focusElement', () async {
// Assemble a TextInputConnection so we can verify its change in state.
final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty);
const TextInputConfiguration configuration = TextInputConfiguration();
TextInput.attach(client, configuration);
final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target');
TextInput.registerScribbleElement(targetElement.elementIdentifier, targetElement);
final FakeScribbleElement otherElement = FakeScribbleElement(elementIdentifier: 'other');
TextInput.registerScribbleElement(otherElement.elementIdentifier, otherElement);
expect(targetElement.latestMethodCall, isEmpty);
expect(otherElement.latestMethodCall, isEmpty);
// Send focusElement message.
final ByteData? messageBytes =
const JSONMessageCodec().encodeMessage(<String, dynamic>{
'args': <dynamic>[targetElement.elementIdentifier, 0.0, 0.0],
'method': 'TextInputClient.focusElement',
});
await ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
TextInput.unregisterScribbleElement(targetElement.elementIdentifier);
TextInput.unregisterScribbleElement(otherElement.elementIdentifier);
expect(targetElement.latestMethodCall, 'onScribbleFocus');
expect(otherElement.latestMethodCall, isEmpty);
});
test('TextInputClient requestElementsInRect', () async {
// Assemble a TextInputConnection so we can verify its change in state.
final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty);
const TextInputConfiguration configuration = TextInputConfiguration();
TextInput.attach(client, configuration);
final List<FakeScribbleElement> targetElements = <FakeScribbleElement>[
FakeScribbleElement(elementIdentifier: 'target1', bounds: const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0)),
FakeScribbleElement(elementIdentifier: 'target2', bounds: const Rect.fromLTWH(0.0, 100.0, 100.0, 100.0)),
];
final List<FakeScribbleElement> otherElements = <FakeScribbleElement>[
FakeScribbleElement(elementIdentifier: 'other1', bounds: const Rect.fromLTWH(100.0, 0.0, 100.0, 100.0)),
FakeScribbleElement(elementIdentifier: 'other2', bounds: const Rect.fromLTWH(100.0, 100.0, 100.0, 100.0)),
];
void registerElements(FakeScribbleElement element) => TextInput.registerScribbleElement(element.elementIdentifier, element);
void unregisterElements(FakeScribbleElement element) => TextInput.unregisterScribbleElement(element.elementIdentifier);
<FakeScribbleElement>[...targetElements, ...otherElements].forEach(registerElements);
// Send requestElementsInRect message.
final ByteData? messageBytes =
const JSONMessageCodec().encodeMessage(<String, dynamic>{
'args': <dynamic>[0.0, 50.0, 50.0, 100.0],
'method': 'TextInputClient.requestElementsInRect',
});
ByteData? responseBytes;
await ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? response) {
responseBytes = response;
},
);
<FakeScribbleElement>[...targetElements, ...otherElements].forEach(unregisterElements);
final List<List<dynamic>> responses = (const JSONMessageCodec().decodeMessage(responseBytes) as List<dynamic>).cast<List<dynamic>>();
expect(responses.first.length, 2);
expect(responses.first.first, containsAllInOrder(<dynamic>[targetElements.first.elementIdentifier, 0.0, 0.0, 100.0, 100.0]));
expect(responses.first.last, containsAllInOrder(<dynamic>[targetElements.last.elementIdentifier, 0.0, 100.0, 100.0, 100.0]));
});
}); });
test('TextEditingValue.isComposingRangeValid', () async { test('TextEditingValue.isComposingRangeValid', () async {
...@@ -567,5 +710,20 @@ class FakeTextInputClient implements TextInputClient { ...@@ -567,5 +710,20 @@ class FakeTextInputClient implements TextInputClient {
latestMethodCall = 'showAutocorrectionPromptRect'; latestMethodCall = 'showAutocorrectionPromptRect';
} }
@override
void showToolbar() {
latestMethodCall = 'showToolbar';
}
TextInputConfiguration get configuration => const TextInputConfiguration(); TextInputConfiguration get configuration => const TextInputConfiguration();
@override
void insertTextPlaceholder(Size size) {
latestMethodCall = 'insertTextPlaceholder';
}
@override
void removeTextPlaceholder() {
latestMethodCall = 'removeTextPlaceholder';
}
} }
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:convert' show utf8; import 'dart:convert' show utf8;
import 'dart:ui';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -64,3 +65,29 @@ class FakeTextChannel implements MethodChannel { ...@@ -64,3 +65,29 @@ class FakeTextChannel implements MethodChannel {
} }
} }
} }
class FakeScribbleElement implements ScribbleClient {
FakeScribbleElement({required String elementIdentifier, Rect bounds = Rect.zero})
: _elementIdentifier = elementIdentifier,
_bounds = bounds;
final String _elementIdentifier;
final Rect _bounds;
String latestMethodCall = '';
@override
Rect get bounds => _bounds;
@override
String get elementIdentifier => _elementIdentifier;
@override
bool isInScribbleRect(Rect rect) {
return _bounds.overlaps(rect);
}
@override
void onScribbleFocus(Offset offset) {
latestMethodCall = 'onScribbleFocus';
}
}
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' show Rect, Offset;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -271,4 +273,84 @@ class TestTextInput { ...@@ -271,4 +273,84 @@ class TestTextInput {
(ByteData? data) { /* response from framework is discarded */ }, (ByteData? data) { /* response from framework is discarded */ },
); );
} }
/// Simulates a scribble interaction starting.
Future<void> startScribbleInteraction() async {
assert(isRegistered);
await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall(
'TextInputClient.scribbleInteractionBegan',
<dynamic>[_client ?? -1,]
),
),
(ByteData? data) { /* response from framework is discarded */ },
);
}
/// Simulates a Scribble focus.
Future<void> scribbleFocusElement(String elementIdentifier, Offset offset) async {
assert(isRegistered);
await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall(
'TextInputClient.focusElement',
<dynamic>[elementIdentifier, offset.dx, offset.dy]
),
),
(ByteData? data) { /* response from framework is discarded */ },
);
}
/// Simulates iOS asking for the list of Scribble elements during UIIndirectScribbleInteraction.
Future<List<List<dynamic>>> scribbleRequestElementsInRect(Rect rect) async {
assert(isRegistered);
List<List<dynamic>> response = <List<dynamic>>[];
await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall(
'TextInputClient.requestElementsInRect',
<dynamic>[rect.left, rect.top, rect.width, rect.height]
),
),
(ByteData? data) {
response = (SystemChannels.textInput.codec.decodeEnvelope(data!) as List<dynamic>).map((dynamic element) => element as List<dynamic>).toList();
},
);
return response;
}
/// Simulates iOS inserting a UITextPlaceholder during a long press with the pencil.
Future<void> scribbleInsertPlaceholder() async {
assert(isRegistered);
await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall(
'TextInputClient.insertTextPlaceholder',
<dynamic>[_client ?? -1, 0.0, 0.0]
),
),
(ByteData? data) { /* response from framework is discarded */ },
);
}
/// Simulates iOS removing a UITextPlaceholder after a long press with the pencil is released.
Future<void> scribbleRemovePlaceholder() async {
assert(isRegistered);
await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall(
'TextInputClient.removeTextPlaceholder',
<dynamic>[_client ?? -1]
),
),
(ByteData? data) { /* response from framework is discarded */ },
);
}
} }
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