From 3cde69e8d9dff7e081cb6daa6cd633c49176cb32 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 7 Nov 2022 11:28:27 -0800 Subject: [PATCH] Revert "Revert "Scribble mixin (#104128)" (#114647)" (#114698) Relands the "Scribble mixin" PR, which was reverted due to breaking a Google test in the last roll. Breaks the Scribble feature out of TextInputClient in order to avoid breaking changes. --- packages/flutter/lib/services.dart | 1 + .../flutter/lib/src/services/binding.dart | 5 +- .../flutter/lib/src/services/scribble.dart | 243 ++++++++++++++++++ .../lib/src/services/system_channels.dart | 32 +++ .../flutter/lib/src/services/text_input.dart | 175 +------------ .../lib/src/widgets/editable_text.dart | 184 ++++++++----- .../flutter/test/services/autofill_test.dart | 15 -- .../flutter/test/services/binding_test.dart | 7 - .../test/services/delta_text_input_test.dart | 15 -- .../flutter/test/services/scribble_test.dart | 213 +++++++++++++++ .../test/services/text_input_test.dart | 174 +------------ .../test/services/text_input_utils.dart | 17 +- .../test/widgets/editable_text_test.dart | 79 +++++- .../flutter_test/lib/src/test_text_input.dart | 32 +-- 14 files changed, 730 insertions(+), 462 deletions(-) create mode 100644 packages/flutter/lib/src/services/scribble.dart create mode 100644 packages/flutter/test/services/scribble_test.dart diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index a0ff42f6772e..74d18f333c42 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -37,6 +37,7 @@ export 'src/services/raw_keyboard_macos.dart'; export 'src/services/raw_keyboard_web.dart'; export 'src/services/raw_keyboard_windows.dart'; export 'src/services/restoration.dart'; +export 'src/services/scribble.dart'; export 'src/services/service_extensions.dart'; export 'src/services/spell_check.dart'; export 'src/services/system_channels.dart'; diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index b9531f419c29..3f92afa0c30f 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -15,9 +15,9 @@ import 'binary_messenger.dart'; import 'hardware_keyboard.dart'; import 'message_codec.dart'; import 'restoration.dart'; +import 'scribble.dart'; import 'service_extensions.dart'; import 'system_channels.dart'; -import 'text_input.dart'; export 'dart:ui' show ChannelBuffers, RootIsolateToken; @@ -43,7 +43,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { SystemChannels.system.setMessageHandler((dynamic message) => handleSystemMessage(message as Object)); SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage); SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage); - TextInput.ensureInitialized(); + Scribble.ensureInitialized(); readInitialLifecycleStateFromNativeWindow(); } @@ -326,7 +326,6 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { void setSystemUiChangeCallback(SystemUiChangeCallback? callback) { _systemUiChangeCallback = callback; } - } /// Signature for listening to changes in the [SystemUiMode]. diff --git a/packages/flutter/lib/src/services/scribble.dart b/packages/flutter/lib/src/services/scribble.dart new file mode 100644 index 000000000000..3548868cb7ce --- /dev/null +++ b/packages/flutter/lib/src/services/scribble.dart @@ -0,0 +1,243 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import 'message_codec.dart'; +import 'platform_channel.dart'; +import 'system_channels.dart'; + +/// An interface into system-level handwriting text input. +/// +/// This is typically used by implemeting the methods in [ScribbleClient] in a +/// class, usually a [State], and setting an instance of it to [client]. The +/// relevant methods on [ScribbleClient] will be called in response to method +/// channel calls on [SystemChannels.scribble]. +/// +/// Currently, handwriting input is supported in the iOS embedder with the Apple +/// Pencil. +/// +/// [EditableText] uses this class via [ScribbleClient] to automatically support +/// handwriting input when [EditableText.scribbleEnabled] is set to true. +/// +/// See also: +/// +/// * [SystemChannels.scribble], which is the [MethodChannel] used by this +/// class, and which has a list of the methods that this class handles. +class Scribble { + Scribble._() { + _channel.setMethodCallHandler(_handleScribbleInvocation); + } + + /// Ensure that a [Scribble] instance has been set up so that the platform + /// can handle messages on the scribble method channel. + static void ensureInitialized() { + _instance; // ignore: unnecessary_statements + } + + /// Set the [MethodChannel] used to communicate with the system's text input + /// control. + /// + /// This is only meant for testing within the Flutter SDK. Changing this + /// will break the ability to do handwriting input. This has no effect if + /// asserts are disabled. + @visibleForTesting + static void setChannel(MethodChannel newChannel) { + assert(() { + _instance._channel = newChannel..setMethodCallHandler(_instance._handleScribbleInvocation); + return true; + }()); + } + + static final Scribble _instance = Scribble._(); + + /// Set the given [ScribbleClient] as the single active client. + /// + /// This is usually based on the [ScribbleClient] receiving focus. + static set client(ScribbleClient? client) { + _instance._client = client; + } + + /// Return the current active [ScribbleClient], or null if none. + static ScribbleClient? get client => _instance._client; + + ScribbleClient? _client; + + MethodChannel _channel = SystemChannels.scribble; + + final Map _scribbleClients = {}; + bool _scribbleInProgress = false; + + /// Used for testing within the Flutter SDK to get the currently registered [ScribbleClient] list. + @visibleForTesting + static Map get scribbleClients => Scribble._instance._scribbleClients; + + /// Returns true if a scribble interaction is currently happening. + static bool get scribbleInProgress => _instance._scribbleInProgress; + + Future _handleScribbleInvocation(MethodCall methodCall) async { + final String method = methodCall.method; + if (method == 'Scribble.focusElement') { + final List args = methodCall.arguments as List; + _scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble())); + return; + } else if (method == 'Scribble.requestElementsInRect') { + final List args = (methodCall.arguments as List).cast().map((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 [elementIdentifier, ...[bounds.left, bounds.top, bounds.width, bounds.height]]; + }).toList(); + } else if (method == 'Scribble.scribbleInteractionBegan') { + _scribbleInProgress = true; + return; + } else if (method == 'Scribble.scribbleInteractionFinished') { + _scribbleInProgress = false; + return; + } + + // The methods below are only valid when a client exists, i.e. when a field + // is focused. + final ScribbleClient? client = _client; + if (client == null) { + return; + } + + final List args = methodCall.arguments as List; + switch (method) { + case 'Scribble.showToolbar': + client.showToolbar(); + break; + case 'Scribble.insertTextPlaceholder': + client.insertTextPlaceholder(Size((args[1] as num).toDouble(), (args[2] as num).toDouble())); + break; + case 'Scribble.removeTextPlaceholder': + client.removeTextPlaceholder(); + break; + default: + throw MissingPluginException(); + } + } + + /// 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) { + _instance._scribbleClients[elementIdentifier] = scribbleClient; + } + + /// Unregisters a [ScribbleClient] with [elementIdentifier]. + static void unregisterScribbleElement(String elementIdentifier) { + _instance._scribbleClients.remove(elementIdentifier); + } + + List _cachedSelectionRects = []; + + /// 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. + static void setSelectionRects(List selectionRects) { + if (!listEquals(_instance._cachedSelectionRects, selectionRects)) { + _instance._cachedSelectionRects = selectionRects; + _instance._channel.invokeMethod( + 'Scribble.setSelectionRects', + selectionRects.map((SelectionRect rect) { + return [rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position]; + }).toList(), + ); + } + } +} + +/// An interface to interact with the engine for handwriting text input. +/// +/// This is currently only used to handle +/// [UIIndirectScribbleInteraction](https://developer.apple.com/documentation/uikit/uiindirectscribbleinteraction), +/// which is responsible for manually receiving handwritten text input in UIKit. +/// The Flutter engine uses this to receive handwriting input on Flutter text +/// input fields. +mixin 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. + /// + /// The [Offset] indicates the location where the focus event happened, which + /// is typically where the cursor should be placed. + void onScribbleFocus(Offset offset); + + /// Tests whether the [ScribbleClient] overlaps the given rectangle bounds, + /// where the rectangle bounds are in global coordinates. + bool isInScribbleRect(Rect rect); + + /// The current bounds of the [ScribbleClient]. + Rect get bounds; + + /// Requests that the client show the editing toolbar. + /// + /// This is used when the platform changes the selection during scribble + /// input. + 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(); +} + +/// 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 => Object.hash(position, bounds); + + @override + String toString() => 'SelectionRect($position, $bounds)'; +} diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index d8b661166fe3..7c7889a6f98e 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -222,6 +222,38 @@ class SystemChannels { JSONMethodCodec(), ); + /// A JSON [MethodChannel] for handling handwriting input. + /// + /// This method channel is used by iPadOS 14's Scribble feature where writing + /// with an Apple Pencil on top of a text field inserts text into the field. + /// + /// The following methods are defined for this channel: + /// + /// * `Scribble.focusElement`: Indicates that focus is requested at the given + /// [Offset]. + /// + /// * `Scribble.requestElementsInRect`: Returns a List of identifiers and + /// bounds for the [ScribbleClient]s that lie within the given Rect. + /// + /// * `Scribble.scribbleInteractionBegan`: Indicates that handwriting input + /// has started. + /// + /// * `Scribble.scribbleInteractionFinished`: Indicates that handwriting input + /// has ended. + /// + /// * `Scribble.showToolbar`: Requests that the toolbar be shown, such as + /// when selection is changed by handwriting. + /// + /// * `Scribble.insertTextPlaceholder`: Requests that visual writing space is + /// reserved. + /// + /// * `Scribble.removeTextPlaceholder`: Requests that any placeholder writing + /// space is removed. + static const MethodChannel scribble = OptionalMethodChannel( + 'flutter/scribble', + JSONMethodCodec(), + ); + /// A [MethodChannel] for handling spell check for text input. /// /// This channel exposes the spell check framework for supported platforms. diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index b2ccef4320b6..1016d05a9b7a 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1162,84 +1162,12 @@ mixin TextInputClient { /// * [TextInputControl.show], a method to show the new input control. void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) {} - /// 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() {} - /// Performs the specified MacOS-specific selector from the /// `NSStandardKeyBindingResponding` protocol or user-specified selector /// from `DefaultKeyBinding.Dict`. void performSelector(String selectorName) {} } -/// 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 => Object.hash(position, bounds); - - @override - String toString() => 'SelectionRect($position, $bounds)'; -} - /// An interface to receive granular information from [TextInput]. /// /// See also: @@ -1299,7 +1227,6 @@ class TextInputConnection { Matrix4? _cachedTransform; Rect? _cachedRect; Rect? _cachedCaretRect; - List _cachedSelectionRects = []; static int _nextId = 1; final int _id; @@ -1322,12 +1249,6 @@ class TextInputConnection { /// Whether this connection is currently interacting with the text input control. 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. void show() { assert(attached); @@ -1408,17 +1329,6 @@ class TextInputConnection { TextInput._instance._setCaretRect(validRect); } - /// 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 selectionRects) { - if (!listEquals(_cachedSelectionRects, selectionRects)) { - _cachedSelectionRects = selectionRects; - TextInput._instance._setSelectionRects(selectionRects); - } - } - /// Send text styling information. /// /// This information is used by the Flutter Web Engine to change the style @@ -1676,6 +1586,10 @@ class TextInput { /// Ensure that a [TextInput] instance has been set up so that the platform /// can handle messages on the text input method channel. + @Deprecated( + 'Use Scribble.ensureInitialized instead. ' + 'This feature was deprecated after v3.1.0-9.0.pre.' + ) static void ensureInitialized() { _instance; // ignore: unnecessary_statements } @@ -1738,16 +1652,6 @@ class TextInput { TextInputConnection? _currentConnection; late TextInputConfiguration _currentConfiguration; - final Map _scribbleClients = {}; - bool _scribbleInProgress = false; - - /// Used for testing within the Flutter SDK to get the currently registered [ScribbleClient] list. - @visibleForTesting - static Map get scribbleClients => TextInput._instance._scribbleClients; - - /// Returns true if a scribble interaction is currently happening. - bool get scribbleInProgress => _scribbleInProgress; - Future _loudlyHandleTextInputInvocation(MethodCall call) async { try { return await _handleTextInputInvocation(call); @@ -1764,33 +1668,8 @@ class TextInput { rethrow; } } - Future _handleTextInputInvocation(MethodCall methodCall) async { final String method = methodCall.method; - if (method == 'TextInputClient.focusElement') { - final List args = methodCall.arguments as List; - _scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble())); - return; - } else if (method == 'TextInputClient.requestElementsInRect') { - final List args = (methodCall.arguments as List).cast().map((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 [elementIdentifier, ...[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) { return; } @@ -1894,15 +1773,6 @@ class TextInput { case 'TextInputClient.showAutocorrectionPromptRect': _currentConnection!._client.showAutocorrectionPromptRect(args[1] as int, args[2] as int); 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: throw MissingPluginException(); } @@ -1986,12 +1856,6 @@ class TextInput { } } - void _setSelectionRects(List selectionRects) { - for (final TextInputControl control in _inputControls) { - control.setSelectionRects(selectionRects); - } - } - void _setStyle({ required String? fontFamily, required double? fontSize, @@ -2091,20 +1955,6 @@ class TextInput { control.finishAutofillContext(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); - } } /// An interface for implementing text input controls that receive text editing @@ -2188,12 +2038,6 @@ mixin TextInputControl { /// changes. void setCaretRect(Rect rect) {} - /// Informs the text input control about selection area changes. - /// - /// This method is called when the attached input client's selection area - /// changes. - void setSelectionRects(List selectionRects) {} - /// Informs the text input control about text style changes. /// /// This method is called on the when the attached input client's text style @@ -2316,17 +2160,6 @@ class _PlatformTextInputControl with TextInputControl { ); } - @override - void setSelectionRects(List selectionRects) { - _channel.invokeMethod( - 'TextInput.setSelectionRects', - selectionRects.map((SelectionRect rect) { - return [rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position]; - }).toList(), - ); - } - - @override void setStyle({ required String? fontFamily, diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 0f3672f26db4..2e7d27013782 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2560,7 +2560,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (value.text == _value.text && value.composing == _value.composing) { // `selection` is the only change. - _handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard); + _handleSelectionChanged( + value.selection, + Scribble.scribbleInProgress + ? SelectionChangedCause.scribble + : SelectionChangedCause.keyboard, + ); } else { // Only hide the toolbar overlay, the selection handle's visibility will be handled // by `_handleSelectionChanged`. https://github.com/flutter/flutter/issues/108673 @@ -3519,7 +3524,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien } graphemeStart = graphemeEnd; } - _textInputConnection!.setSelectionRects(rects); + Scribble.setSelectionRects(rects); } void _updateSizeAndTransform() { @@ -3530,7 +3535,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien _updateSelectionRects(); SchedulerBinding.instance.addPostFrameCallback((Duration _) => _updateSizeAndTransform()); } else if (_placeholderLocation != -1) { - removeTextPlaceholder(); + _removeTextPlaceholder(); } } @@ -3623,7 +3628,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar /// is already shown, or when no text selection currently exists. - @override bool showToolbar() { // Web is using native dom elements to enable clipboard functionality of the // toolbar: copy, paste, select, cut. It might also provide additional @@ -3694,39 +3698,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - // Tracks the location a [_ScribblePlaceholder] should be rendered in the - // text. - // - // A value of -1 indicates there should be no placeholder, otherwise the - // value should be between 0 and the length of the text, inclusive. - int _placeholderLocation = -1; - - @override - void insertTextPlaceholder(Size size) { - if (!widget.scribbleEnabled) { - return; - } - - if (!widget.controller.selection.isValid) { - return; - } - - setState(() { - _placeholderLocation = _value.text.length - widget.controller.selection.end; - }); - } - - @override - void removeTextPlaceholder() { - if (!widget.scribbleEnabled) { - return; - } - - setState(() { - _placeholderLocation = -1; - }); - } - @override void performSelector(String selectorName) { final Intent? intent = intentForMacOSSelector(selectorName); @@ -4099,6 +4070,35 @@ class EditableTextState extends State with AutomaticKeepAliveClien return Actions.invoke(context, intent); } + // Tracks the location a [_ScribblePlaceholder] should be rendered in the + // text. + // + // A value of -1 indicates there should be no placeholder, otherwise the + // value should be between 0 and the length of the text, inclusive. + int _placeholderLocation = -1; + + void _onPlaceholderLocationChanged(int location) { + setState(() { + _placeholderLocation = location; + }); + } + + void _onScribbleFocus(Offset offset) { + widget.focusNode.requestFocus(); + renderEditable.selectPositionAt(from: offset, cause: SelectionChangedCause.scribble); + _openInputConnection(); + _updateSelectionRects(force: true); + } + + void _removeTextPlaceholder() { + if (!widget.scribbleEnabled) { + return; + } + + setState(() { + _placeholderLocation = -1; + }); + } /// The default behavior used if [onTapOutside] is null. /// @@ -4216,12 +4216,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien onPaste: _semanticsOnPaste(controls), child: _ScribbleFocusable( focusNode: widget.focusNode, - editableKey: _editableKey, enabled: widget.scribbleEnabled, - updateSelectionRects: () { - _openInputConnection(); - _updateSelectionRects(force: true); - }, + onPlaceholderLocationChanged: _onPlaceholderLocationChanged, + onScribbleFocus: _onScribbleFocus, + onShowToolbar: showToolbar, + readOnly: widget.readOnly, + value: _value, child: _Editable( key: _editableKey, startHandleLayerLink: _startHandleLayerLink, @@ -4536,6 +4536,13 @@ class _Editable extends MultiChildRenderObjectWidget { } } +/// A function that that takes a placeholder location as an int offset into some +/// text. +typedef _PlaceholderLocationCallback = void Function(int location); + +/// A function that takes the Offset at which focus is requested. +typedef _ScribbleFocusCallback = void Function(Offset offset); + @immutable class _ScribbleCacheKey { const _ScribbleCacheKey({ @@ -4576,55 +4583,88 @@ class _ScribbleCacheKey { } } +/// A widget that provides the ability to receive handwriting input from +/// [Scribble]. class _ScribbleFocusable extends StatefulWidget { const _ScribbleFocusable({ required this.child, - required this.focusNode, - required this.editableKey, - required this.updateSelectionRects, required this.enabled, + required this.focusNode, + required this.onPlaceholderLocationChanged, + required this.onScribbleFocus, + required this.onShowToolbar, + required this.readOnly, + required this.value, }); final Widget child; - final FocusNode focusNode; - final GlobalKey editableKey; - final VoidCallback updateSelectionRects; final bool enabled; + final FocusNode focusNode; + final _PlaceholderLocationCallback onPlaceholderLocationChanged; + final _ScribbleFocusCallback onScribbleFocus; + final VoidCallback onShowToolbar; + final bool readOnly; + final TextEditingValue value; @override _ScribbleFocusableState createState() => _ScribbleFocusableState(); } -class _ScribbleFocusableState extends State<_ScribbleFocusable> implements ScribbleClient { +class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleClient { _ScribbleFocusableState(): _elementIdentifier = (_nextElementIdentifier++).toString(); + void _onFocusChange() { + _updateClient(widget.focusNode.hasFocus); + } + + void _updateClient(bool hasFocus) { + if (hasFocus) { + if (Scribble.client != this) { + Scribble.client = this; + } + } else if (Scribble.client == this) { + Scribble.client = null; + } + } + @override void initState() { super.initState(); + _updateClient(widget.focusNode.hasFocus); + widget.focusNode.addListener(_onFocusChange); if (widget.enabled) { - TextInput.registerScribbleElement(elementIdentifier, this); + Scribble.registerScribbleElement(elementIdentifier, this); } } @override void didUpdateWidget(_ScribbleFocusable oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChange); + widget.focusNode.addListener(_onFocusChange); + _updateClient(widget.focusNode.hasFocus); + } if (!oldWidget.enabled && widget.enabled) { - TextInput.registerScribbleElement(elementIdentifier, this); + Scribble.registerScribbleElement(elementIdentifier, this); } if (oldWidget.enabled && !widget.enabled) { - TextInput.unregisterScribbleElement(elementIdentifier); + Scribble.unregisterScribbleElement(elementIdentifier); } } @override void dispose() { - TextInput.unregisterScribbleElement(elementIdentifier); + Scribble.unregisterScribbleElement(elementIdentifier); + widget.focusNode.removeListener(_onFocusChange); + if (Scribble.client == this) { + Scribble.client = null; + } super.dispose(); } - RenderEditable? get renderEditable => widget.editableKey.currentContext?.findRenderObject() as RenderEditable?; + // Start ScribbleClient. static int _nextElementIdentifier = 1; final String _elementIdentifier; @@ -4634,15 +4674,38 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> implements Scrib @override void onScribbleFocus(Offset offset) { - widget.focusNode.requestFocus(); - renderEditable?.selectPositionAt(from: offset, cause: SelectionChangedCause.scribble); - widget.updateSelectionRects(); + return widget.onScribbleFocus(offset); + } + + @override + void insertTextPlaceholder(Size size) { + if (!widget.enabled || !widget.value.selection.isValid || widget.readOnly) { + return; + } + + widget.onPlaceholderLocationChanged( + widget.value.text.length - widget.value.selection.end, + ); + } + + @override + void removeTextPlaceholder() { + if (!widget.enabled) { + return; + } + + widget.onPlaceholderLocationChanged(-1); + } + + @override + void showToolbar() { + widget.onShowToolbar(); } @override bool isInScribbleRect(Rect rect) { final Rect calculatedBounds = bounds; - if (renderEditable?.readOnly ?? false) { + if (widget.readOnly) { return false; } if (calculatedBounds == Rect.zero) { @@ -4654,7 +4717,8 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> implements Scrib final Rect intersection = calculatedBounds.intersect(rect); final HitTestResult result = HitTestResult(); WidgetsBinding.instance.hitTest(result, intersection.center); - return result.path.any((HitTestEntry entry) => entry.target == renderEditable); + final RenderObject? renderObject = context.findRenderObject(); + return result.path.any((HitTestEntry entry) => entry.target == renderObject); } @override @@ -4667,6 +4731,8 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> implements Scrib return MatrixUtils.transformRect(transform, Rect.fromLTWH(0, 0, box.size.width, box.size.height)); } + // End ScribbleClient. + @override Widget build(BuildContext context) { return widget.child; diff --git a/packages/flutter/test/services/autofill_test.dart b/packages/flutter/test/services/autofill_test.dart index dba33c479ffd..fb0bd612b0bf 100644 --- a/packages/flutter/test/services/autofill_test.dart +++ b/packages/flutter/test/services/autofill_test.dart @@ -147,21 +147,6 @@ class FakeAutofillClient implements TextInputClient, AutofillClient { @override void autofill(TextEditingValue newEditingValue) => updateEditingValue(newEditingValue); - @override - void showToolbar() { - latestMethodCall = 'showToolbar'; - } - - @override - void insertTextPlaceholder(Size size) { - latestMethodCall = 'insertTextPlaceholder'; - } - - @override - void removeTextPlaceholder() { - latestMethodCall = 'removeTextPlaceholder'; - } - @override void performSelector(String selectorName) { latestMethodCall = 'performSelector'; diff --git a/packages/flutter/test/services/binding_test.dart b/packages/flutter/test/services/binding_test.dart index fbdd4e31fe00..dfc06b655d9d 100644 --- a/packages/flutter/test/services/binding_test.dart +++ b/packages/flutter/test/services/binding_test.dart @@ -106,11 +106,4 @@ void main() { await rootBundle.loadString('test_asset2'); expect(flutterAssetsCallCount, 4); }); - - test('initInstances sets a default method call handler for SystemChannels.textInput', () async { - final ByteData message = const JSONMessageCodec().encodeMessage({'method': 'TextInput.requestElementsInRect', 'args': null})!; - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/textinput', message, (ByteData? data) { - expect(data, isNotNull); - }); - }); } diff --git a/packages/flutter/test/services/delta_text_input_test.dart b/packages/flutter/test/services/delta_text_input_test.dart index 4e98c5cd6b27..81b4c62fc600 100644 --- a/packages/flutter/test/services/delta_text_input_test.dart +++ b/packages/flutter/test/services/delta_text_input_test.dart @@ -271,21 +271,6 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient { latestMethodCall = 'showAutocorrectionPromptRect'; } - @override - void insertTextPlaceholder(Size size) { - latestMethodCall = 'insertTextPlaceholder'; - } - - @override - void removeTextPlaceholder() { - latestMethodCall = 'removeTextPlaceholder'; - } - - @override - void showToolbar() { - latestMethodCall = 'showToolbar'; - } - @override void performSelector(String selectorName) { latestMethodCall = 'performSelector'; diff --git a/packages/flutter/test/services/scribble_test.dart b/packages/flutter/test/services/scribble_test.dart new file mode 100644 index 000000000000..0bc4fe6ef696 --- /dev/null +++ b/packages/flutter/test/services/scribble_test.dart @@ -0,0 +1,213 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'text_input_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('ScribbleClient showToolbar method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); + + // Send showToolbar message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.showToolbar', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(targetElement.latestMethodCall, 'showToolbar'); + }); + + test('ScribbleClient removeTextPlaceholder method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); + + // Send removeTextPlaceholder message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.removeTextPlaceholder', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(targetElement.latestMethodCall, 'removeTextPlaceholder'); + }); + + test('ScribbleClient insertTextPlaceholder method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); + + // Send insertTextPlaceholder message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.insertTextPlaceholder', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(targetElement.latestMethodCall, 'insertTextPlaceholder'); + }); + + test('ScribbleClient scribbleInteractionBegan and scribbleInteractionFinished', () async { + Scribble.ensureInitialized(); + + expect(Scribble.scribbleInProgress, isFalse); + + // Send scribbleInteractionBegan message. + ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.scribbleInteractionBegan', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(Scribble.scribbleInProgress, isTrue); + + // Send scribbleInteractionFinished message. + messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.scribbleInteractionFinished', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(Scribble.scribbleInProgress, isFalse); + }); + + test('ScribbleClient focusElement', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.registerScribbleElement(targetElement.elementIdentifier, targetElement); + final FakeScribbleElement otherElement = FakeScribbleElement(elementIdentifier: 'other'); + Scribble.registerScribbleElement(otherElement.elementIdentifier, otherElement); + + expect(targetElement.latestMethodCall, isEmpty); + expect(otherElement.latestMethodCall, isEmpty); + + // Send focusElement message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [targetElement.elementIdentifier, 0.0, 0.0], + 'method': 'Scribble.focusElement', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + Scribble.unregisterScribbleElement(targetElement.elementIdentifier); + Scribble.unregisterScribbleElement(otherElement.elementIdentifier); + + expect(targetElement.latestMethodCall, 'onScribbleFocus'); + expect(otherElement.latestMethodCall, isEmpty); + }); + + test('ScribbleClient requestElementsInRect', () async { + final List targetElements = [ + 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 otherElements = [ + 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) => Scribble.registerScribbleElement(element.elementIdentifier, element); + void unregisterElements(FakeScribbleElement element) => Scribble.unregisterScribbleElement(element.elementIdentifier); + + [...targetElements, ...otherElements].forEach(registerElements); + + // Send requestElementsInRect message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [0.0, 50.0, 50.0, 100.0], + 'method': 'Scribble.requestElementsInRect', + }); + ByteData? responseBytes; + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? response) { + responseBytes = response; + }, + ); + + [...targetElements, ...otherElements].forEach(unregisterElements); + + final List> responses = (const JSONMessageCodec().decodeMessage(responseBytes) as List).cast>(); + expect(responses.first.length, 2); + expect(responses.first.first, containsAllInOrder([targetElements.first.elementIdentifier, 0.0, 0.0, 100.0, 100.0])); + expect(responses.first.last, containsAllInOrder([targetElements.last.elementIdentifier, 0.0, 100.0, 100.0, 100.0])); + }); +} + +class FakeScribbleClient implements ScribbleClient { + FakeScribbleClient(); + + String latestMethodCall = ''; + + @override + String get elementIdentifier => ''; + + @override + void onScribbleFocus(Offset offset) { + latestMethodCall = 'onScribbleFocus'; + } + + @override + bool isInScribbleRect(Rect rect) { + latestMethodCall = 'isInScribbleRect'; + return false; + } + + @override + Rect get bounds => Rect.zero; + + @override + void showToolbar() { + latestMethodCall = 'showToolbar'; + } + + @override + void insertTextPlaceholder(Size size) { + latestMethodCall = 'insertTextPlaceholder'; + } + + @override + void removeTextPlaceholder() { + latestMethodCall = 'removeTextPlaceholder'; + } +} diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index a1be567feb1f..2e84cee168e2 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -610,148 +610,6 @@ void main() { 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({ - 'args': [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({ - 'args': [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({ - 'args': [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({ - 'args': [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 targetElements = [ - 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 otherElements = [ - 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); - - [...targetElements, ...otherElements].forEach(registerElements); - - // Send requestElementsInRect message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [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; - }, - ); - - [...targetElements, ...otherElements].forEach(unregisterElements); - - final List> responses = (const JSONMessageCodec().decodeMessage(responseBytes) as List).cast>(); - expect(responses.first.length, 2); - expect(responses.first.first, containsAllInOrder([targetElements.first.elementIdentifier, 0.0, 0.0, 100.0, 100.0])); - expect(responses.first.last, containsAllInOrder([targetElements.last.elementIdentifier, 0.0, 100.0, 100.0, 100.0])); - }); }); test('TextEditingValue.isComposingRangeValid', () async { @@ -906,12 +764,6 @@ void main() { expect(fakeTextChannel.outgoingCalls.length, 6); expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setEditableSizeAndTransform'); - connection.setSelectionRects(const [SelectionRect(position: 0, bounds: Rect.zero)]); - expectedMethodCalls.add('setSelectionRects'); - expect(control.methodCalls, expectedMethodCalls); - expect(fakeTextChannel.outgoingCalls.length, 7); - expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setSelectionRects'); - connection.setStyle( fontFamily: null, fontSize: null, @@ -921,20 +773,20 @@ void main() { ); expectedMethodCalls.add('setStyle'); expect(control.methodCalls, expectedMethodCalls); - expect(fakeTextChannel.outgoingCalls.length, 8); + expect(fakeTextChannel.outgoingCalls.length, 7); expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setStyle'); connection.close(); expectedMethodCalls.add('detach'); expect(control.methodCalls, expectedMethodCalls); - expect(fakeTextChannel.outgoingCalls.length, 9); + expect(fakeTextChannel.outgoingCalls.length, 8); expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.clearClient'); expectedMethodCalls.add('hide'); final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); await binding.runAsync(() async {}); await expectLater(control.methodCalls, expectedMethodCalls); - expect(fakeTextChannel.outgoingCalls.length, 10); + expect(fakeTextChannel.outgoingCalls.length, 9); expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.hide'); }); @@ -998,11 +850,6 @@ class FakeTextInputClient with TextInputClient { latestMethodCall = 'showAutocorrectionPromptRect'; } - @override - void showToolbar() { - latestMethodCall = 'showToolbar'; - } - TextInputConfiguration get configuration => const TextInputConfiguration(); @override @@ -1010,16 +857,6 @@ class FakeTextInputClient with TextInputClient { latestMethodCall = 'didChangeInputControl'; } - @override - void insertTextPlaceholder(Size size) { - latestMethodCall = 'insertTextPlaceholder'; - } - - @override - void removeTextPlaceholder() { - latestMethodCall = 'removeTextPlaceholder'; - } - @override void performSelector(String selectorName) { latestMethodCall = 'performSelector'; @@ -1078,11 +915,6 @@ class FakeTextInputControl with TextInputControl { methodCalls.add('setEditableSizeAndTransform'); } - @override - void setSelectionRects(List selectionRects) { - methodCalls.add('setSelectionRects'); - } - @override void setStyle({ required String? fontFamily, diff --git a/packages/flutter/test/services/text_input_utils.dart b/packages/flutter/test/services/text_input_utils.dart index 67a89e69a912..e12cf3f50dc0 100644 --- a/packages/flutter/test/services/text_input_utils.dart +++ b/packages/flutter/test/services/text_input_utils.dart @@ -65,7 +65,7 @@ class FakeTextChannel implements MethodChannel { } } -class FakeScribbleElement implements ScribbleClient { +class FakeScribbleElement with ScribbleClient { FakeScribbleElement({required String elementIdentifier, Rect bounds = Rect.zero}) : _elementIdentifier = elementIdentifier, _bounds = bounds; @@ -89,4 +89,19 @@ class FakeScribbleElement implements ScribbleClient { void onScribbleFocus(Offset offset) { latestMethodCall = 'onScribbleFocus'; } + + @override + void insertTextPlaceholder(Size size) { + latestMethodCall = 'insertTextPlaceholder'; + } + + @override + void removeTextPlaceholder() { + latestMethodCall = 'removeTextPlaceholder'; + } + + @override + void showToolbar() { + latestMethodCall = 'showToolbar'; + } } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 79591e35b98e..5991c1f0a3df 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -2210,6 +2210,7 @@ void main() { final TextEditingController controller = TextEditingController(text: 'Lorem ipsum dolor sit amet'); late SelectionChangedCause selectionCause; + Scribble.ensureInitialized(); await tester.pumpWidget( MaterialApp( @@ -2229,7 +2230,7 @@ void main() { ), ); - await tester.testTextInput.scribbleFocusElement(TextInput.scribbleClients.keys.first, Offset.zero); + await tester.testTextInput.scribbleFocusElement(Scribble.scribbleClients.keys.first, Offset.zero); expect(focusNode.hasFocus, true); expect(selectionCause, SelectionChangedCause.scribble); @@ -2255,7 +2256,7 @@ void main() { ), ); - final List elementEntry = [TextInput.scribbleClients.keys.first, 0.0, 0.0, 800.0, 600.0]; + final List elementEntry = [Scribble.scribbleClients.keys.first, 0.0, 0.0, 800.0, 600.0]; List> elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); expect(elements.first, containsAll(elementEntry)); @@ -4629,8 +4630,8 @@ void main() { tester.binding.window.physicalSizeTestValue = const Size(750.0, 1334.0); final List> log = >[]; - SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { - if (methodCall.method == 'TextInput.setSelectionRects') { + SystemChannels.scribble.setMockMethodCallHandler((MethodCall methodCall) async { + if (methodCall.method == 'Scribble.setSelectionRects') { final List args = methodCall.arguments as List; final List selectionRects = []; for (final dynamic rect in args) { @@ -4801,6 +4802,76 @@ void main() { // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + testWidgets('scribble client is set based on most recent focus', (WidgetTester tester) async { + final List log = []; + SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + final TextEditingController controller = TextEditingController(); + controller.text = 'Text1'; + + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final FocusNode focusNode1 = FocusNode(); + final FocusNode focusNode2 = FocusNode(); + + Scribble.client = null; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + EditableText( + key: key1, + controller: TextEditingController(), + focusNode: focusNode1, + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + scribbleEnabled: false, + ), + EditableText( + key: key2, + controller: TextEditingController(), + focusNode: focusNode2, + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + scribbleEnabled: false, + ), + ], + ), + ), + ), + ); + + expect(Scribble.client, isNull); + + focusNode1.requestFocus(); + await tester.pump(); + + expect(Scribble.client, isNotNull); + final ScribbleClient client1 = Scribble.client!; + + focusNode2.requestFocus(); + await tester.pump(); + + expect(Scribble.client, isNot(client1)); + expect(Scribble.client, isNotNull); + + focusNode2.unfocus(); + await tester.pump(); + + expect(Scribble.client, isNull); + + // On web, we should rely on the browser's implementation of Scribble. + }, skip: kIsWeb); // [intended] + testWidgets('text styling info is sent on show keyboard', (WidgetTester tester) async { final List log = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async { diff --git a/packages/flutter_test/lib/src/test_text_input.dart b/packages/flutter_test/lib/src/test_text_input.dart index 55138cc94d4d..6735e48057a3 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -285,10 +285,10 @@ class TestTextInput { Future startScribbleInteraction() async { assert(isRegistered); await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.scribbleInteractionBegan', + 'Scribble.scribbleInteractionBegan', [_client ?? -1,] ), ), @@ -300,10 +300,10 @@ class TestTextInput { Future scribbleFocusElement(String elementIdentifier, Offset offset) async { assert(isRegistered); await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.focusElement', + 'Scribble.focusElement', [elementIdentifier, offset.dx, offset.dy] ), ), @@ -316,15 +316,15 @@ class TestTextInput { assert(isRegistered); List> response = >[]; await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.requestElementsInRect', + 'Scribble.requestElementsInRect', [rect.left, rect.top, rect.width, rect.height] ), ), (ByteData? data) { - response = (SystemChannels.textInput.codec.decodeEnvelope(data!) as List).map((dynamic element) => element as List).toList(); + response = (SystemChannels.scribble.codec.decodeEnvelope(data!) as List).map((dynamic element) => element as List).toList(); }, ); @@ -335,10 +335,10 @@ class TestTextInput { Future scribbleInsertPlaceholder() async { assert(isRegistered); await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.insertTextPlaceholder', + 'Scribble.insertTextPlaceholder', [_client ?? -1, 0.0, 0.0] ), ), @@ -350,10 +350,10 @@ class TestTextInput { Future scribbleRemovePlaceholder() async { assert(isRegistered); await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.removeTextPlaceholder', + 'Scribble.removeTextPlaceholder', [_client ?? -1] ), ),