Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Support Scribble Handwriting #75472

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jan 11, 2022
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,4 @@ Pradumna Saraf <[email protected]>
Kai Yu <[email protected]>
Denis Grafov <[email protected]>
TheOneWithTheBraid <[email protected]>
Twin Sun, LLC <[email protected]>
10 changes: 10 additions & 0 deletions packages/flutter/lib/src/cupertino/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ class CupertinoTextField extends StatefulWidget {
this.autofillHints = const <String>[],
this.clipBehavior = Clip.hardEdge,
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
}) : assert(textAlign != null),
assert(readOnly != null),
Expand Down Expand Up @@ -454,6 +455,7 @@ class CupertinoTextField extends StatefulWidget {
this.autofillHints = const <String>[],
this.clipBehavior = Clip.hardEdge,
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
}) : assert(textAlign != null),
assert(readOnly != null),
Expand Down Expand Up @@ -798,6 +800,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;

Expand Down Expand Up @@ -843,6 +848,7 @@ class CupertinoTextField extends StatefulWidget {
properties.add(DiagnosticsProperty<TextAlignVertical>('textAlignVertical', textAlignVertical, defaultValue: null));
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>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
}
}
Expand Down Expand Up @@ -963,6 +969,9 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
if (cause == SelectionChangedCause.keyboard)
return false;

if (cause == SelectionChangedCause.scribble)
return true;

if (_effectiveController.text.isNotEmpty)
return true;

Expand Down Expand Up @@ -1292,6 +1301,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
autofillClient: this,
clipBehavior: widget.clipBehavior,
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
),
),
Expand Down
8 changes: 7 additions & 1 deletion packages/flutter/lib/src/material/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ class TextField extends StatefulWidget {
this.autofillHints = const <String>[],
this.clipBehavior = Clip.hardEdge,
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
}) : assert(textAlign != null),
assert(readOnly != null),
Expand Down Expand Up @@ -768,6 +769,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;

Expand Down Expand Up @@ -812,6 +816,7 @@ class TextField extends StatefulWidget {
properties.add(DiagnosticsProperty<ScrollController>('scrollController', scrollController, defaultValue: null));
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>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
}
}
Expand Down Expand Up @@ -1029,7 +1034,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
if (!_isEnabled)
return false;

if (cause == SelectionChangedCause.longPress)
if (cause == SelectionChangedCause.longPress || cause == SelectionChangedCause.scribble)
return true;

if (_effectiveController.text.isNotEmpty)
Expand Down Expand Up @@ -1273,6 +1278,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
autocorrectionTextRectColor: autocorrectionTextRectColor,
clipBehavior: widget.clipBehavior,
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
),
),
Expand Down
10 changes: 10 additions & 0 deletions packages/flutter/lib/src/rendering/editable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
// [assembleSemanticsNode] invocations.
Queue<SemanticsNode>? _cachedChildNodes;

/// Returns a list of rects that bound the given selection.
///
/// See [TextPainter.getBoxesForSelection] for more details.
List<Rect> getBoxesForSelection(TextSelection selection) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be any cleaner if this method were added to TextLayoutMetrics?

The goal of that class is to simplify things by providing a read-only interface instead of the full RenderEditable. You probably have to use RenderEditable anyway though, so maybe it's not worth it here. Just wanted to bring it up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah...yeah it looks like RenderEditable is the only implementer of TextLayoutMetrics, so I suppose that would make some sense. I don't know how useful this is for things other than Scribble interactions, though. The implementation would probably need to stay here since it needs the TextPainter, though.

_computeTextMetricsIfNeeded();
return _textPainter.getBoxesForSelection(selection)
.map((TextBox textBox) => textBox.toRect().shift(_paintOffset))
.toList();
Comment on lines +1274 to +1275
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Not sure if this is in the styleguide or not, so I could be wrong, but I thought we only indent by four characters here and don't align the dots.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can change it if you want, the closest thing I could find in the style guide was https://github.com/heartcombo/devise/blob/c82e4cf47b02002b2fd7ca31d441cf1043fc634c/lib/devise/models/recoverable.rb#L145, but it doesn't address this specific case

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine if the styleguide doesn't mention it and the analyzer doesn't care.

}

@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
Expand Down
158 changes: 157 additions & 1 deletion packages/flutter/lib/src/services/text_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know there is no implementation here, but should this specify inclusively or exclusively?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like it's exclusive in the default implementation that I provided using Rect.overlaps, but I'm not actually sure how PencilKit decides which input to choose other than that it's based on the bounds. I can add a note about that if it makes sense to do so, though.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be private?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's used in EditableText (https://github.com/flutter/flutter/pull/75472/files/7e28e92a313677a5aa17c8878c0b50eae49789d9#diff-f5f93c879cef9a102adbf148583de9f3d9a05f9678fdcad7bde106f62200bb34R2694 ) so I think it needs to be public. It would also be useful to be public if people are making custom text widgets that they want to be used with Scribble.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I missed that, sounds good.

/// 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].
Expand Down Expand Up @@ -1154,6 +1227,7 @@ class TextInputConnection {
Matrix4? _cachedTransform;
Rect? _cachedRect;
Rect? _cachedCaretRect;
List<SelectionRect> _cachedSelectionRects = <SelectionRect>[];

static int _nextId = 1;
final int _id;
Expand All @@ -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);
Expand Down Expand Up @@ -1274,6 +1354,19 @@ class TextInputConnection {
);
}

/// Send the bounding boxes of the current selected glyphs in the client to
/// the platform's text input plugin.
///
/// These are used by the engine during a UIDirectScribbleInteraction.
void setSelectionRects(List<SelectionRect> selectionRects) {
if (!listEquals(_cachedSelectionRects, selectionRects)) {
_cachedSelectionRects = selectionRects;
TextInput._instance._setSelectionRects(selectionRects.map((SelectionRect rect) {
return <num>[rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could num be double to be more specific?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably make it work, but rect.position is an int, while the bounds properties are double, so I think num makes the most sense

}).toList());
}
}

/// Send text styling information.
///
/// This information is used by the Flutter Web Engine to change the style
Expand Down Expand Up @@ -1535,10 +1628,43 @@ class TextInput {
TextInputConnection? _currentConnection;
late TextInputConfiguration _currentConfiguration;

final Map<String, ScribbleClient> _scribbleClients = <String, ScribbleClient>{};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a bit strange to store the client list here. Would it be possible to move this to a widget?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a static member of EditableTextState make sense? Or maybe _ScribbleElement should become public and this and the register/unregister static methods would all live there?

bool _scribbleInProgress = false;

/// Used for testing within the Flutter SDK to get the currently registered [ScribbleClient] list.
@visibleForTesting
static Map<String, ScribbleClient> get scribbleClients => TextInput._instance._scribbleClients;

/// Returns true if a scribble interaction is currently happening.
bool get scribbleInProgress => _scribbleInProgress;

Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
final String method = methodCall.method;
if (method == 'TextInputClient.focusElement') {
final List<dynamic> args = methodCall.arguments as List<dynamic>;
_scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble()));
return;
} else if (method == 'TextInputClient.requestElementsInRect') {
final List<double> args = (methodCall.arguments as List<dynamic>).cast<num>().map<double>((num value) => value.toDouble()).toList();
return _scribbleClients.keys.where((String elementIdentifier) {
final Rect rect = Rect.fromLTWH(args[0], args[1], args[2], args[3]);
if (!(_scribbleClients[elementIdentifier]?.isInScribbleRect(rect) ?? false))
return false;
final Rect bounds = _scribbleClients[elementIdentifier]?.bounds ?? Rect.zero;
return !(bounds == Rect.zero || bounds.hasNaN || bounds.isInfinite);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}).map((String elementIdentifier) {
final Rect bounds = _scribbleClients[elementIdentifier]!.bounds;
return <dynamic>[elementIdentifier, ...<dynamic>[bounds.left, bounds.top, bounds.width, bounds.height]];
}).toList();
} else if (method == 'TextInputClient.scribbleInteractionBegan') {
_scribbleInProgress = true;
return;
} else if (method == 'TextInputClient.scribbleInteractionFinished') {
_scribbleInProgress = false;
return;
}
if (_currentConnection == null)
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.
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -1703,6 +1838,13 @@ class TextInput {
);
}

void _setSelectionRects(List<List<num>> args) {
_channel.invokeMethod<void>(
'TextInput.setSelectionRects',
args,
);
}

void _setStyle(Map<String, dynamic> args) {
_channel.invokeMethod<void>(
'TextInput.setStyle',
Expand Down Expand Up @@ -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);
}
}
Loading