Scribe Android handwriting text input (#148784)

Enables the Scribe feature, or Android stylus handwriting text input.


![scribe](https://github.com/flutter/flutter/assets/389558/25a54ae9-9399-4772-8482-913ec7a9b330)

This PR only implements basic handwriting input. Other features will be
done in subsequent PRs:

 * https://github.com/flutter/flutter/issues/155948
 * https://github.com/flutter/flutter/issues/156018

I created and fixed issue about stylus hovering while working on this:
https://github.com/flutter/flutter/issues/148810

Original PR for iOS Scribble, the iOS version of this feature:
https://github.com/flutter/flutter/pull/75472
FYI @fbcouch 

~~Depends on https://github.com/flutter/engine/pull/52943~~ (merged).

Fixes https://github.com/flutter/flutter/issues/115607

<details>

<summary>Example code I'm using to test this feature (but any TextField
works)</summary>

```dart
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final FocusNode _focusNode1 = FocusNode();
  final FocusNode _focusNode2 = FocusNode();
  final FocusNode _focusNode3 = FocusNode();
  final TextEditingController _controller3 = TextEditingController(
    text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scribe demo'),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 74.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              TextField(
                focusNode: _focusNode1,
                autofocus: false,
              ),
              TextField(
                focusNode: _focusNode2,
              ),
              TextField(
                focusNode: _focusNode3,
                minLines: 4,
                maxLines: 4,
                controller: _controller3,
              ),
              TextButton(
                onPressed: () {
                  _focusNode1.unfocus();
                  _focusNode2.unfocus();
                  _focusNode3.unfocus();
                },
                child: const Text('Unfocus'),
              ),
              TextButton(
                onPressed: () {
                  _focusNode1.requestFocus();
                  SchedulerBinding.instance.addPostFrameCallback((Duration _) {
                    SystemChannels.textInput.invokeMethod('TextInput.hide');
                  });
                },
                child: const Text('Focus 1'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

</details>

---------

Co-authored-by: Nate Wilson <nate.w5687@gmail.com>
This commit is contained in:
Justin McCandless 2024-11-20 17:33:14 -08:00 committed by GitHub
parent 5ead4e15a7
commit b473698019
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1315 additions and 679 deletions

View File

@ -44,6 +44,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/scribe.dart';
export 'src/services/service_extensions.dart';
export 'src/services/spell_check.dart';
export 'src/services/system_channels.dart';

View File

@ -295,7 +295,12 @@ class CupertinoTextField extends StatefulWidget {
this.contentInsertionConfiguration,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
@Deprecated(
'Use `stylusHandwritingEnabled` instead. '
'This feature was deprecated after v3.27.0-0.2.pre.',
)
this.scribbleEnabled = true,
this.stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled,
this.enableIMEPersonalizedLearning = true,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.spellCheckConfiguration,
@ -425,7 +430,12 @@ class CupertinoTextField extends StatefulWidget {
this.contentInsertionConfiguration,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
@Deprecated(
'Use `stylusHandwritingEnabled` instead. '
'This feature was deprecated after v3.27.0-0.2.pre.',
)
this.scribbleEnabled = true,
this.stylusHandwritingEnabled = true,
this.enableIMEPersonalizedLearning = true,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.spellCheckConfiguration,
@ -762,8 +772,15 @@ class CupertinoTextField extends StatefulWidget {
final String? restorationId;
/// {@macro flutter.widgets.editableText.scribbleEnabled}
@Deprecated(
'Use `stylusHandwritingEnabled` instead. '
'This feature was deprecated after v3.27.0-0.2.pre.',
)
final bool scribbleEnabled;
/// {@macro flutter.widgets.editableText.stylusHandwritingEnabled}
final bool stylusHandwritingEnabled;
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
@ -895,6 +912,7 @@ class CupertinoTextField extends StatefulWidget {
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge));
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('stylusHandwritingEnabled', stylusHandwritingEnabled, defaultValue: EditableText.defaultStylusHandwritingEnabled));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null));
properties.add(DiagnosticsProperty<List<String>>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const <String>[], defaultValue: contentInsertionConfiguration == null ? const <String>[] : kDefaultContentInsertionMimeTypes));
@ -1447,6 +1465,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
clipBehavior: widget.clipBehavior,
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
stylusHandwritingEnabled: widget.stylusHandwritingEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
contentInsertionConfiguration: widget.contentInsertionConfiguration,
contextMenuBuilder: widget.contextMenuBuilder,

View File

@ -301,7 +301,12 @@ class TextField extends StatefulWidget {
this.contentInsertionConfiguration,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
@Deprecated(
'Use `stylusHandwritingEnabled` instead. '
'This feature was deprecated after v3.27.0-0.2.pre.',
)
this.scribbleEnabled = true,
this.stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled,
this.enableIMEPersonalizedLearning = true,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.canRequestFocus = true,
@ -797,8 +802,15 @@ class TextField extends StatefulWidget {
final String? restorationId;
/// {@macro flutter.widgets.editableText.scribbleEnabled}
@Deprecated(
'Use `stylusHandwritingEnabled` instead. '
'This feature was deprecated after v3.27.0-0.2.pre.',
)
final bool scribbleEnabled;
/// {@macro flutter.widgets.editableText.stylusHandwritingEnabled}
final bool stylusHandwritingEnabled;
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
@ -948,6 +960,7 @@ class TextField extends StatefulWidget {
properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge));
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('stylusHandwritingEnabled', stylusHandwritingEnabled, defaultValue: EditableText.defaultStylusHandwritingEnabled));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null));
properties.add(DiagnosticsProperty<List<String>>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const <String>[], defaultValue: contentInsertionConfiguration == null ? const <String>[] : kDefaultContentInsertionMimeTypes));
@ -1514,6 +1527,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
clipBehavior: widget.clipBehavior,
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
stylusHandwritingEnabled: widget.stylusHandwritingEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
contentInsertionConfiguration: widget.contentInsertionConfiguration,
contextMenuBuilder: widget.contextMenuBuilder,

View File

@ -182,7 +182,12 @@ class TextFormField extends FormField<String> {
ContentInsertionConfiguration? contentInsertionConfiguration,
MaterialStatesController? statesController,
Clip clipBehavior = Clip.hardEdge,
@Deprecated(
'Use `stylusHandwritingEnabled` instead. '
'This feature was deprecated after v3.27.0-0.2.pre.',
)
bool scribbleEnabled = true,
bool stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled,
bool canRequestFocus = true,
}) : assert(initialValue == null || controller == null),
assert(obscuringCharacter.length == 1),
@ -279,6 +284,7 @@ class TextFormField extends FormField<String> {
contentInsertionConfiguration: contentInsertionConfiguration,
clipBehavior: clipBehavior,
scribbleEnabled: scribbleEnabled,
stylusHandwritingEnabled: stylusHandwritingEnabled,
canRequestFocus: canRequestFocus,
),
);

View File

@ -0,0 +1,143 @@
// 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/foundation.dart';
import 'message_codec.dart';
import 'system_channels.dart';
/// An interface into Android's stylus handwriting text input.
///
/// Allows handwriting directly on top of a text input using a stylus.
///
/// See also:
///
/// * [EditableText.stylusHandwritingEnabled], which controls whether Flutter's
/// built-in text fields support handwriting input.
/// * [SystemChannels.scribe], which is the [MethodChannel] used by this
/// class, and which has a list of the methods that this class handles.
/// * <https://developer.android.com/develop/ui/views/touch-and-input/stylus-input/stylus-input-in-text-fields>,
/// which is the Android documentation explaining the Scribe feature.
abstract final class Scribe {
static const MethodChannel _channel = SystemChannels.scribe;
/// A convenience method to check if the device currently supports Scribe
/// stylus handwriting input.
///
/// Call this each time before calling [startStylusHandwriting] to make sure
/// it's available.
///
/// {@tool snippet}
/// This example shows using [isFeatureAvailable] to confirm that
/// [startStylusHandwriting] can be called.
///
/// ```dart
/// if (!await Scribe.isFeatureAvailable()) {
/// // The device doesn't support stylus input right now, or maybe at all.
/// return;
/// }
///
/// // Scribe is supported, so start it.
/// Scribe.startStylusHandwriting();
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [isStylusHandwritingAvailable], which is similar, but throws an error
/// when called by an unsupported API level. It directly corresponds to the
/// underlying Android API
/// <https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#isStylusHandwritingAvailable()>.
/// * [EditableText.stylusHandwritingEnabled], which controls whether
/// Flutter's built-in text fields support handwriting input.
static Future<bool> isFeatureAvailable() async {
final bool? result = await _channel.invokeMethod<bool?>(
'Scribe.isFeatureAvailable',
);
if (result == null) {
throw FlutterError('MethodChannel.invokeMethod unexpectedly returned null.');
}
return result;
}
/// Returns true if the InputMethodManager supports Scribe stylus handwriting
/// input, false otherwise.
///
/// Call this each time before calling [startStylusHandwriting] to make sure
/// it's available.
///
/// Supported on Android API 34 and above. If called by an unsupported API
/// level, a [PlatformException] will be thrown. To avoid error handling, use
/// the convenience method [isFeatureAvailable] instead.
///
/// {@tool snippet}
/// This example shows using [isStylusHandwritingAvailable] to confirm that
/// [startStylusHandwriting] can be called.
///
/// ```dart
/// try {
/// if (!await Scribe.isStylusHandwritingAvailable()) {
/// // If isStylusHandwritingAvailable returns false then the device's API level
/// // supports Scribe, but for some other reason it's not able to accept stylus
/// // input right now.
/// return;
/// }
/// } on PlatformException catch (exception) {
/// if (exception.message == 'Requires API level 34 or higher.') {
/// // The device's API level is too low to support Scribe.
/// return;
/// }
/// // Any other exception is unexpected and should not be caught here.
/// rethrow;
/// }
///
/// // Scribe is supported, so start it.
/// Scribe.startStylusHandwriting();
/// ```
/// {@end-tool}
///
/// See also:
///
/// * <https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#isStylusHandwritingAvailable()>,
/// which is the corresponding API on Android that this method attempts to
/// mirror.
static Future<bool> isStylusHandwritingAvailable() async {
final bool? result = await _channel.invokeMethod<bool?>(
'Scribe.isStylusHandwritingAvailable',
);
if (result == null) {
throw FlutterError('MethodChannel.invokeMethod unexpectedly returned null.');
}
return result;
}
/// Tell Android to begin receiving stylus handwriting input.
///
/// This is typically called after detecting a [PointerDownEvent] from a
/// [PointerDeviceKind.stylus] on an active text field, indicating the start
/// of stylus handwriting input. If there is no active [TextInputConnection],
/// the call will be ignored.
///
/// Call [isFeatureAvailable] each time before calling this to make sure that
/// stylus handwriting input is supported and available.
///
/// Supported on Android API 33 and above.
///
/// See also:
///
/// * <https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#startStylusHandwriting(android.view.View)>,
/// which is the corresponding API on Android that this method attempts to
/// mirror.
/// * [EditableText.stylusHandwritingEnabled], which controls whether
/// Flutter's built-in text fields support handwriting input.
static Future<void> startStylusHandwriting() {
return _channel.invokeMethod<void>(
'Scribe.startStylusHandwriting',
);
}
}

View File

@ -277,6 +277,35 @@ abstract final class SystemChannels {
JSONMethodCodec(),
);
/// A [MethodChannel] for handling Android Scribe stylus handwriting input.
///
/// Android's Scribe feature allows writing directly on top of a text input
/// using a stylus.
///
/// The following methods are defined for this channel:
///
/// * `Scribe.startStylusHandwriting`: Indicates that stylus input has been
/// detected and Android should start handwriting input.
/// * `Scribe.isStylusHandwritingAvailable`: Returns a boolean representing
/// whether or not the device currently supports accepting stylus handwriting
/// input. Throws if the device's API level is not sufficient.
/// * `Scribe.isFeatureAvailable`: Returns a boolean representing whether or
/// not the device currently supports accepting stylus handwriting input.
/// Returns false and does not throw if the device's API level is not
/// sufficient.
///
/// See also:
///
/// * [Scribe], which uese this channel.
/// * [ScribbleClient], which implements the iOS version of this feature,
/// [Scribble](https://support.apple.com/guide/ipad/enter-text-with-scribble-ipad355ab2a7/ipados).
/// * <https://developer.android.com/develop/ui/views/touch-and-input/stylus-input/stylus-input-in-text-fields>,
/// which is the Android documentation explaining the Scribe feature.
static const MethodChannel scribe = OptionalMethodChannel(
'flutter/scribe',
JSONMethodCodec(),
);
/// A [MethodChannel] for handling spell check for text input.
///
/// This channel exposes the spell check framework for supported platforms.

View File

@ -1066,7 +1066,12 @@ enum SelectionChangedCause {
/// of text.
drag,
/// The user used iPadOS 14+ Scribble to change the selection.
// TODO(justinmc): Rename this to stylusHandwriting.
// https://github.com/flutter/flutter/issues/159223
/// The user used stylus handwriting to change the selection.
///
/// Currently, this is only supported on iPadOS 14+ via the Scribble feature,
/// or on Android API 34+ via the Scribe feature.
scribble,
}
@ -1262,9 +1267,13 @@ mixin TextInputClient {
void performSelector(String selectorName) {}
}
/// An interface to receive focus from the engine.
/// An interface into iOS's stylus hadnwriting text input.
///
/// This is currently only used to handle UIIndirectScribbleInteraction.
/// See also:
///
/// * [Scribe], which provides similar functionality for Anroid.
/// * [UIIndirectScribbleInteraction](https://developer.apple.com/documentation/uikit/uiindirectscribbleinteraction),
/// which is iOS's API for Scribble.
abstract class ScribbleClient {
/// A unique identifier for this element.
String get elementIdentifier;

View File

@ -20,7 +20,7 @@ import 'dart:ui' as ui hide TextStyle;
import 'package:characters/characters.dart' show CharacterRange, StringCharacters;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
@ -880,7 +880,12 @@ class EditableText extends StatefulWidget {
this.clipBehavior = Clip.hardEdge,
this.restorationId,
this.scrollBehavior,
@Deprecated(
'Use `stylusHandwritingEnabled` instead. '
'This feature was deprecated after v3.27.0-0.2.pre.',
)
this.scribbleEnabled = true,
this.stylusHandwritingEnabled = defaultStylusHandwritingEnabled,
this.enableIMEPersonalizedLearning = true,
this.contentInsertionConfiguration,
this.contextMenuBuilder,
@ -1735,8 +1740,33 @@ class EditableText extends StatefulWidget {
///
/// Defaults to true.
/// {@endtemplate}
@Deprecated(
'Use `stylusHandwritingEnabled` instead. '
'This feature was deprecated after v3.27.0-0.2.pre.',
)
final bool scribbleEnabled;
/// {@template flutter.widgets.editableText.stylusHandwritingEnabled}
/// Whether this input supports stylus handwriting, where the user can write
/// directly on top of a field.
///
/// Currently only the following devices are supported:
///
/// * iPads running iOS 14 and above using an Apple Pencil.
/// * Android devices running API 34 and above and using an active stylus.
/// {@endtemplate}
///
/// On Android, Scribe gestures are detected outside of [EditableText],
/// typically by [TextSelectionGestureDetectorBuilder]. This is handled
/// automatically in [TextField].
///
/// See also:
///
/// * [ScribbleClient], which can be mixed into an arbirtrary widget to
/// provide iOS Scribble functionality.
/// * [Scribe], which can be used to interact with Android Scribe directly.
final bool stylusHandwritingEnabled;
/// {@template flutter.widgets.editableText.selectionEnabled}
/// Same as [enableInteractiveSelection].
///
@ -1975,6 +2005,9 @@ class EditableText extends StatefulWidget {
/// {@macro flutter.widgets.magnifier.intro}
final TextMagnifierConfiguration magnifierConfiguration;
/// The default value for [stylusHandwritingEnabled].
static const bool defaultStylusHandwritingEnabled = true;
bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText);
/// Returns the [ContextMenuButtonItem]s representing the buttons in this
@ -2242,6 +2275,7 @@ class EditableText extends StatefulWidget {
properties.add(DiagnosticsProperty<Iterable<String>>('autofillHints', autofillHints, defaultValue: null));
properties.add(DiagnosticsProperty<TextHeightBehavior>('textHeightBehavior', textHeightBehavior, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('stylusHandwritingEnabled', stylusHandwritingEnabled, defaultValue: defaultStylusHandwritingEnabled));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true));
properties.add(DiagnosticsProperty<UndoHistoryController>('undoController', undoController, defaultValue: null));
@ -2363,6 +2397,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
Orientation? _lastOrientation;
bool get _stylusHandwritingEnabled {
// During the deprecation period, respect scribbleEnabled being explicitly
// set.
if (!widget.scribbleEnabled) {
return widget.scribbleEnabled;
}
return widget.stylusHandwritingEnabled;
}
late final AppLifecycleListener _appLifecycleListener;
bool _justResumed = false;
@ -4464,7 +4507,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_ScribbleCacheKey? _scribbleCacheKey;
void _updateSelectionRects({bool force = false}) {
if (!widget.scribbleEnabled || defaultTargetPlatform != TargetPlatform.iOS) {
if (!_stylusHandwritingEnabled || defaultTargetPlatform != TargetPlatform.iOS) {
return;
}
@ -4755,7 +4798,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
void insertTextPlaceholder(Size size) {
if (!widget.scribbleEnabled) {
if (!_stylusHandwritingEnabled) {
return;
}
@ -4770,7 +4813,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
void removeTextPlaceholder() {
if (!widget.scribbleEnabled || _placeholderLocation == -1) {
if (!_stylusHandwritingEnabled || _placeholderLocation == -1) {
return;
}
@ -5284,9 +5327,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
onCut: _semanticsOnCut(controls),
onPaste: _semanticsOnPaste(controls),
child: _ScribbleFocusable(
focusNode: widget.focusNode,
editableKey: _editableKey,
enabled: widget.scribbleEnabled,
enabled: _stylusHandwritingEnabled,
focusNode: widget.focusNode,
updateSelectionRects: () {
_openInputConnection();
_updateSelectionRects(force: true);

View File

@ -1857,6 +1857,24 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S
}
}
/// Returns the bounding [Rect] of the text selection handle in local
/// coordinates.
///
/// When interacting with a text seletion handle through a touch event, the
/// interactive area should be at least [kMinInteractiveDimension] square,
/// which this method does not consider.
Rect _getHandleRect(TextSelectionHandleType type, double preferredLineHeight) {
final Size handleSize = widget.selectionControls.getHandleSize(
preferredLineHeight,
);
return Rect.fromLTWH(
0.0,
0.0,
handleSize.width,
handleSize.height,
);
}
@override
void didUpdateWidget(_SelectionHandleOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
@ -1874,20 +1892,10 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S
@override
Widget build(BuildContext context) {
final Offset handleAnchor = widget.selectionControls.getHandleAnchor(
final Rect handleRect = _getHandleRect(
widget.type,
widget.preferredLineHeight,
);
final Size handleSize = widget.selectionControls.getHandleSize(
widget.preferredLineHeight,
);
final Rect handleRect = Rect.fromLTWH(
-handleAnchor.dx,
-handleAnchor.dy,
handleSize.width,
handleSize.height,
);
// Make sure the GestureDetector is big enough to be easily interactive.
final Rect interactiveRect = handleRect.expandToInclude(
@ -1900,6 +1908,11 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S
math.max((interactiveRect.height - handleRect.height) / 2, 0),
);
final Offset handleAnchor = widget.selectionControls.getHandleAnchor(
widget.type,
widget.preferredLineHeight,
);
// Make sure a drag is eagerly accepted. This is used on iOS to match the
// behavior where a drag directly on a collapse handle will always win against
// other drag gestures.
@ -1907,7 +1920,8 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S
return CompositedTransformFollower(
link: widget.handleLayerLink,
offset: interactiveRect.topLeft,
// Put the handle's anchor point on the leader's anchor point.
offset: -handleAnchor - Offset(padding.left, padding.top),
showWhenUnlinked: false,
child: FadeTransition(
opacity: _opacity,
@ -2246,6 +2260,7 @@ class TextSelectionGestureDetectorBuilder {
if (!delegate.selectionEnabled) {
return;
}
// TODO(Renzo-Olivares): Migrate text selection gestures away from saving state
// in renderEditable. The gesture callbacks can use the details objects directly
// in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap]
@ -2270,6 +2285,22 @@ class TextSelectionGestureDetectorBuilder {
final bool isShiftPressedValid = _isShiftPressed && renderEditable.selection?.baseOffset != null;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
if (editableText.widget.stylusHandwritingEnabled) {
final bool stylusEnabled = switch (kind) {
PointerDeviceKind.stylus
|| PointerDeviceKind.invertedStylus =>
editableText.widget.stylusHandwritingEnabled,
_ => false,
};
if (stylusEnabled) {
Scribe.isFeatureAvailable().then((bool isAvailable) {
if (isAvailable) {
renderEditable.selectPosition(cause: SelectionChangedCause.scribble);
Scribe.startStylusHandwriting();
}
});
}
}
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
// On mobile platforms the selection is set on tap up.

View File

@ -0,0 +1,84 @@
// 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/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
test('when receiving an unsupported message', () async {
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
'method': 'Scribe.unsupportedMessage',
});
final ByteData? response = await binding.defaultBinaryMessenger.handlePlatformMessage(
'flutter/scribe',
messageBytes,
null,
);
// When a MissingPluginException is thrown, it is caught and a null response
// is returned.
expect(response, isNull);
}, skip: kIsWeb); // [intended]
for (final bool? returnValue in <bool?>[false, true, null]) {
test('Scribe.isStylusHandwritingAvailable calls through to platform channel', () async {
final List<MethodCall> calls = <MethodCall>[];
binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.scribe, (MethodCall methodCall) {
calls.add(methodCall);
return Future<bool?>.value(returnValue);
});
if (returnValue == null) {
expect(() async {
await Scribe.isStylusHandwritingAvailable();
}, throwsA(isA<FlutterError>()));
} else {
expect(await Scribe.isStylusHandwritingAvailable(), returnValue);
}
expect(calls, hasLength(1));
expect(calls.first.method, 'Scribe.isStylusHandwritingAvailable');
}, skip: kIsWeb); // [intended]
}
for (final bool? returnValue in <bool?>[false, true, null]) {
test('Scribe.isFeatureAvailable calls through to platform channel', () async {
final List<MethodCall> calls = <MethodCall>[];
binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.scribe, (MethodCall methodCall) {
calls.add(methodCall);
return Future<bool?>.value(returnValue);
});
if (returnValue == null) {
expect(() async {
await Scribe.isFeatureAvailable();
}, throwsA(isA<FlutterError>()));
} else {
expect(await Scribe.isFeatureAvailable(), returnValue);
}
expect(calls, hasLength(1));
expect(calls.first.method, 'Scribe.isFeatureAvailable');
}, skip: kIsWeb); // [intended]
}
test('Scribe.startStylusHandwriting calls through to platform channel', () async {
final List<MethodCall> calls = <MethodCall>[];
binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.scribe, (MethodCall methodCall) {
calls.add(methodCall);
return Future<void>.value();
});
Scribe.startStylusHandwriting();
expect(calls, hasLength(1));
expect(calls.first.method, 'Scribe.startStylusHandwriting');
}, skip: kIsWeb); // [intended]
}

View File

@ -0,0 +1,686 @@
// 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/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'editable_text_utils.dart';
void main() {
const TextStyle textStyle = TextStyle();
const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
late TextEditingController controller;
late FocusNode focusNode;
setUp(() async {
controller = TextEditingController();
focusNode = FocusNode(debugLabel: 'EditableText Node');
});
tearDown(() {
controller.dispose();
focusNode.dispose();
});
testWidgets('selection rects re-sent when refocused', (WidgetTester tester) async {
final List<List<SelectionRect>> log = <List<SelectionRect>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async {
if (methodCall.method == 'TextInput.setSelectionRects') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
final List<SelectionRect> selectionRects = <SelectionRect>[];
for (final dynamic rect in args) {
selectionRects.add(SelectionRect(
position: (rect as List<dynamic>)[4] as int,
bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double),
));
}
log.add(selectionRects);
}
return null;
});
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
controller.text = 'Text1';
Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: width,
height: height,
child: EditableText(
controller: controller,
textAlign: textAlign,
scrollController: scrollController,
maxLines: null,
focusNode: focusNode,
cursorWidth: 0,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
),
),
),
),
);
}
const List<SelectionRect> expectedRects = <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0))
];
await pumpEditableText();
expect(log, isEmpty);
await tester.showKeyboard(find.byType(EditableText));
// First update.
expect(log.single, expectedRects);
log.clear();
await tester.pumpAndSettle();
expect(log, isEmpty);
focusNode.unfocus();
await tester.pumpAndSettle();
expect(log, isEmpty);
focusNode.requestFocus();
//await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
// Should re-receive the same rects.
expect(log.single, expectedRects);
log.clear();
// 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>{ TargetPlatform.iOS })); // [intended]
testWidgets('Selection changes during Scribble interaction should have the scribble cause', (WidgetTester tester) async {
controller.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.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);
await tester.testTextInput.finishScribbleInteraction();
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
testWidgets('Requests focus and changes the selection when onScribbleFocus is called', (WidgetTester tester) async {
controller.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>{ TargetPlatform.iOS })); // [intended]
testWidgets('Declares itself for Scribble interaction if the bounds overlap the scribble rect and the widget is touchable', (WidgetTester tester) async {
controller.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<dynamic> elementEntry = <dynamic>[TextInput.scribbleClients.keys.first, 0.0, 0.0, 800.0, 600.0];
List<List<dynamic>> 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: <Widget>[
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,
stylusHandwritingEnabled: 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>{ TargetPlatform.iOS })); // [intended]
testWidgets('single line Scribble fields can show a horizontal placeholder', (WidgetTester tester) async {
controller.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,
stylusHandwritingEnabled: 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>{ TargetPlatform.iOS })); // [intended]
testWidgets('multiline Scribble fields can show a vertical placeholder', (WidgetTester tester) async {
controller.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,
stylusHandwritingEnabled: 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>{ TargetPlatform.iOS })); // [intended]
testWidgets('selection rects are sent when they change', (WidgetTester tester) async {
addTearDown(tester.view.reset);
// Ensure selection rects are sent on iPhone (using SE 3rd gen size)
tester.view.physicalSize = const Size(750.0, 1334.0);
final List<List<SelectionRect>> log = <List<SelectionRect>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) {
if (methodCall.method == 'TextInput.setSelectionRects') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
final List<SelectionRect> selectionRects = <SelectionRect>[];
for (final dynamic rect in args) {
selectionRects.add(SelectionRect(
position: (rect as List<dynamic>)[4] as int,
bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double),
));
}
log.add(selectionRects);
}
return null;
});
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
controller.text = 'Text1';
Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: width,
height: height,
child: EditableText(
controller: controller,
textAlign: textAlign,
scrollController: scrollController,
maxLines: null,
focusNode: focusNode,
cursorWidth: 0,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
),
),
),
),
);
}
await pumpEditableText();
expect(log, isEmpty);
await tester.showKeyboard(find.byType(EditableText));
// First update.
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0))
]);
log.clear();
await tester.pumpAndSettle();
expect(log, isEmpty);
await pumpEditableText();
expect(log, isEmpty);
// Change the width such that each character occupies a line.
await pumpEditableText(width: 20);
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0))
]);
log.clear();
await tester.enterText(find.byType(EditableText), 'Text1👨👩👦');
await tester.pump();
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0)),
SelectionRect(position: 5, bounds: Rect.fromLTRB(0.0, 70.0, 42.0, 84.0)),
]);
log.clear();
// The 4th line will be partially visible.
await pumpEditableText(width: 20, height: 45);
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
]);
log.clear();
await pumpEditableText(width: 20, height: 45, textAlign: TextAlign.right);
// This is 1px off from being completely right-aligned. The 1px width is
// reserved for caret.
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)),
// These 2 lines will be out of bounds.
// SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 56.0, 19.0, 70.0)),
// SelectionRect(position: 5, bounds: Rect.fromLTRB(-23.0, 70.0, 19.0, 84.0)),
]);
log.clear();
expect(scrollController.offset, 0);
// Scrolling also triggers update.
scrollController.jumpTo(14);
await tester.pumpAndSettle();
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, -14.0, 19.0, 0.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)),
// This line is skipped because it's below the bottom edge of the render
// object.
// SelectionRect(position: 5, bounds: Rect.fromLTRB(5.0, 56.0, 47.0, 70.0)),
]);
log.clear();
// 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>{ TargetPlatform.iOS })); // [intended]
testWidgets('selection rects are not sent if stylusHandwritingEnabled is false', (WidgetTester tester) async {
final List<MethodCall> log = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async {
log.add(methodCall);
return null;
});
controller.text = 'Text1';
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
EditableText(
key: ValueKey<String>(controller.text),
controller: controller,
focusNode: focusNode,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
stylusHandwritingEnabled: false,
),
],
),
),
),
);
await tester.showKeyboard(find.byKey(ValueKey<String>(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>{ TargetPlatform.iOS })); // [intended]
testWidgets('selection rects sent even when character corners are outside of paintBounds', (WidgetTester tester) async {
final List<List<SelectionRect>> log = <List<SelectionRect>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) {
if (methodCall.method == 'TextInput.setSelectionRects') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
final List<SelectionRect> selectionRects = <SelectionRect>[];
for (final dynamic rect in args) {
selectionRects.add(SelectionRect(
position: (rect as List<dynamic>)[4] as int,
bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double),
));
}
log.add(selectionRects);
}
return null;
});
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
controller.text = 'Text1';
final GlobalKey<EditableTextState> editableTextKey = GlobalKey();
Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: width,
height: height,
child: EditableText(
controller: controller,
textAlign: textAlign,
scrollController: scrollController,
maxLines: null,
focusNode: focusNode,
cursorWidth: 0,
key: editableTextKey,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
),
),
),
),
);
}
// Set height to 1 pixel less than full height.
await pumpEditableText(height: 13);
expect(log, isEmpty);
// Scroll so that the top of each character is above the top of the renderEditable
// and the bottom of each character is below the bottom of the renderEditable.
final ViewportOffset offset = ViewportOffset.fixed(0.5);
addTearDown(offset.dispose);
editableTextKey.currentState!.renderEditable.offset = offset;
await tester.showKeyboard(find.byType(EditableText));
// We should get all the rects.
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, -0.5, 14.0, 13.5)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, -0.5, 28.0, 13.5)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, -0.5, 42.0, 13.5)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, -0.5, 56.0, 13.5)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, -0.5, 70.0, 13.5))
]);
log.clear();
// 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>{ TargetPlatform.iOS })); // [intended]
}

View File

@ -0,0 +1,229 @@
// 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' show PointerDeviceKind;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
const TextStyle textStyle = TextStyle();
const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
final List<MethodCall> calls = <MethodCall>[];
bool isFeatureAvailableReturnValue = true;
late TextEditingController controller;
late FocusNode focusNode;
setUp(() async {
calls.clear();
isFeatureAvailableReturnValue = true;
binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.scribe, (MethodCall methodCall) {
calls.add(methodCall);
return switch (methodCall.method) {
'Scribe.isFeatureAvailable' => Future<bool>.value(isFeatureAvailableReturnValue),
'Scribe.startStylusHandwriting' => Future<void>.value(),
_=> throw FlutterError('Unexpected method call: ${methodCall.method}'),
};
});
controller = TextEditingController(
text: 'Lorem ipsum dolor sit amet',
);
focusNode = FocusNode(debugLabel: 'EditableText Node');
});
tearDown(() {
controller.dispose();
focusNode.dispose();
});
Future<void> pumpTextSelectionGestureDetectorBuilder(
WidgetTester tester, {
bool forcePressEnabled = true,
bool selectionEnabled = true,
}) async {
final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>();
final FakeTextSelectionGestureDetectorBuilderDelegate delegate = FakeTextSelectionGestureDetectorBuilderDelegate(
editableTextKey: editableTextKey,
forcePressEnabled: forcePressEnabled,
selectionEnabled: selectionEnabled,
);
final TextSelectionGestureDetectorBuilder provider =
TextSelectionGestureDetectorBuilder(delegate: delegate);
await tester.pumpWidget(
MaterialApp(
home: provider.buildGestureDetector(
behavior: HitTestBehavior.translucent,
child: EditableText(
key: editableTextKey,
controller: controller,
backgroundCursorColor: Colors.grey,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
showSelectionHandles: true,
),
),
),
);
}
testWidgets('when Scribe is available, starts handwriting on tap down', (WidgetTester tester) async {
isFeatureAvailableReturnValue = true;
await pumpTextSelectionGestureDetectorBuilder(tester);
expect(focusNode.hasFocus, isFalse);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus, pointer: 1);
await gesture.down(tester.getCenter(find.byType(EditableText)));
// Wait for the gesture arena.
await tester.pumpAndSettle();
expect(calls, hasLength(2));
expect(calls.first.method, 'Scribe.isFeatureAvailable');
expect(calls[1].method, 'Scribe.startStylusHandwriting');
await gesture.up();
expect(focusNode.hasFocus, isTrue);
// On web, let the browser handle handwriting input.
}, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android })); // [intended]
testWidgets('when Scribe is unavailable, does not start handwriting on tap down', (WidgetTester tester) async {
isFeatureAvailableReturnValue = false;
await pumpTextSelectionGestureDetectorBuilder(tester);
expect(focusNode.hasFocus, isFalse);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus, pointer: 1);
await gesture.down(tester.getCenter(find.byType(EditableText)));
// Wait for the gesture arena.
await tester.pumpAndSettle();
expect(calls, hasLength(1));
expect(calls.first.method, 'Scribe.isFeatureAvailable');
await gesture.up();
}, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android })); // [intended]
testWidgets('tap down event must be from a stylus in order to start handwriting', (WidgetTester tester) async {
isFeatureAvailableReturnValue = true;
await pumpTextSelectionGestureDetectorBuilder(tester);
expect(focusNode.hasFocus, isFalse);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.down(tester.getCenter(find.byType(EditableText)));
expect(calls, isEmpty);
await gesture.up();
}, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android })); // [intended]
testWidgets('tap down event on a collapsed selection handle is handled by the handle and does not start handwriting', (WidgetTester tester) async {
isFeatureAvailableReturnValue = true;
await pumpTextSelectionGestureDetectorBuilder(tester);
expect(focusNode.hasFocus, isFalse);
expect(find.byType(CompositedTransformFollower), findsNothing);
// Tap to show the collapsed selection handle.
final Offset fieldOffset = tester.getTopLeft(find.byType(EditableText));
await tester.tapAt(fieldOffset + const Offset(20.0, 10.0));
await tester.pump();
expect(find.byType(CompositedTransformFollower), findsOneWidget);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus, pointer: 1);
final Finder handleFinder = find.descendant(
of: find.byType(CompositedTransformFollower),
matching: find.byType(CustomPaint),
);
await gesture.down(tester.getCenter(handleFinder));
// Wait for the gesture arena.
await tester.pumpAndSettle();
expect(calls, hasLength(0));
expect(controller.selection.isCollapsed, isTrue);
final int cursorStart = controller.selection.start;
// Dragging on top of the handle moves it like normal.
await gesture.moveBy(const Offset(20.0, 0.0));
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.start, greaterThan(cursorStart));
expect(calls, hasLength(0));
await gesture.up();
}, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android })); // [intended]
testWidgets('tap down event on the end selection handle is handled by the handle and does not start handwriting', (WidgetTester tester) async {
isFeatureAvailableReturnValue = true;
await pumpTextSelectionGestureDetectorBuilder(tester);
expect(focusNode.hasFocus, isFalse);
expect(find.byType(CompositedTransformFollower), findsNothing);
// Long press to select the first word and show both handles.
final Offset fieldOffset = tester.getTopLeft(find.byType(EditableText));
await tester.longPressAt(fieldOffset + const Offset(20.0, 10.0));
await tester.pump();
expect(find.byType(CompositedTransformFollower), findsNWidgets(2));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus, pointer: 1);
final Finder endHandleFinder = find.descendant(
of: find.byType(CompositedTransformFollower).at(1),
matching: find.byType(CustomPaint),
);
await gesture.down(tester.getCenter(endHandleFinder));
// Wait for the gesture arena.
await tester.pumpAndSettle();
expect(calls, isEmpty);
expect(controller.selection.isCollapsed, isFalse);
final TextSelection selectionStart = controller.selection;
// Dragging on top of the handle extends selection like normal.
await gesture.moveBy(const Offset(20.0, 0.0));
expect(controller.selection.isCollapsed, isFalse);
expect(controller.selection.start, equals(selectionStart.start));
expect(controller.selection.end, greaterThan(selectionStart.end));
expect(calls, isEmpty);
await gesture.up();
}, skip: kIsWeb, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android })); // [intended]
}
class FakeTextSelectionGestureDetectorBuilderDelegate implements TextSelectionGestureDetectorBuilderDelegate {
FakeTextSelectionGestureDetectorBuilderDelegate({
required this.editableTextKey,
required this.forcePressEnabled,
required this.selectionEnabled,
});
@override
final GlobalKey<EditableTextState> editableTextKey;
@override
final bool forcePressEnabled;
@override
final bool selectionEnabled;
}

View File

@ -1094,88 +1094,6 @@ void main() {
expect(focusNode.hasFocus, isFalse);
});
testWidgets('selection rects re-sent when refocused', (WidgetTester tester) async {
final List<List<SelectionRect>> log = <List<SelectionRect>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async {
if (methodCall.method == 'TextInput.setSelectionRects') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
final List<SelectionRect> selectionRects = <SelectionRect>[];
for (final dynamic rect in args) {
selectionRects.add(SelectionRect(
position: (rect as List<dynamic>)[4] as int,
bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double),
));
}
log.add(selectionRects);
}
return null;
});
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
controller.text = 'Text1';
Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: width,
height: height,
child: EditableText(
controller: controller,
textAlign: textAlign,
scrollController: scrollController,
maxLines: null,
focusNode: focusNode,
cursorWidth: 0,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
),
),
),
),
);
}
const List<SelectionRect> expectedRects = <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0))
];
await pumpEditableText();
expect(log, isEmpty);
await tester.showKeyboard(find.byType(EditableText));
// First update.
expect(log.single, expectedRects);
log.clear();
await tester.pumpAndSettle();
expect(log, isEmpty);
focusNode.unfocus();
await tester.pumpAndSettle();
expect(log, isEmpty);
focusNode.requestFocus();
//await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
// Should re-receive the same rects.
expect(log.single, expectedRects);
log.clear();
// 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>{ TargetPlatform.iOS })); // [intended]
testWidgets('EditableText does not derive selection color from DefaultSelectionStyle', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/103341.
const TextEditingValue value = TextEditingValue(
@ -3027,327 +2945,6 @@ void main() {
}
});
testWidgets('Selection changes during Scribble interaction should have the scribble cause', (WidgetTester tester) async {
controller.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.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);
await tester.testTextInput.finishScribbleInteraction();
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
testWidgets('Requests focus and changes the selection when onScribbleFocus is called', (WidgetTester tester) async {
controller.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>{ TargetPlatform.iOS })); // [intended]
testWidgets('Declares itself for Scribble interaction if the bounds overlap the scribble rect and the widget is touchable', (WidgetTester tester) async {
controller.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<dynamic> elementEntry = <dynamic>[TextInput.scribbleClients.keys.first, 0.0, 0.0, 800.0, 600.0];
List<List<dynamic>> 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: <Widget>[
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>{ TargetPlatform.iOS })); // [intended]
testWidgets('single line Scribble fields can show a horizontal placeholder', (WidgetTester tester) async {
controller.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>{ TargetPlatform.iOS })); // [intended]
testWidgets('multiline Scribble fields can show a vertical placeholder', (WidgetTester tester) async {
controller.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>{ TargetPlatform.iOS })); // [intended]
testWidgets('Sends "updateConfig" when read-only flag is flipped', (WidgetTester tester) async {
bool readOnly = true;
late StateSetter setState;
@ -5593,261 +5190,6 @@ void main() {
);
});
testWidgets('selection rects are sent when they change', (WidgetTester tester) async {
addTearDown(tester.view.reset);
// Ensure selection rects are sent on iPhone (using SE 3rd gen size)
tester.view.physicalSize = const Size(750.0, 1334.0);
final List<List<SelectionRect>> log = <List<SelectionRect>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) {
if (methodCall.method == 'TextInput.setSelectionRects') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
final List<SelectionRect> selectionRects = <SelectionRect>[];
for (final dynamic rect in args) {
selectionRects.add(SelectionRect(
position: (rect as List<dynamic>)[4] as int,
bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double),
));
}
log.add(selectionRects);
}
return null;
});
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
controller.text = 'Text1';
Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: width,
height: height,
child: EditableText(
controller: controller,
textAlign: textAlign,
scrollController: scrollController,
maxLines: null,
focusNode: focusNode,
cursorWidth: 0,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
),
),
),
),
);
}
await pumpEditableText();
expect(log, isEmpty);
await tester.showKeyboard(find.byType(EditableText));
// First update.
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0))
]);
log.clear();
await tester.pumpAndSettle();
expect(log, isEmpty);
await pumpEditableText();
expect(log, isEmpty);
// Change the width such that each character occupies a line.
await pumpEditableText(width: 20);
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0))
]);
log.clear();
await tester.enterText(find.byType(EditableText), 'Text1👨👩👦');
await tester.pump();
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0)),
SelectionRect(position: 5, bounds: Rect.fromLTRB(0.0, 70.0, 42.0, 84.0)),
]);
log.clear();
// The 4th line will be partially visible.
await pumpEditableText(width: 20, height: 45);
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)),
]);
log.clear();
await pumpEditableText(width: 20, height: 45, textAlign: TextAlign.right);
// This is 1px off from being completely right-aligned. The 1px width is
// reserved for caret.
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)),
// These 2 lines will be out of bounds.
// SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 56.0, 19.0, 70.0)),
// SelectionRect(position: 5, bounds: Rect.fromLTRB(-23.0, 70.0, 19.0, 84.0)),
]);
log.clear();
expect(scrollController.offset, 0);
// Scrolling also triggers update.
scrollController.jumpTo(14);
await tester.pumpAndSettle();
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, -14.0, 19.0, 0.0)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)),
// This line is skipped because it's below the bottom edge of the render
// object.
// SelectionRect(position: 5, bounds: Rect.fromLTRB(5.0, 56.0, 47.0, 70.0)),
]);
log.clear();
// 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>{ TargetPlatform.iOS })); // [intended]
testWidgets('selection rects are not sent if scribbleEnabled is false', (WidgetTester tester) async {
final List<MethodCall> log = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async {
log.add(methodCall);
return null;
});
controller.text = 'Text1';
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
EditableText(
key: ValueKey<String>(controller.text),
controller: controller,
focusNode: focusNode,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
scribbleEnabled: false,
),
],
),
),
),
);
await tester.showKeyboard(find.byKey(ValueKey<String>(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>{ TargetPlatform.iOS })); // [intended]
testWidgets('selection rects sent even when character corners are outside of paintBounds', (WidgetTester tester) async {
final List<List<SelectionRect>> log = <List<SelectionRect>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) {
if (methodCall.method == 'TextInput.setSelectionRects') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
final List<SelectionRect> selectionRects = <SelectionRect>[];
for (final dynamic rect in args) {
selectionRects.add(SelectionRect(
position: (rect as List<dynamic>)[4] as int,
bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double),
));
}
log.add(selectionRects);
}
return null;
});
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
controller.text = 'Text1';
final GlobalKey<EditableTextState> editableTextKey = GlobalKey();
Future<void> pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: width,
height: height,
child: EditableText(
controller: controller,
textAlign: textAlign,
scrollController: scrollController,
maxLines: null,
focusNode: focusNode,
cursorWidth: 0,
key: editableTextKey,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
),
),
),
),
);
}
// Set height to 1 pixel less than full height.
await pumpEditableText(height: 13);
expect(log, isEmpty);
// Scroll so that the top of each character is above the top of the renderEditable
// and the bottom of each character is below the bottom of the renderEditable.
final ViewportOffset offset = ViewportOffset.fixed(0.5);
addTearDown(offset.dispose);
editableTextKey.currentState!.renderEditable.offset = offset;
await tester.showKeyboard(find.byType(EditableText));
// We should get all the rects.
expect(log.single, const <SelectionRect>[
SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, -0.5, 14.0, 13.5)),
SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, -0.5, 28.0, 13.5)),
SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, -0.5, 42.0, 13.5)),
SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, -0.5, 56.0, 13.5)),
SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, -0.5, 70.0, 13.5))
]);
log.clear();
// 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>{ TargetPlatform.iOS })); // [intended]
testWidgets('text styling info is sent on show keyboard', (WidgetTester tester) async {
final List<MethodCall> log = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async {