diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index f1866d18380..618defea5c9 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -297,6 +297,7 @@ class CupertinoTextField extends StatefulWidget { this.autofillHints = const [], this.clipBehavior = Clip.hardEdge, this.restorationId, + this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, }) : assert(textAlign != null), assert(readOnly != null), @@ -468,6 +469,7 @@ class CupertinoTextField extends StatefulWidget { this.autofillHints = const [], this.clipBehavior = Clip.hardEdge, this.restorationId, + this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, }) : assert(textAlign != null), assert(readOnly != null), @@ -826,6 +828,9 @@ class CupertinoTextField extends StatefulWidget { /// {@macro flutter.material.textfield.restorationId} final String? restorationId; + /// {@macro flutter.widgets.editableText.scribbleEnabled} + final bool scribbleEnabled; + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} final bool enableIMEPersonalizedLearning; @@ -871,6 +876,7 @@ class CupertinoTextField extends StatefulWidget { properties.add(DiagnosticsProperty('textAlignVertical', textAlignVertical, defaultValue: null)); properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); properties.add(DiagnosticsProperty('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge)); + properties.add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); } } @@ -991,6 +997,9 @@ class _CupertinoTextFieldState extends State with Restoratio if (cause == SelectionChangedCause.keyboard) return false; + if (cause == SelectionChangedCause.scribble) + return true; + if (_effectiveController.text.isNotEmpty) return true; @@ -1320,6 +1329,7 @@ class _CupertinoTextFieldState extends State with Restoratio autofillClient: this, clipBehavior: widget.clipBehavior, restorationId: 'editable', + scribbleEnabled: widget.scribbleEnabled, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, ), ), diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 523b3f8bec2..ccb7e2936a7 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -329,6 +329,7 @@ class TextField extends StatefulWidget { this.autofillHints = const [], this.clipBehavior = Clip.hardEdge, this.restorationId, + this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, }) : assert(textAlign != null), assert(readOnly != null), @@ -781,6 +782,9 @@ class TextField extends StatefulWidget { /// {@endtemplate} final String? restorationId; + /// {@macro flutter.widgets.editableText.scribbleEnabled} + final bool scribbleEnabled; + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} final bool enableIMEPersonalizedLearning; @@ -825,6 +829,7 @@ class TextField extends StatefulWidget { properties.add(DiagnosticsProperty('scrollController', scrollController, defaultValue: null)); properties.add(DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null)); properties.add(DiagnosticsProperty('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge)); + properties.add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); } } @@ -1042,7 +1047,7 @@ class _TextFieldState extends State with RestorationMixin implements if (!_isEnabled) return false; - if (cause == SelectionChangedCause.longPress) + if (cause == SelectionChangedCause.longPress || cause == SelectionChangedCause.scribble) return true; if (_effectiveController.text.isNotEmpty) @@ -1286,6 +1291,7 @@ class _TextFieldState extends State with RestorationMixin implements autocorrectionTextRectColor: autocorrectionTextRectColor, clipBehavior: widget.clipBehavior, restorationId: 'editable', + scribbleEnabled: widget.scribbleEnabled, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, ), ), diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index f8113e3121b..5f8cebfd7b8 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -1265,6 +1265,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, // [assembleSemanticsNode] invocations. Queue? _cachedChildNodes; + /// Returns a list of rects that bound the given selection. + /// + /// See [TextPainter.getBoxesForSelection] for more details. + List getBoxesForSelection(TextSelection selection) { + _computeTextMetricsIfNeeded(); + return _textPainter.getBoxesForSelection(selection) + .map((TextBox textBox) => textBox.toRect().shift(_paintOffset)) + .toList(); + } + @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index aaddbf579c9..7bf468ab60f 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -955,6 +955,9 @@ enum SelectionChangedCause { /// The user used the mouse to change the selection by dragging over a piece /// of text. drag, + + /// The user used iPadOS 14+ Scribble to change the selection. + scribble, } /// A mixin for manipulating the selection, provided for toolbar or shortcut @@ -1105,6 +1108,76 @@ abstract class TextInputClient { /// /// [TextInputClient] should cleanup its connection and finalize editing. 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]. @@ -1154,6 +1227,7 @@ class TextInputConnection { Matrix4? _cachedTransform; Rect? _cachedRect; Rect? _cachedCaretRect; + List _cachedSelectionRects = []; static int _nextId = 1; final int _id; @@ -1176,6 +1250,12 @@ 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); @@ -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 selectionRects) { + if (!listEquals(_cachedSelectionRects, selectionRects)) { + _cachedSelectionRects = selectionRects; + TextInput._instance._setSelectionRects(selectionRects.map((SelectionRect rect) { + return [rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position]; + }).toList()); + } + } + /// Send text styling information. /// /// This information is used by the Flutter Web Engine to change the style @@ -1535,10 +1628,43 @@ 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 _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; - final String method = methodCall.method; // The requestExistingInputState request needs to be handled regardless of // the client ID, as long as we have a _currentConnection. @@ -1630,6 +1756,15 @@ 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(); } @@ -1703,6 +1838,13 @@ class TextInput { ); } + void _setSelectionRects(List> args) { + _channel.invokeMethod( + 'TextInput.setSelectionRects', + args, + ); + } + void _setStyle(Map args) { _channel.invokeMethod( 'TextInput.setStyle', @@ -1765,4 +1907,18 @@ class TextInput { 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); + } } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index e69cc1b20af..f91e9e61de9 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'dart:math' as math; import 'dart:ui' as ui hide TextStyle; -import 'package:characters/characters.dart' show CharacterRange; +import 'package:characters/characters.dart' show CharacterRange, StringCharacters; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/rendering.dart'; @@ -58,6 +58,10 @@ const Duration _kCursorBlinkWaitForStart = Duration(milliseconds: 150); // is shown in an obscured text field. const int _kObscureShowLatestCharCursorTicks = 3; +// The minimum width of an iPad screen. The smallest iPad is currently the +// iPad Mini 6th Gen according to ios-resolution.com. +const double _kIPadWidth = 1488.0; + /// A controller for an editable text field. /// /// Whenever the user modifies a text field with an associated @@ -518,6 +522,7 @@ class EditableText extends StatefulWidget { this.clipBehavior = Clip.hardEdge, this.restorationId, this.scrollBehavior, + this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, }) : assert(controller != null), assert(focusNode != null), @@ -1232,6 +1237,15 @@ class EditableText extends StatefulWidget { /// [scrollPhysics]. final ScrollPhysics? scrollPhysics; + /// {@template flutter.widgets.editableText.scribbleEnabled} + /// Whether iOS 14 Scribble features are enabled for this widget. + /// + /// Only available on iPads. + /// + /// Defaults to true. + /// {@endtemplate} + final bool scribbleEnabled; + /// {@template flutter.widgets.editableText.selectionEnabled} /// Same as [enableInteractiveSelection]. /// @@ -1533,6 +1547,7 @@ class EditableText extends StatefulWidget { properties.add(DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null)); properties.add(DiagnosticsProperty>('autofillHints', autofillHints, defaultValue: null)); properties.add(DiagnosticsProperty('textHeightBehavior', textHeightBehavior, defaultValue: null)); + properties.add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true)); } @@ -1902,7 +1917,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (value.text == _value.text && value.composing == _value.composing) { // `selection` is the only change. - _handleSelectionChanged(value.selection, SelectionChangedCause.keyboard); + _handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard); } else { hideToolbar(); _currentPromptRectRange = null; @@ -2686,6 +2701,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien // Place cursor at the end if the selection is invalid when we receive focus. _handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null); } + + _cachedText = ''; + _cachedFirstRect = null; + _cachedSize = Size.zero; + _cachedPlaceholder = -1; } else { WidgetsBinding.instance!.removeObserver(this); setState(() { _currentPromptRectRange = null; }); @@ -2693,13 +2713,82 @@ class EditableTextState extends State with AutomaticKeepAliveClien updateKeepAlive(); } + String _cachedText = ''; + Rect? _cachedFirstRect; + Size _cachedSize = Size.zero; + int _cachedPlaceholder = -1; + TextStyle? _cachedTextStyle; + + void _updateSelectionRects({bool force = false}) { + if (!widget.scribbleEnabled) + return; + if (defaultTargetPlatform != TargetPlatform.iOS) + return; + // This is to avoid sending selection rects on non-iPad devices. + if (WidgetsBinding.instance!.window.physicalSize.shortestSide < _kIPadWidth) + return; + + final String text = renderEditable.text?.toPlainText(includeSemanticsLabels: false) ?? ''; + final List firstSelectionBoxes = renderEditable.getBoxesForSelection(const TextSelection(baseOffset: 0, extentOffset: 1)); + final Rect? firstRect = firstSelectionBoxes.isNotEmpty ? firstSelectionBoxes.first : null; + final ScrollDirection scrollDirection = _scrollController.position.userScrollDirection; + final Size size = renderEditable.size; + final bool textChanged = text != _cachedText; + final bool textStyleChanged = _cachedTextStyle != widget.style; + final bool firstRectChanged = _cachedFirstRect != firstRect; + final bool sizeChanged = _cachedSize != size; + final bool placeholderChanged = _cachedPlaceholder != _placeholderLocation; + if (scrollDirection == ScrollDirection.idle && (force || textChanged || textStyleChanged || firstRectChanged || sizeChanged || placeholderChanged)) { + _cachedText = text; + _cachedFirstRect = firstRect; + _cachedTextStyle = widget.style; + _cachedSize = size; + _cachedPlaceholder = _placeholderLocation; + bool belowRenderEditableBottom = false; + final List rects = List.generate( + _cachedText.characters.length, + (int i) { + if (belowRenderEditableBottom) + return null; + + final int offset = _cachedText.characters.getRange(0, i).string.length; + final List boxes = renderEditable.getBoxesForSelection(TextSelection(baseOffset: offset, extentOffset: offset + _cachedText.characters.characterAt(i).string.length)); + if (boxes.isEmpty) + return null; + + final SelectionRect selectionRect = SelectionRect( + bounds: boxes.first, + position: offset, + ); + if (renderEditable.paintBounds.bottom < selectionRect.bounds.top) { + belowRenderEditableBottom = true; + return null; + } + return selectionRect; + }, + ).where((SelectionRect? selectionRect) { + if (selectionRect == null) + return false; + if (renderEditable.paintBounds.right < selectionRect.bounds.left || selectionRect.bounds.right < renderEditable.paintBounds.left) + return false; + if (renderEditable.paintBounds.bottom < selectionRect.bounds.top || selectionRect.bounds.bottom < renderEditable.paintBounds.top) + return false; + return true; + }).map((SelectionRect? selectionRect) => selectionRect!).toList(); + _textInputConnection!.setSelectionRects(rects); + } + } + void _updateSizeAndTransform() { if (_hasInputConnection) { final Size size = renderEditable.size; final Matrix4 transform = renderEditable.getTransformTo(null); _textInputConnection!.setEditableSizeAndTransform(size, transform); + _updateSelectionRects(); SchedulerBinding.instance! .addPostFrameCallback((Duration _) => _updateSizeAndTransform()); + } else if (_placeholderLocation != -1) { + removeTextPlaceholder(); } } @@ -2782,6 +2871,7 @@ 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 @@ -2820,6 +2910,36 @@ 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 String get autofillId => 'EditableText-$hashCode'; @@ -3046,53 +3166,62 @@ class EditableTextState extends State with AutomaticKeepAliveClien onCopy: _semanticsOnCopy(controls), onCut: _semanticsOnCut(controls), onPaste: _semanticsOnPaste(controls), - child: _Editable( - key: _editableKey, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - inlineSpan: buildTextSpan(), - value: _value, - cursorColor: _cursorColor, - backgroundCursorColor: widget.backgroundCursorColor, - showCursor: EditableText.debugDeterministicCursor - ? ValueNotifier(widget.showCursor) - : _cursorVisibilityNotifier, - forceLine: widget.forceLine, - readOnly: widget.readOnly, - hasFocus: _hasFocus, - maxLines: widget.maxLines, - minLines: widget.minLines, - expands: widget.expands, - strutStyle: widget.strutStyle, - selectionColor: widget.selectionColor, - textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), - textAlign: widget.textAlign, - textDirection: _textDirection, - locale: widget.locale, - textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context), - textWidthBasis: widget.textWidthBasis, - obscuringCharacter: widget.obscuringCharacter, - obscureText: widget.obscureText, - autocorrect: widget.autocorrect, - smartDashesType: widget.smartDashesType, - smartQuotesType: widget.smartQuotesType, - enableSuggestions: widget.enableSuggestions, - offset: offset, - onCaretChanged: _handleCaretChanged, - rendererIgnoresPointer: widget.rendererIgnoresPointer, - cursorWidth: widget.cursorWidth, - cursorHeight: widget.cursorHeight, - cursorRadius: widget.cursorRadius, - cursorOffset: widget.cursorOffset ?? Offset.zero, - selectionHeightStyle: widget.selectionHeightStyle, - selectionWidthStyle: widget.selectionWidthStyle, - paintCursorAboveText: widget.paintCursorAboveText, - enableInteractiveSelection: widget.enableInteractiveSelection && (!widget.readOnly || !widget.obscureText), - textSelectionDelegate: this, - devicePixelRatio: _devicePixelRatio, - promptRectRange: _currentPromptRectRange, - promptRectColor: widget.autocorrectionTextRectColor, - clipBehavior: widget.clipBehavior, + child: _ScribbleFocusable( + focusNode: widget.focusNode, + editableKey: _editableKey, + enabled: widget.scribbleEnabled, + updateSelectionRects: () { + _openInputConnection(); + _updateSelectionRects(force: true); + }, + child: _Editable( + key: _editableKey, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + inlineSpan: buildTextSpan(), + value: _value, + cursorColor: _cursorColor, + backgroundCursorColor: widget.backgroundCursorColor, + showCursor: EditableText.debugDeterministicCursor + ? ValueNotifier(widget.showCursor) + : _cursorVisibilityNotifier, + forceLine: widget.forceLine, + readOnly: widget.readOnly, + hasFocus: _hasFocus, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + strutStyle: widget.strutStyle, + selectionColor: widget.selectionColor, + textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), + textAlign: widget.textAlign, + textDirection: _textDirection, + locale: widget.locale, + textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context), + textWidthBasis: widget.textWidthBasis, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + offset: offset, + onCaretChanged: _handleCaretChanged, + rendererIgnoresPointer: widget.rendererIgnoresPointer, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorOffset: widget.cursorOffset ?? Offset.zero, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + paintCursorAboveText: widget.paintCursorAboveText, + enableInteractiveSelection: widget.enableInteractiveSelection && (!widget.readOnly || !widget.obscureText), + textSelectionDelegate: this, + devicePixelRatio: _devicePixelRatio, + promptRectRange: _currentPromptRectRange, + promptRectColor: widget.autocorrectionTextRectColor, + clipBehavior: widget.clipBehavior, + ), ), ), ); @@ -3122,6 +3251,24 @@ class EditableTextState extends State with AutomaticKeepAliveClien } return TextSpan(style: widget.style, text: text); } + if (_placeholderLocation >= 0 && _placeholderLocation <= _value.text.length) { + final List<_ScribblePlaceholder> placeholders = <_ScribblePlaceholder>[]; + final int placeholderLocation = _value.text.length - _placeholderLocation; + if (_isMultiline) { + // The zero size placeholder here allows the line to break and keep the caret on the first line. + placeholders.add(const _ScribblePlaceholder(child: SizedBox(), size: Size.zero)); + placeholders.add(_ScribblePlaceholder(child: const SizedBox(), size: Size(renderEditable.size.width, 0.0))); + } else { + placeholders.add(const _ScribblePlaceholder(child: SizedBox(), size: Size(100.0, 0.0))); + } + return TextSpan(style: widget.style, children: [ + TextSpan(text: _value.text.substring(0, placeholderLocation)), + ...placeholders, + TextSpan(text: _value.text.substring(placeholderLocation)), + ], + ); + } + // Read only mode should not paint text composing. return widget.controller.buildTextSpan( context: context, @@ -3327,6 +3474,142 @@ class _Editable extends MultiChildRenderObjectWidget { } } +class _ScribbleFocusable extends StatefulWidget { + const _ScribbleFocusable({ + Key? key, + required this.child, + required this.focusNode, + required this.editableKey, + required this.updateSelectionRects, + required this.enabled, + }): super(key: key); + + final Widget child; + final FocusNode focusNode; + final GlobalKey editableKey; + final VoidCallback updateSelectionRects; + final bool enabled; + + @override + _ScribbleFocusableState createState() => _ScribbleFocusableState(); +} + +class _ScribbleFocusableState extends State<_ScribbleFocusable> implements ScribbleClient { + _ScribbleFocusableState(): _elementIdentifier = (_nextElementIdentifier++).toString(); + + @override + void initState() { + super.initState(); + if (widget.enabled) { + TextInput.registerScribbleElement(elementIdentifier, this); + } + } + + @override + void didUpdateWidget(_ScribbleFocusable oldWidget) { + super.didUpdateWidget(oldWidget); + if (!oldWidget.enabled && widget.enabled) { + TextInput.registerScribbleElement(elementIdentifier, this); + } + + if (oldWidget.enabled && !widget.enabled) { + TextInput.unregisterScribbleElement(elementIdentifier); + } + } + + @override + void dispose() { + TextInput.unregisterScribbleElement(elementIdentifier); + super.dispose(); + } + + RenderEditable? get renderEditable => widget.editableKey.currentContext?.findRenderObject() as RenderEditable?; + + static int _nextElementIdentifier = 1; + final String _elementIdentifier; + + @override + String get elementIdentifier => _elementIdentifier; + + @override + void onScribbleFocus(Offset offset) { + widget.focusNode.requestFocus(); + renderEditable?.selectPositionAt(from: offset, cause: SelectionChangedCause.scribble); + widget.updateSelectionRects(); + } + + @override + bool isInScribbleRect(Rect rect) { + final Rect calculatedBounds = bounds; + if (renderEditable?.readOnly ?? false) + return false; + if (calculatedBounds == Rect.zero) + return false; + if (!calculatedBounds.overlaps(rect)) + return false; + 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); + } + + @override + Rect get bounds { + final RenderBox? box = context.findRenderObject() as RenderBox?; + if (box == null || !mounted || !box.attached) + return Rect.zero; + final Matrix4 transform = box.getTransformTo(null); + return MatrixUtils.transformRect(transform, Rect.fromLTWH(0, 0, box.size.width, box.size.height)); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +class _ScribblePlaceholder extends WidgetSpan { + const _ScribblePlaceholder({ + required Widget child, + ui.PlaceholderAlignment alignment = ui.PlaceholderAlignment.bottom, + TextBaseline? baseline, + TextStyle? style, + required this.size, + }) : assert(child != null), + assert(baseline != null || !( + identical(alignment, ui.PlaceholderAlignment.aboveBaseline) || + identical(alignment, ui.PlaceholderAlignment.belowBaseline) || + identical(alignment, ui.PlaceholderAlignment.baseline) + )), + super( + alignment: alignment, + baseline: baseline, + style: style, + child: child, + ); + + /// The size of the span, used in place of adding a placeholder size to the [TextPainter]. + final Size size; + + @override + void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, List? dimensions }) { + assert(debugAssertIsValid()); + final bool hasStyle = style != null; + if (hasStyle) { + builder.pushStyle(style!.getTextStyle(textScaleFactor: textScaleFactor)); + } + builder.addPlaceholder( + size.width, + size.height, + alignment, + scale: textScaleFactor, + ); + if (hasStyle) { + builder.pop(); + } + } +} + /// An interface for retriving the logical text boundary (left-closed-right-open) /// at a given location in a document. /// diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index fb0495ce921..1a2a8581242 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -2454,7 +2454,7 @@ void main() { ); final RenderEditable renderEditable = tester.renderObject( - find.byElementPredicate((Element element) => element.renderObject is RenderEditable), + find.byElementPredicate((Element element) => element.renderObject is RenderEditable).last, ); List lastCharEndpoint = renderEditable.getEndpointsForSelection( @@ -3252,7 +3252,7 @@ void main() { expect( tester.renderObject( - find.byElementPredicate((Element element) => element.renderObject is RenderEditable), + find.byElementPredicate((Element element) => element.renderObject is RenderEditable).last, ).text!.style!.color, isSameColorAs(CupertinoColors.white), ); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 4656ba22d68..63e94f68136 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -9179,6 +9179,38 @@ void main() { expect(right.opacity.value, equals(1.0)); }, variant: const TargetPlatformVariant({ 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 transitions = + find.byType(FadeTransition).evaluate().map((Element e) => e.widget).cast().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.iOS })); + testWidgets('Tap shows handles but not toolbar', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'abc def ghi', diff --git a/packages/flutter/test/services/autofill_test.dart b/packages/flutter/test/services/autofill_test.dart index 11fdcd93eab..e463a08fc60 100644 --- a/packages/flutter/test/services/autofill_test.dart +++ b/packages/flutter/test/services/autofill_test.dart @@ -2,6 +2,8 @@ // 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/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -141,6 +143,21 @@ 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'; + } } class FakeAutofillScope with AutofillScopeMixin implements AutofillScope { diff --git a/packages/flutter/test/services/delta_text_input_test.dart b/packages/flutter/test/services/delta_text_input_test.dart index e87a66521f8..eca5455aff6 100644 --- a/packages/flutter/test/services/delta_text_input_test.dart +++ b/packages/flutter/test/services/delta_text_input_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:convert' show jsonDecode; +import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -114,5 +115,20 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient { 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); } diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index 09e5831110d..8591a04a8c5 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -4,6 +4,7 @@ import 'dart:convert' show jsonDecode; +import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -496,6 +497,148 @@ 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 { @@ -567,5 +710,20 @@ class FakeTextInputClient implements TextInputClient { latestMethodCall = 'showAutocorrectionPromptRect'; } + @override + void showToolbar() { + latestMethodCall = 'showToolbar'; + } + TextInputConfiguration get configuration => const TextInputConfiguration(); + + @override + void insertTextPlaceholder(Size size) { + latestMethodCall = 'insertTextPlaceholder'; + } + + @override + void removeTextPlaceholder() { + latestMethodCall = 'removeTextPlaceholder'; + } } diff --git a/packages/flutter/test/services/text_input_utils.dart b/packages/flutter/test/services/text_input_utils.dart index e1e3ddf9b59..2598c098a85 100644 --- a/packages/flutter/test/services/text_input_utils.dart +++ b/packages/flutter/test/services/text_input_utils.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:convert' show utf8; +import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -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'; + } +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 19d1a9bcebb..3c1ec2b84bb 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1775,6 +1775,329 @@ void main() { } }); + testWidgets('Selection changes during Scribble interaction should have the scribble cause', (WidgetTester tester) async { + late SelectionChangedCause selectionCause; + + final TextEditingController controller = + TextEditingController(text: 'Lorem ipsum dolor sit amet'); + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + if (cause != null) + selectionCause = cause; + }, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + // A normal selection update from the framework has 'keyboard' as the cause. + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 2, extentOffset: 3), + )); + await tester.pumpAndSettle(); + + expect(selectionCause, SelectionChangedCause.keyboard); + + // A selection update during a scribble interaction has 'scribble' as the cause. + await tester.testTextInput.startScribbleInteraction(); + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 3, extentOffset: 4), + )); + await tester.pumpAndSettle(); + + expect(selectionCause, SelectionChangedCause.scribble); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); + + testWidgets('Requests focus and changes the selection when onScribbleFocus is called', (WidgetTester tester) async { + final TextEditingController controller = + TextEditingController(text: 'Lorem ipsum dolor sit amet'); + late SelectionChangedCause selectionCause; + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + if (cause != null) + selectionCause = cause; + }, + ), + ), + ); + + await tester.testTextInput.scribbleFocusElement(TextInput.scribbleClients.keys.first, Offset.zero); + + expect(focusNode.hasFocus, true); + expect(selectionCause, SelectionChangedCause.scribble); + + // On web, we should rely on the browser's implementation of Scribble, so the selection changed cause + // will never be SelectionChangedCause.scribble. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('Declares itself for Scribble interaction if the bounds overlap the scribble rect and the widget is touchable', (WidgetTester tester) async { + final TextEditingController controller = + TextEditingController(text: 'Lorem ipsum dolor sit amet'); + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final List elementEntry = [TextInput.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)); + + // Touch is outside the bounds of the widget. + elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(-1, -1, 1, 1)); + expect(elements.length, 0); + + // Widget is read only. + await tester.pumpWidget( + MaterialApp( + home: EditableText( + readOnly: true, + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); + expect(elements.length, 0); + + // Widget is not touchable. + await tester.pumpWidget( + MaterialApp( + home: Stack(children: [ + EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + Positioned( + left: 0, + top: 0, + right: 0, + bottom: 0, + child: Container(color: Colors.black), + ), + ], + ), + ), + ); + + elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); + expect(elements.length, 0); + + // Widget has scribble disabled. + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + scribbleEnabled: false, + ), + ), + ); + + elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); + expect(elements.length, 0); + + + // On web, we should rely on the browser's implementation of Scribble, so the engine will + // never request the scribble elements. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('single line Scribble fields can show a horizontal placeholder', (WidgetTester tester) async { + final TextEditingController controller = + TextEditingController(text: 'Lorem ipsum dolor sit amet'); + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 5, extentOffset: 5), + )); + await tester.pumpAndSettle(); + + await tester.testTextInput.scribbleInsertPlaceholder(); + await tester.pumpAndSettle(); + + TextSpan textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children!.length, 3); + expect((textSpan.children![0] as TextSpan).text, 'Lorem'); + expect(textSpan.children![1] is WidgetSpan, true); + expect((textSpan.children![2] as TextSpan).text, ' ipsum dolor sit amet'); + + await tester.testTextInput.scribbleRemovePlaceholder(); + await tester.pumpAndSettle(); + + textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children, null); + expect(textSpan.text, 'Lorem ipsum dolor sit amet'); + + // Widget has scribble disabled. + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + scribbleEnabled: false, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 5, extentOffset: 5), + )); + await tester.pumpAndSettle(); + + await tester.testTextInput.scribbleInsertPlaceholder(); + await tester.pumpAndSettle(); + + textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children, null); + expect(textSpan.text, 'Lorem ipsum dolor sit amet'); + + // On web, we should rely on the browser's implementation of Scribble, so the framework + // will not handle placeholders. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('multiline Scribble fields can show a vertical placeholder', (WidgetTester tester) async { + final TextEditingController controller = + TextEditingController(text: 'Lorem ipsum dolor sit amet'); + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + maxLines: 2, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 5, extentOffset: 5), + )); + await tester.pumpAndSettle(); + + await tester.testTextInput.scribbleInsertPlaceholder(); + await tester.pumpAndSettle(); + + TextSpan textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children!.length, 4); + expect((textSpan.children![0] as TextSpan).text, 'Lorem'); + expect(textSpan.children![1] is WidgetSpan, true); + expect(textSpan.children![2] is WidgetSpan, true); + expect((textSpan.children![3] as TextSpan).text, ' ipsum dolor sit amet'); + + await tester.testTextInput.scribbleRemovePlaceholder(); + await tester.pumpAndSettle(); + + textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children, null); + expect(textSpan.text, 'Lorem ipsum dolor sit amet'); + + // Widget has scribble disabled. + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + maxLines: 2, + scribbleEnabled: false, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 5, extentOffset: 5), + )); + await tester.pumpAndSettle(); + + await tester.testTextInput.scribbleInsertPlaceholder(); + await tester.pumpAndSettle(); + + textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children, null); + expect(textSpan.text, 'Lorem ipsum dolor sit amet'); + + // On web, we should rely on the browser's implementation of Scribble, so the framework + // will not handle placeholders. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + testWidgets('Sends "updateConfig" when read-only flag is flipped', (WidgetTester tester) async { bool readOnly = true; late StateSetter setState; @@ -3850,6 +4173,85 @@ void main() { ); }); + testWidgets('selection rects are sent when they change', (WidgetTester tester) async { + final List log = []; + SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + final TextEditingController controller = TextEditingController(); + controller.text = 'Text1'; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + EditableText( + key: ValueKey(controller.text), + controller: controller, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), + ], + ), + ), + ), + ); + await tester.showKeyboard(find.byKey(ValueKey(controller.text))); + + // There should be a new platform message updating the selection rects. + final MethodCall methodCall = log.firstWhere((MethodCall m) => m.method == 'TextInput.setSelectionRects'); + expect(methodCall.method, 'TextInput.setSelectionRects'); + expect((methodCall.arguments as List).length, 5); + + // 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('selection rects are not sent if scribbleEnabled is false', (WidgetTester tester) async { + final List log = []; + SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + final TextEditingController controller = TextEditingController(); + controller.text = 'Text1'; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + EditableText( + key: ValueKey(controller.text), + controller: controller, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + scribbleEnabled: false, + ), + ], + ), + ), + ), + ); + await tester.showKeyboard(find.byKey(ValueKey(controller.text))); + + // There should be a new platform message updating the selection rects. + expect(log.where((MethodCall m) => m.method == 'TextInput.setSelectionRects').length, 0); + + // 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('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 a9787c04023..1a4f5d9caff 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -3,6 +3,8 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' show Rect, Offset; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -271,4 +273,84 @@ class TestTextInput { (ByteData? data) { /* response from framework is discarded */ }, ); } + + /// Simulates a scribble interaction starting. + Future startScribbleInteraction() async { + assert(isRegistered); + await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.scribbleInteractionBegan', + [_client ?? -1,] + ), + ), + (ByteData? data) { /* response from framework is discarded */ }, + ); + } + + /// Simulates a Scribble focus. + Future scribbleFocusElement(String elementIdentifier, Offset offset) async { + assert(isRegistered); + await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.focusElement', + [elementIdentifier, offset.dx, offset.dy] + ), + ), + (ByteData? data) { /* response from framework is discarded */ }, + ); + } + + /// Simulates iOS asking for the list of Scribble elements during UIIndirectScribbleInteraction. + Future>> scribbleRequestElementsInRect(Rect rect) async { + assert(isRegistered); + List> response = >[]; + await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.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(); + }, + ); + + return response; + } + + /// Simulates iOS inserting a UITextPlaceholder during a long press with the pencil. + Future scribbleInsertPlaceholder() async { + assert(isRegistered); + await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.insertTextPlaceholder', + [_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 scribbleRemovePlaceholder() async { + assert(isRegistered); + await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.removeTextPlaceholder', + [_client ?? -1] + ), + ), + (ByteData? data) { /* response from framework is discarded */ }, + ); + } }