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
Add WidgetSpan support in TextFields and "Replacements" API for TextEditingController #80185
Conversation
It looks like this pull request may not have tests. Please make sure to add tests before merging. If you need an exemption to this rule, contact Hixie on the #hackers channel in Chat. If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix? Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
const TextStyle(decoration: TextDecoration.underline), | ||
); | ||
return TextSpan( | ||
for (final TextEditingInlineSpanReplacement replacement in textEditingInlineSpanReplacements) { |
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.
while testing out the pr, i noticed the entire app would freeze and crash if deleting too fast. im now wondering if these fors could be the culprit, as 3 nested fors could be heavy. for solving this, i was thinking of using the setter for textEditingInlineSpanReplacements for compiling the rangeSpanMapping instead of building these when building the text spans
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 PR is now in a much more mature state. I'd be interested to see if you still see the freeze/crash. I am not seeing it on my test devices. The nested for loops are indeed inefficient for large amounts of text, but I would be very surprised to see it make a noticeable difference for the vast majority of use cases. We do similar/more work for the layout itself that this should be insignificant amount of work barring thousands of matched TextRanges.
Since the text can change at any point, the spans must be calculated on each text change event to ensure what we output at any given point is correct. Computing the replacements in the setter would not be able to capture all of the replacements as text is being typed.
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, keep in mind, debug mode performance is much worse than release mode performance and is not indicative of the actual performance of the app.
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.
Upon further testing, turns out the issue was on my app's code and not the pr itself. The pr seems to be working pretty good, gj!
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.
Thanks for the PR! I have a few questions reguarding how text modification works when there's a PlaceholderSpan
:
- If I press backspace after placing the caret immediately after the
PlaceholderSpan
, it's supposed to delete the entire span, since it's just one code point? - If I press backspace after placing the caret at some other random places, how are we going to translate the modified value so it can be sent back to the
ReplacementTextEditingController
? The text editing controller has already replaced the matched patterns with placeholder spans, so the place that handles the keypress needs to know how to translate the placeholder spans back to the patterns right?
// place offset at beginning or end of placeholder depending on | ||
// which half it is in. | ||
adjustment += offset - dims.range!.start; | ||
if (offset > dims.range!.start + dims.range!.end / 2) { |
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 be decided by the placeholder, like TextAffinity
, instead of depending on which half it is in?
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 have added affinity support, but for caret positioning from tapping, I have still used the "which half it is in" since the affinity of the tap is ambiguous and we default to always picking one affinity first to try before the other, making it extremely difficult to select one side of the widget. The 'which half' method seems to be much more consistent and desirable UX.
For everything else, I pass the affinity in and we use that to determine positioning.
textParentData.offset.dx, | ||
textParentData.offset.dy, | ||
0.0, | ||
)..scale( |
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.
doesn't the scale
also scale the offset, if it's applied after the translation?
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.
Yes, the scaling occurs with the entire paragraph and everything laid out in it.
// Add composing region as a replacement to a TextSpan with underline. | ||
if (composingRegionPrioritized && value.isComposingRangeValid && withComposing) { | ||
_addToMappingWithoutOverlap((String value, TextRange range) { | ||
final TextStyle composingStyle = style!.merge( |
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.
style
can be null from the signature of this 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.
This improper non-null cast seems to also be in the current master implementation. I'll change it for both.
@@ -2982,6 +3032,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { | |||
visitor(foregroundChild); | |||
if (backgroundChild != null) | |||
visitor(backgroundChild); | |||
super.visitChildren(visitor); | |||
} |
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.
RenderEditable
also overrides redepthChildren
. Should that method be updated as well?
To answer your questions:
|
So if you press ctrl+del, on macOS it deletes the current line. This is an example of layout-dependent text modifications, and this kind of modification can't be directly used to update a |
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.
Overall this is pretty cool.
/// This value is only relevant when used in TextFields/Editable widgets | ||
/// as replacements for strings of regular text. | ||
final TextRange? range; |
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 just be TextRange.empty in the cases where it is not relevant?
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.
(That may remove the need for some of the !
below)
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.
done!
// restored to the original values before final layout and painting. | ||
List<PlaceholderDimensions>? _placeholderDimensions; | ||
|
||
// Layout the child inline widgets. We then pass the dimensions of the |
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.
A substantial amount of (somewhat complex) logic here seems to be shared with RenderParagraph for similar purposes. I wonder if we can find a way to share it between the implementations.
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.
(true for other methods in this file as well)
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.
Some of the work in #79420 may make it easier to support a lot of this logic in a common place by removing many of the editing methods, but as of now, reuse of code would require a fairly complex redesign/refactor.
|
||
/// If composing regions should be matched against for replacements. | ||
/// | ||
/// When true, composing regions are added before any patterns are applied, |
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 sure what's meant by "added" here and in the paragraph below.
/// and replacements made. This means that composing region may sometimes | ||
/// fail to display if the composing region matches against of the the | ||
/// replacement patterns. | ||
final bool composingRegionPrioritized; |
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.
why "prioritized"? Isn't this basically saying whether the composingRegion should be ignored for replacements? E.g. when this is true, a long as a string is part of the composing region, it cannot be replaced with a replacement?
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.
Renamed to composingRegionReplaceable
} | ||
} | ||
|
||
void _addToMappingWithoutOverlap(InlineSpanGenerator generator, TextRange matchedRange, Map<TextRange, InlineSpan> rangeSpanMapping, String 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 this just be a private (static) method on ReplacementTextEditingController? Why top-level?
The backspace behavior described above sounds a little strange. I think the expectation is that if I have my cursor positioned after a widgetspan and I hit backspace, the widgetspan should not decompose into its internal reprsentation. That's similar to how deleting a multi-byte emoji works: If I have my cursor placed after 🇩🇪 and hit backspace, I expect the entire flag to disappear. It shouldn't decompositions into DE. Does navigating with arrow keys still work as expected? E.g. if the cursor is placed after a WidgetSpan and I hit the left arrow, does the cursor directly jump before the span? Or do I have to press the arrow multiple times? (Would be good to have a test for this behavior). Similar, what does a widget span mean for multi-line text? E.g. if I press the up/down arrow, does the cursor still end up at the right position in the previous/next line? Or do widget spans interfere with that? (A test for this case would be cool, too) |
On further thought, I agree the backspace should just delete the whole widgetspan by default. My initial idea was to use a formatter for maximum flexibility, but in hindsight, deleting part of a replaced span is unlikely to be a use case and can still be implemented via formatter if really desired. Yes, arrow keys should still function as expected. I can add tests for this. |
@goderbauer I found that the delete key is actually typically handled by the IME, and for most IMEs, it never passes through the Framework's RenderEditable deletion and other editing methods. For example, on android, In the framework, since we are only receiving an update from the IME post-modification, we would have to rely on comparing it with the pre-update string, which is essentially a heuristic (that is frequently used in formatters). Since this is a more advanced feature, I am leaning towards keeping the current behavior of deletions decomposing the replaced widget rather than implementing something that isn't quite robust. On the bright side, leaving it as is technically allows for more flexibility in how this is used. |
How's it going? |
1 similar comment
How's it going? |
Could we replace the string sequence with a single placeholder character to enable the IME to delete it with one backspace hit? |
There are a few problems with replacing the matched substrings with placeholder characters in the actual TextEditingValue:
|
I am actually leaning towards landing this in two sections:
The second part of this is far more controversial and may require further work to refine. However, the first part should be relatively straightforward. More advanced developers should be able to work with just the first part to integrate WidgetSpans into their fields, while the second part provides a more easily accessible API for it. |
Core WidgetSpan support in RenderEditable should land in #83537 We will further discuss a wrapper API for making using WidgetSpans easier. In the meantime, passing WidgetSpans into RenderEditable should work, though you should not expect editing or carets to work perfectly out of the box depending on your specific use cases without additional supporting code to handle it. WidgetSpans can be added by subclassing TextEditingController to output WidgetSpans. I will close this PR for now and open a new one when the user-friendly API is more ready. |
@GaryQian caret positions are a mess even by handling it through extended controller classes and formatters especially when the user changes the caret position explicitly. Are you still working on this issue? |
He's not, so anyone else is welcome to open a PR. |
Since the addition of WidgetSpans as a feature of RichText widgets, there has been significant demand for addition of WidgetSpans to TextFields (#30688)
This PR is largely based off of the implementation in #30069
This PR adds the capability to add replacements for matched patterns/regexes with any InlineSpan (including WidgetSpan and TextSpan) as well as lower level support for PlaceholderSpans in Editable and TextPainter.
Adds
ReplacementTextEditingController
which holds a List ofTextEditingInlineSpanReplacements
, each of which define aPattern
to match against and a generator function that generates anInlineSpan
from the matched string. On every change in the entered text, the replacements will be processed from first to last, replacing matches with the generatedInlineSpan
. By generating aWidgetSpan
, widgets can be dynamically embedded. Since the matched string value is passed to the generator, this string can be parsed to dynamically generate any widget.For example, this controller replaces
{hello}
with blue boxes:Later, pass the controller to the TextField:
In the background, this PR adds support for WidgetSpan rendering to RenderEditable and Editable, as well as handle caret positioning in TextPainter. WidgetSpans may be used in TextFields without using the replacement API by overriding
buildTextSpan
inTextEditingController
See #30069 and #33794 for the RichText implementation of this feature.