-
Notifications
You must be signed in to change notification settings - Fork 28.6k
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
Support Scribble Handwriting #75472
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for the PR and great work! Left some initial feedback and questions.
An additional question regarding the UIIndirectScribbleInteractionDelegate
protocol:
when does UIKit call func indirectScribbleInteraction(UIInteraction, frameForElement: Self.ElementIdentifier) -> CGRect
? Is it always immediately after one of the async calls (func indirectScribbleInteraction(UIInteraction, requestElementsIn: CGRect, completion: ([Self.ElementIdentifier]) -> Void)
or func indirectScribbleInteraction(UIInteraction, focusElementIfNeeded: Self.ElementIdentifier, referencePoint: CGPoint, completion: ((UIResponder & UITextInput)?) -> Void)
)? I'd like to understand how much information we would have to send to the engine every frame.
@@ -1049,6 +1049,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { | |||
/// The text to display. | |||
TextSpan? get text => _textPainter.text as TextSpan?; | |||
final TextPainter _textPainter; | |||
/// The text painter currently used to paint text. | |||
/// | |||
/// Currently used to find the selection rects for Scribble support |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably don't want to expose the underlying TextPainter
but feel free to expose the methods you need on RenderEditable
(see RenderEditable.getBoxesForSelection
).
|
||
/// Called during a UIIndirectScribbleInteraction when the [ScribbleClient] should | ||
/// receive focus | ||
void onScribbleFocus(double x, double y); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should probably use Offset
instead.
void onScribbleFocus(double x, double y); | ||
|
||
/// Tests whether the [ScribbleClient] overlaps the given rectangle bounds | ||
bool inScribbleRect(double x, double y, double width, double height); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe: isInScribbleRect(Rect scribbleRect)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Additionally you can have text fields that are offscreen (e.g. currently obstructed by other routes), so this is probably not enough to tell which client wants to be focused.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree! Do you have any thoughts on the best way to figure that out? Between that and other things that might obscure the fields from being interactive, I think that is the biggest remaining issue.
I think the rest of your feedback all makes sense at first glance, thank you for taking the time to review this!
bool inScribbleRect(double x, double y, double width, double height); | ||
|
||
/// The current bounds of the [ScribbleClient] | ||
List<double> get bounds; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should use more specific types when possible (i.e. Rect
over List<double>
).
@@ -1236,10 +1281,24 @@ class TextInput { | |||
TextInputConnection? _currentConnection; | |||
late TextInputConfiguration _currentConfiguration; | |||
|
|||
final Map<String, ScribbleClient> _scribbleClients = <String, ScribbleClient>{}; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
@@ -2423,6 +2423,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien | |||
final Size size = renderEditable.size; | |||
final Matrix4 transform = renderEditable.getTransformTo(null); | |||
_textInputConnection!.setEditableSizeAndTransform(size, transform); | |||
final TextSpan textSpan = buildTextSpan(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this trying to get the Rect
of the RenderEditable
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is to get the length of the text for use on line 2430
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see to implement closestPositionToPoint
and friends you will need the bounding boxes for each glyph. This is going to be pretty heavy for long documents, and since a RenderEditable
clips its content if it gets too long (i.e. RenderEditable
is a scrollable container and only part of it will remain visible), you'll get false positives on glyphs that are not currently visible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What scribble function(s) would it break if we leave the closestPositionToPoint
family unimplemented?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh interesting. I will have to put some long text in and see how that affects things.
So, closestPositionToPoint
itself allows UIKit to move the selection around based on where the user is writing. That one is not a huge deal to leave unimplemented, I suppose – the caret will just stay where it is using the previous implementation. The bigger ones are firstRectForRange
and selectionRectsForRange
which UIKit needs to do pretty much everything except inserting text at the caret: selecting, deleting, inserting/removing spaces
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, so you are right, text long enough to scroll does cause some issues. I pushed up some changes to deal with this:
- Offset the selection rects by the current scroll offset
- Cache the selection rects so that they are only recalculated if needed
- Wait for the scroll direction to be
.idle
so that scrolling is smooth
} | ||
|
||
/// Deregisters a [ScribbleClient] with [elementIdentifier] | ||
static void deregisterScribbleElement(String elementIdentifier) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did a search in a codebase and it seems we didn't name anything deregisterSomething
. So maybe unregister
?
@@ -681,6 +683,7 @@ class TextEditingValue { | |||
start: encoded['composingBase'] as int? ?? -1, | |||
end: encoded['composingExtent'] as int? ?? -1, | |||
), | |||
scribbleInProgress: encoded['scribbleInProgress'] as bool? ?? false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably should send scribbleInProgress
to the framework in a separate text input channel call, since the framework doesn't change the value. And we can avoid having to send the value back, as it's not going to be changed by the framework
@override | ||
String get elementIdentifier { | ||
if (_elementIdentifier == null) { | ||
final math.Random random = math.Random(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems we want the identifier to be unique but not necessarily random? Can we use increase-by-one counter instead?
I believe UIKit calls I don't think we're sending info to the engine on every frame during the indirect interaction now that I moved that to the framework side. Once the input is focused, we do calculate the selection boxes every frame, but we also cache those on the framework side so that they only actually get sent to the engine when changed. |
Sorry for the delay I'll take a look tonight. |
No problem! I actually have an idea for how to do the hit testing thing, just haven't had a chance to cherry pick that into the PR branch. That's here: twinsunllc@0dfa712#diff-f5f93c879cef9a102adbf148583de9f3d9a05f9678fdcad7bde106f62200bb34R2947 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry I just realized I can't test this on a simulator. I'll see if I can get a device.
@@ -2423,6 +2423,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien | |||
final Size size = renderEditable.size; | |||
final Matrix4 transform = renderEditable.getTransformTo(null); | |||
_textInputConnection!.setEditableSizeAndTransform(size, transform); | |||
final TextSpan textSpan = buildTextSpan(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see to implement closestPositionToPoint
and friends you will need the bounding boxes for each glyph. This is going to be pretty heavy for long documents, and since a RenderEditable
clips its content if it gets too long (i.e. RenderEditable
is a scrollable container and only part of it will remain visible), you'll get false positives on glyphs that are not currently visible.
@@ -2423,6 +2423,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien | |||
final Size size = renderEditable.size; | |||
final Matrix4 transform = renderEditable.getTransformTo(null); | |||
_textInputConnection!.setEditableSizeAndTransform(size, transform); | |||
final TextSpan textSpan = buildTextSpan(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What scribble function(s) would it break if we leave the closestPositionToPoint
family unimplemented?
RenderEditable? get renderEditable => widget.editableKey.currentContext?.findRenderObject() as RenderEditable?; | ||
|
||
static int _nextElementIdentifier = 1; | ||
String _elementIdentifier; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this can be final right?
} | ||
return; | ||
} else if (method == 'TextInputClient.requestElementsInRect') { | ||
final List<dynamic> args = methodCall.arguments as List<dynamic>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can have a widget the serves as the scope of the clients that will be considered visible, and put one such widget in each route.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The idea being to move the _scribbleClients
and registerScribbleElement
/ unregisterScribbleElement
members into a ScribbleContext
or some such thing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah the same solution autofill uses to determine which clients are considered visible. But for autofill it's easier: the current scope is the one that containss the current TextInputClient
. For scribble I'm not sure how this should work, since UIIndirectScribbleInteraction
doesn't seem to require the subject text field to be focused. I don't think there is an app-wise "currentRoute", since you can have more than one navigators in an app and each of them will have a "currentRoute" (CupertinoTabScaffold
for example). Even if there is, widgets in the "currentRounte" are not guaranteed to be visible and interactable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be possible to solve this by adding something to the RenderObject
class that resembles hit-testing (or generalize the existing hit-testing system) but with Rect
s instead of PointEvent
s.
final Offset rectOffset = Offset(_isMultiline ? 0.0 : -1 * scrollOffset, _isMultiline ? -1 * scrollOffset : 0.0); | ||
final List<Rect> rects = List<Rect>.generate( | ||
text.length, (int i) => renderEditable.getBoxesForSelection(TextSelection(baseOffset: i, extentOffset: i + 1)).first.toRect()) | ||
.map((Rect rect) => rect.translate(rectOffset.dx, rectOffset.dy)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should probably happen in the RenderEditable
public api you exposed in the PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So using localToGlobal
on the render object there instead of offsetting by the scroll offset here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The RenderEditable.getBoxesForSelection
API you exposed in the PR should be responsible for doing the translate(rectOffset.dx, rectOffset.dy)
thing. The caller would usually expect the TextBox
es to be described in the RenderEditable
's coordiante space instead of _textPainter
's. (Some other RenderEditable
APIs are doing the same thing).
_cachedFirstRect = firstRect; | ||
final Offset rectOffset = Offset(_isMultiline ? 0.0 : -1 * scrollOffset, _isMultiline ? -1 * scrollOffset : 0.0); | ||
final List<Rect> rects = List<Rect>.generate( | ||
text.length, (int i) => renderEditable.getBoxesForSelection(TextSelection(baseOffset: i, extentOffset: i + 1)).first.toRect()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if this is going to work when there's surrogate pairs & extended glypheme clusters. @justinmc
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will UIkit ever ask for the locations of the glyphs in a text field that is not focused? If that's the case the Rects can be outdated since we only update for the current text field.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe it will not. When not focused, we should only be dealing with UIIndirectScribbleInteraction
which asks for the elements in a rect (requestElementsInRect
) and then lets us know to focus on one of them at a given point (focusElementIfNeeded
). Once an element has focus, we should be sending over the rects so that UIKit has what it needs to do the text operations that happen during a UIDirectScribbleInteraction
@LongCatIsLooong I think I have addressed your feedback except for a couple of items:
I also added an implementation for Let me know your thoughts there. I have some merge conflicts now – I'm hoping this is close, but I'll wait to get your thumbs up before rebasing this onto the current master (or perhaps the last green commit on master?) and marking it as ready for review. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work!
The "finding the right scribble clients" problem seems to be way harder than I initially thought and unfortunately I don't really have a good solution.
/cc @justinmc for insights.
void showToolbar(); | ||
|
||
/// UIKit has requested a text placeholder be added at the current selection | ||
void insertTextPlaceholder(Size size); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you elaborate on what is a "text placeholder"? Is it possible for multiple placeholders to co-exist (for example, how does it work if I add a text placeholder in one field and switch to a different input field and add another)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does not appear that you can add multiple placeholders, that said, we do need to clean up the one in the current TextInputClient
if the focus changes, since the removeTextPlaceholder
call will get pushed to the new TextInputClient
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I forgot to answer your first question: this is another scribble feature where you can long press to give yourself extra space to write in the middle of some text. I don't know if it's used for anything else...the docs about it are pretty terse, and don't event mention the scribble use.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe add a little bit in these comments about what this is for, something like:
/// For example, this is called when responding to UIKit requesting
- /// a text placeholder be added at the current selection.
+ /// a text placeholder be added at the current selection, such as when
+ /// requesting additional writing space with iPadOS14 Scribble.
} | ||
return <dynamic>[elementIdentifier, ...<dynamic>[bounds.left, bounds.top, bounds.width, bounds.height]]; | ||
}).where((List<dynamic> list) { | ||
return list.length == 5; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a bit strange, as the meaning of the "where" is not immediately clear. Consider doing the filtering before the map or just do the whole thing in a for loop.
if (method == 'TextInputClient.focusElement') { | ||
final List<dynamic> args = methodCall.arguments as List<dynamic>; | ||
if (_scribbleClients.containsKey(args[0])) { | ||
_scribbleClients[args[0]]?.onScribbleFocus(Offset(args[1].toDouble() as double, args[2].toDouble() as double)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you still need the type case with toDouble()
? Doesn't toDouble()
return a double
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the issue is that args
is List<dynamic>
, so args[1]
is dynamic
which means args[1].toDouble()
also returns dynamic. If i take out as double
I get a the following error:
The argument type 'dynamic' can't be assigned to the parameter type 'double'.
} | ||
return; | ||
} else if (method == 'TextInputClient.requestElementsInRect') { | ||
final List<dynamic> args = methodCall.arguments as List<dynamic>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah the same solution autofill uses to determine which clients are considered visible. But for autofill it's easier: the current scope is the one that containss the current TextInputClient
. For scribble I'm not sure how this should work, since UIIndirectScribbleInteraction
doesn't seem to require the subject text field to be focused. I don't think there is an app-wise "currentRoute", since you can have more than one navigators in an app and each of them will have a "currentRoute" (CupertinoTabScaffold
for example). Even if there is, widgets in the "currentRounte" are not guaranteed to be visible and interactable.
} | ||
return; | ||
} else if (method == 'TextInputClient.requestElementsInRect') { | ||
final List<dynamic> args = methodCall.arguments as List<dynamic>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be possible to solve this by adding something to the RenderObject
class that resembles hit-testing (or generalize the existing hit-testing system) but with Rect
s instead of PointEvent
s.
void insertTextPlaceholder(Size size) { | ||
print('[scribble][flutter] insertTextPlaceholder $size'); | ||
setState(() { | ||
_placeholderLocation = _value.text.length - widget.controller.selection.end; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unfortunately currently in the framework we use (-1, -1)
to represent no selection instead of null
. That means you'll get _value.text.length + 1
if there's no selection. Can that happen here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can the EditableText
have focus but not a selection? The checks on where the placeholder gets inserted should handle that (they test that it falls within [0, _value.text.length]), so we wouldn't insert the placeholder anywhere...which...if there's no selection, we wouldn't know where to put it anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah the user can set the selection to (-1, -1)
even if the EditableText
is focused.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might want to check if the selection is valid (https://master-api.flutter.dev/flutter/dart-ui/TextRange/isValid.html) first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious how text selections interact with placeholders. When a placeholder is inserted and the current selection isn't empty, does the placeholder replace the currently selected text, or the placeholder is simply inserted at the end of the current selection? If it's the latter what happens if you press backspace after inserting a placeholder?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The placeholder should be inserted wherever the user is pressing – the selection actually gets moved there and collapsed anyway when the pencil is placed.
@@ -2519,6 +2545,24 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien | |||
} | |||
} | |||
|
|||
int _placeholderLocation = -1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe use null to indicate there's no placeholder
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually kinda like using -1 as a special value here, as I can easily test that the placeholder is within the bounds of the string (which I need to do anyway) without having to also make sure _placeholderLocation
is not null
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we do go with -1, can we make sure it's fully explained in a comment somewhere? Like that -1 means there is no placeholder, otherwise it must be between 0 and the length of the text inclusive.
|
||
@override | ||
bool isInScribbleRect(Rect rect) { | ||
final Rect _bounds = bounds; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: why the local variable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bounds
is computed, so I figured it was worth getting once and setting it to a local variable to use in the later steps
intersection.bottomCenter, | ||
intersection.bottomRight | ||
].any((Offset point) { | ||
final HitTestResult result = HitTestResult(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I see how difficult it would be to find hitable RenderObject
s in a given Rect
. Hitable RenderBox
es can partially overlap each other. So never mind the previous comment about adding things to RenderObject
. But still we probably shouldn't sample points.
@goderbauer @justinmc any suggestions on how we can find currently "interactable" elements in a given rect?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, sampling "random" points doesn't seem right here.
Also it seems we are doing this hit testing multiple times if there are multiple Scribbles on screen?
Can you describe what the hit test is trying to accomplish?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@goderbauer there's an interesting scribble feature where you can scribble on screen without any text field focused. The text will be sent to the most relevant text field based on the scribble location. An example: in the "reminder" app you can launch the app and just scribble away, and a new "reminder" entry will be added.
To that end UIKit needs to ask us for "active scribble elements" in a certain Rect
. But it's tricky to tell if a ScribbleClient
has a "hotspot" in the given a Rect
(as it can be behind an IgnorePointer
or obscured by another ScribbleClient
, we would have to hit test every Offset
in the Rect
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, so the hit testing is last here, after making sure the _ScribbleFocusable
intersects the rect that UIKit is interested in. I think in most cases that would just be a few elements. It also uses any
so that in the nominal case where the whole text field is visible and interactive, only one point will need to be checked.
I am interested to hear some ideas on the best way to do this. FWIW I think this approach will work in most cases, since it is testing points on the intersection between the two rectangles. Unless someone has very large handwriting, that area should be relatively small.
We actually have a beta test going with a custom engine implementing this that seems to be going pretty well. Granted, that is on a separate branch as we haven't updated from flutter 1.22.2 – I will probably look into upgrading again once this makes it to the stable branch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if we just hit test the center of the intersection and that's it? Are there any real cases where that would fail and the user would reasonably expect it to succeed?
Unless I'm missing some case, it seems like hit testing the center of the intersection would only fail if the EditableText is more than half way obscured. In reality, if the EditableText is obscured, the user probably would forgive us for not entering their text. For example, if the field is partially (at least halfway) scrolled underneath the AppBar, it would be clear that the user needs to reveal it first to get it to work.
The main cases where this approach wouldn't work would be crazy polygon shaped widgets on top of text fields, which probably isn't vital to get correct in a feature like this.
final RenderBox? box = context.findRenderObject() as RenderBox?; | ||
if (box == null || !mounted || !box.attached) | ||
return Rect.zero; | ||
final Offset topLeft = box.localToGlobal(Offset.zero); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This may give you the wrong size because only the origin is transformed. The transform can be retrieved using getTransformTo
and it should be applied to the rect. There doesn't seem to a shorthand method for this unfortunately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(not a full review)
@@ -62,6 +62,9 @@ enum SelectionChangedCause { | |||
/// The user used the mouse to change the selection by dragging over a piece | |||
/// of text. | |||
drag, | |||
|
|||
/// The user iPadOS 14 Scribble to change the selection. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this is missing a verb
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also: the other SelectionChangedCause are expressed more abstract and not necessarily tied to a particular feature or OS. Can we do the same for this? Would it be fair to say that the selection change was triggered, e.g. by "stylus"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps, but I don't know the behavior for those – for scribble specifically, this is needed so that we show the selection handles like native the iPadOS UITextField does when using scribble to change the selection...I don't know if that is the case for using a stylus to change the selection on android, for example
void removeTextPlaceholder(); | ||
} | ||
|
||
/// An interface for recieving focus during a UIIndirectScribbleInteraction |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This shouldn't be tied to a particular platform implementation. E.g. when another platform adds a similar feature we want to reuse these APIs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(here and elsewhere)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of pointing to a particular implementation the docs should explain what this client is (and then could refer to a particular platform implementation as an example that makes use of this).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you have any thoughts on what parts of this would be abstracted out? This is pretty closely tied to the way iPadOS's scribble feature interacts with the UITextInput, so I'm not sure how generic it would be
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I see what you mean – just keeping the docs at a more abstract level...I think I got all of those and made the docs more generic (e.g. handling x from engine) with the stuff about specific iOS features as a "for example"
@@ -1425,4 +1524,15 @@ class TextInput { | |||
shouldSave , | |||
); | |||
} | |||
|
|||
/// Registers a [ScribbleClient] with [elementIdentifier] that can be focused using an | |||
/// UIIndirectScribbleInteraction on an iPad |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit (here and elsewhere): end this with a .
|
||
@override | ||
void removeTextPlaceholder() { | ||
print('[scribble][flutter] removeTextPlaceholder'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: please don't forget to remove prints before submitting this.
@@ -3053,3 +3119,135 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter { | |||
return _ltrRegExp.hasMatch(String.fromCharCode(value)) ? TextDirection.ltr : TextDirection.rtl; | |||
} | |||
} | |||
|
|||
class _ScribbleElement extends StatefulWidget { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Element
is a bad suffix for a widget, elements are something else in Flutter: https://master-api.flutter.dev/flutter/widgets/Element-class.html
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would _ScribbleFocusable
be a better name? It is certainly more descriptive.
@override | ||
void onScribbleFocus(Offset offset) { | ||
widget.focusNode.requestFocus(); | ||
renderEditable?.selectPositionAt(from: offset, cause: SelectionChangedCause.keyboard); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cause: keyboard is odd. Is that correct here?
intersection.bottomCenter, | ||
intersection.bottomRight | ||
].any((Offset point) { | ||
final HitTestResult result = HitTestResult(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, sampling "random" points doesn't seem right here.
Also it seems we are doing this hit testing multiple times if there are multiple Scribbles on screen?
Can you describe what the hit test is trying to accomplish?
/// The size of the span, used in place of adding a placeholder size to the [TextPainter] | ||
final Size size; | ||
|
||
/// Adds a placeholder box to the paragraph builder if a size has been |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no need to repeat the docs of an inherited method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quick review. This is exciting! Mainly see the comments about extended grapheme clusters.
_cachedFirstRect = firstRect; | ||
_cachedSize = size; | ||
final List<Rect> rects = List<Rect>.generate( | ||
text.length, (int i) => renderEditable.getBoxesForSelection(TextSelection(baseOffset: i, extentOffset: i + 1)).first); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As was discussed in #75472 (comment), this will result in TextSelections of partial characters when there are multi-byte characters like 👨👩👦 or 😆 . I think you should use characters, but you'll probably need a full String and not just a StringBuffer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also nit: I think the usual Flutter style is more like this.
final List<Rect> rects = List<Rect>.generate(
text.length,
(int i) => renderEditable.getBoxesForSelection(TextSelection(baseOffset: i, extentOffset: i + 1)).first,
);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@justinmc I did some refactoring to use characters, let me know how that looks. It seems to work well with at least those specific multi-byte characters that you mentioned now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All right, so I did find one more issue that's pretty strange with characters like 👨👩👦 that contain zero width joiners. With the following text:
\n
👨👩👦 1 2 3 4 5 6 7 8
(Note the leading newline) – only "👨👩👦 1 2 3 4 5 " will be selectable with scribble. However, adding a trailing newline makes the whole line selectable.
I've been poking around various things and trying to compare them to the native UITextView versions of these, and really have no idea what's going on. It really looks like PencilKit basically searches (correctly) positions 1-24 at first, then comes back through and only looks at 1-20 before setting the selected text to 1-19. If you have any thoughts on why that might be, I'm all ears.
All that said, it works perfectly with 😆 in all of my testing, so I think this is limited to ZWJ-containing characters.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@justinmc All right, I think the ZWJ-containing issue is resolved with the latest updates to the engine part of this PR
@@ -2519,6 +2545,24 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien | |||
} | |||
} | |||
|
|||
int _placeholderLocation = -1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we do go with -1, can we make sure it's fully explained in a comment somewhere? Like that -1 means there is no placeholder, otherwise it must be between 0 and the length of the text inclusive.
intersection.bottomCenter, | ||
intersection.bottomRight | ||
].any((Offset point) { | ||
final HitTestResult result = HitTestResult(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if we just hit test the center of the intersection and that's it? Are there any real cases where that would fail and the user would reasonably expect it to succeed?
Unless I'm missing some case, it seems like hit testing the center of the intersection would only fail if the EditableText is more than half way obscured. In reality, if the EditableText is obscured, the user probably would forgive us for not entering their text. For example, if the field is partially (at least halfway) scrolled underneath the AppBar, it would be clear that the user needs to reveal it first to get it to work.
The main cases where this approach wouldn't work would be crazy polygon shaped widgets on top of text fields, which probably isn't vital to get correct in a feature like this.
a0ba019
to
3152493
Compare
2fab767
to
2f8c117
Compare
@LongCatIsLooong @justinmc @goderbauer I believe I've addressed all of the comments here, let me know if you see anything else! Thanks! |
@LongCatIsLooong @justinmc @goderbauer Just checking in here if there are any more changes needed. It looks like there are some merge conflicts now, so if everything looks good, I can clean those up and hopefully we can get this merged 😄 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the slow response, I'm still really excited for Flutter to support this. No major blockers in my comments, but we should also get @LongCatIsLooong to sign off.
Am I able to try this out using the simulator on a Mac, or do I need an iPad?
void showToolbar(); | ||
|
||
/// UIKit has requested a text placeholder be added at the current selection | ||
void insertTextPlaceholder(Size size); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe add a little bit in these comments about what this is for, something like:
/// For example, this is called when responding to UIKit requesting
- /// a text placeholder be added at the current selection.
+ /// a text placeholder be added at the current selection, such as when
+ /// requesting additional writing space with iPadOS14 Scribble.
/// [bounds]. | ||
const SelectionRect({required this.position, required this.bounds}); | ||
|
||
/// The position of this selection rect within the text. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you elaborate exactly what this position is in these docs? Is it the baseOffset of the selection, or is it whichever comes first depending on the TextDirection?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this go in the comment? I don't know how it would work with a rtl language (are Traditional Chinese or Cantonese right to left on iOS?), but this is essentially the base offset of a selection that would contain this SelectionRect, so for example, this will be 0
for the first character, 1
for the second, and so on, with some jumps for multi-byte characters, so an 8 byte character at 0
would jump to 8
for the next character.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I see, I maybe just add String to clarify and then it's good: "The position of this selection rect within the text String."
/// The position of this selection rect within the text. | ||
final int position; | ||
|
||
/// The rectangle representing the bounds of this selection rect. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible for the selection to be on multiple lines? If so, is this like a bounding box?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, this is not itself a selection, it just represents a bounding box around a given character within a string of text, so the string "abc" will have selection rects for "a", "b", and "c".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh got it, I didn't realize it was only for a single character.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From the code in editable_text.dart, this in the current focused RenderEditable
's coordinate space?
} | ||
return; | ||
} else if (method == 'TextInputClient.requestElementsInRect') { | ||
final List<dynamic> args = methodCall.arguments as List<dynamic>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you need List.cast.
/// 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(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I worry about adding one more way to show the toolbar, since it's already pretty complicated figuring out what's causing it to show or not. It does seem to be necessary in this case though...
void _updateSelectionRects({bool force = false}) { | ||
if (defaultTargetPlatform != TargetPlatform.iOS) | ||
return; | ||
// This is to avoid sending selection rects on non-iPad devices |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Period at the end.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess we have no better way to determine if this is iPadOS... At least we should make 1536.0
a constant called _kIPadWidth
or something like that.
if (scrollDirection == ScrollDirection.idle && (force || text != _cachedText || | ||
_cachedFirstRect != firstRect || _cachedSize != size || | ||
_cachedPlaceholder != _placeholderLocation)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make this any more readable if you split out some variable(s) out of this big conditional?
expect(textSpan.text, 'Lorem ipsum dolor sit amet'); | ||
}, skip: kIsWeb); | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove this extra indent.
textSpan = findRenderEditable(tester).text!; | ||
expect(textSpan.children, null); | ||
expect(textSpan.text, 'Lorem ipsum dolor sit amet'); | ||
}, skip: kIsWeb); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should some of these tests be TargetPlatform.iOS only?
@justinmc Thanks for your comments, I'll take a look through those individually – I have some memory management stuff to clean up on the engine side as well. Yes, unfortunately there's no simulator support, so you will need a physical iPad with iPadOS 14 and a Pencil. |
@justinmc I think I made all the changes you requested / responded to questions. Let me know if I missed anything |
@fbcouch Awesome work! I was wondering if there's a design doc that provides an overview of this feature? framework <-> engine interactions, etc... |
@blasten There isn't that I know of, but I can try to put one together if that would be helpful |
…n text style changes
…noTextField to allow opting out of the scribble features
72b5534
to
6ccdd12
Compare
@LongCatIsLooong Thanks! I added a note to that effect, and rebasing onto master fixed the checks! |
Let's try merging this! |
This reverts commit 9490917.
Flutter 3.7 version, ipad device, any page can be triggered, apple pencil's handwriting function |
Enables the Scribe feature, or Android stylus handwriting text input.  This PR only implements basic handwriting input. Other features will be done in subsequent PRs: * #155948 * #156018 I created and fixed issue about stylus hovering while working on this: #148810 Original PR for iOS Scribble, the iOS version of this feature: #75472 FYI @fbcouch ~~Depends on flutter/engine#52943 (merged). Fixes #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 <[email protected]>
Engine PR: flutter/engine#24224
This PR is the framework side of adding scribble handwriting support. I still need to add some tests, but looking for feedback on the overall approach. I incorporated some feedback from @LongCatIsLooong on a previous draft, moving most of the logic of this into the framework and adding a widget (
_ScribbleElement
) to handle most of the scribble-specific logic.The one remaining issue here (that I could use some ideas on how to solve) is that the UIIndirectScribbleInteraction can always be triggered, regardless of whether the text input in question is obscured by another element or otherwise non-interactive (e.g. behind something else in a stack).
#61278
Design doc: https://docs.google.com/document/d/1mjQbsSRQnHuAgMNdouaSgTS-Xv-w57fdKfOUUafWpRo
If you had to change anything in the flutter/tests repo, include a link to the migration guide as per the breaking change policy.
Pre-launch Checklist
///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.