-
Notifications
You must be signed in to change notification settings - Fork 29.8k
iOS selection handles are invisible #31332
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
Conversation
by fuzzily checking contains
1d69883 to
ad48e16
Compare
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.
@GaryQian Is there anything else more specific that I can refer people to for the slightly off engine bug that I asked you about on Friday?
By the way, despite all of the tests that I added to this, I was never able to reproduce that engine bug in the tests. Any idea how I might do that if it's possible?
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.
There's a few places we do height rounding for text: https://github.com/flutter/engine/blob/master/third_party/txt/src/txt/paragraph.cc#L921
and https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/painting/text_painter.dart#L294 come to mind. The combination of these produces integer height values.
Is this the bug you are asking about? Maybe because we are rounding in both the engine and the framework, one of those rounds is eating up your "slightly off".
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.
GetRectsForRange produces the box by using the rounded baseline, but then adds on the unrounded ascent and descent, which may cause inconsistencies between the reported height and the getRectsForRange positions.
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.
Any better way to do 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.
Instead of a fuzzy check, it may be better to instead increase the size of the clipping zone by measuring the height of the actual glyphs. With the current API, should be able to be achieved with calling GetBoxesForRange() with BoxHeightStyle.max on the first and last glyph. This should result in the true size of the paragraph, except that this requires layout first. The problem with this metric is that a single line height does not convert to multi line height with a simple int multiplication even if all lines are identical.
Another thing that could cause this is that since we now use strut on TextFields, it is now possible for text to extend past the provided space due to different fonts being fallen back on.
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, it does seem like a fuzzy check is a hack and adds slop to the way everything works, I'd prefer a more formal and rigid solution if it is achievable. Feel free to come chat about other approaches to this.
25f6fe8 to
777c755
Compare
|
I commented on my reservations on a fuzzy check, but this may end up being the only reasonable way to fix the bug without doing an overhaul of the legacy rounding system currently in place, which would be a massive breaking change that would basically break all golden tests in existence. |
|
Lets fix this problem with a fuzzy check now. It would be nice to be able tighten up the logic in the future. |
|
@GaryQian @HansMuller I improved the fuzzy check and the docs and created an issue for cleaning this up in the future so we don't have to do an approximation: #31495 |
HansMuller
left a comment
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.
Looks good, just some small time feedback.
| // Returns a bool indicating if the offset is within factor of being contained | ||
| // within the rect. | ||
| static bool _containsFuzzy(Rect rect, Offset offset, [double factor = 0.1]) { | ||
| return rect.contains(offset) |
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 expression could just be rect.inflate(factor).contains(offset). Suggest renaming factor => slop (here and elsewhere). We actually use "slop" in other similar names.
| // Check if the caret is visible with an approximation because of an error | ||
| // in the engine that always reports that the caret extends above the input | ||
| // by a small amount. See paragraph.cc's implementation of GetRectsForRange. | ||
| const double kApproximateFactor = 0.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.
For const variables, don't use the "k" prefix. In this case (sadly) the variable name that would be consistent with existing names would be visibleRegionSlop.
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 also describe in the comment above how you arrived at this 0.1 value? _applyFloatingPointHack seem to describe rounding to the nearest pixel. Is this related?
| ); | ||
|
|
||
| final EditableTextState state = | ||
| tester.state<EditableTextState>(find.byType(EditableText)); |
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's OK to just put this variable and its value on one line; otherwise 2-space indent (here and below).
|
|
||
| testWidgets('iOS text selection handle visibility', (WidgetTester tester) async { | ||
| debugDefaultTargetPlatformOverride = TargetPlatform.iOS; | ||
| final GlobalKey<EditableTextState> editableTextKey = |
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 key really needed? Could always use find.byType(EditableText)?
| rightVisibleBefore = rightVisible; | ||
|
|
||
| // Check that the handles' positions are correct (clamped within the | ||
| // viewport but not stuck). |
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 does "stuck" mean 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.
I copied this from the material test. I think it means not in a single position every time even when the selected text moves. I'll update both comments.
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.
Sounds good!
| }); | ||
|
|
||
| testWidgets('iOS text selection handle visibility', (WidgetTester tester) async { | ||
| debugDefaultTargetPlatformOverride = TargetPlatform.iOS; |
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.
Include a comment here:
// Regression test for https://github.com/flutter/flutter/issues/31287
|
|
||
| Future<void> verifyVisibility( | ||
| bool leftVisible, | ||
| Symbol leftPosition, |
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 usually don't use Symbols for this kind of thing. Better to just define some local integer constants.
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.
More copied code, but I'll quickly update it to an enum. Let me know if that doesn't work.
|
|
||
| final Size viewport = renderEditable.size; | ||
|
|
||
| void testPosition(double pos, Symbol expected) { |
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.
Use a switch here since expected is just a substitute for a local enum.
| final Size viewport = renderEditable.size; | ||
|
|
||
| void testPosition(double pos, Symbol expected) { | ||
| if (expected == #left) |
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.
These symbols might be a little easier to understand if they were called "leftViewportEdge" or "withinViewport" etc
| bool leftVisibleBefore = false; | ||
| bool rightVisibleBefore = false; | ||
|
|
||
| Future<void> verifyVisibility( |
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 might be slightly easier to read if the parameter pairs were reversed:
verifyVisibility(leftViewportEdge, true, rightViewportEdge, true)
xster
left a comment
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.
LGTM. Thanks for fixing.
| ); | ||
|
|
||
| _selectionStartInViewport.value = visibleRegion.contains(startOffset + effectiveOffset); | ||
| // Check if the caret is visible with an approximation because a difference |
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 add a TODO with a tracking bug to fix the root cause?
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 do. Here's the issue for the record: #31495
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.
s/caret/handle right? I think the code in here all reference the blinking cursor as caret and the edges of a selection as handles.
| // Check if the caret is visible with an approximation because of an error | ||
| // in the engine that always reports that the caret extends above the input | ||
| // by a small amount. See paragraph.cc's implementation of GetRectsForRange. | ||
| const double kApproximateFactor = 0.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.
Can you also describe in the comment above how you arrived at this 0.1 value? _applyFloatingPointHack seem to describe rounding to the nearest pixel. Is this related?
| final List<Widget> transitions = | ||
| find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList(); | ||
| expect(transitions.length, 3); | ||
| final FadeTransition left = transitions[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.
Add a comment for why 3 and what are the first and second ones expected to be.
| expect(right.opacity.value, equals(1.0)); | ||
| }); | ||
|
|
||
| testWidgets('iOS shows selection carets', (WidgetTester tester) async { |
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 add this test to cupertino/text_field_test.dart too?
| final GlobalKey<EditableTextState> editableTextKey = | ||
| GlobalKey<EditableTextState>(); | ||
|
|
||
| const String testText = 'XXXXX XXXXX'; |
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.
Add a comment for what the spaces are for
| const String testText = 'XXXXX XXXXX'; | ||
| final TextEditingController controller = TextEditingController(text: testText); | ||
|
|
||
| final Widget widget = MaterialApp( |
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 don't seem to be using this reference later. Maybe just inline this into the pumpWidget?
| bool rightVisibleBefore = false; | ||
|
|
||
| Future<void> verifyVisibility( | ||
| bool leftVisible, |
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 call this expectedLeftVisible etc?
Might be worth checking this in RTL too in which case it's really expectedStartVisible
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 just added a very simple RTL test. I tried copying one of these big tests and making it RTL, but I've been fighting with it all day to get it to pass. I'm seeing lots of weird behavior that I don't see when I manually test the app myself. I think it's better to just have a simple one than for me to hack in a copy of these that I don't understand.
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 const can not be used before type declarations?
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.
Whoops, good catch.
e3bf76a to
36389e3
Compare
|
@GaryQian Can we be certain that the rounding error will always be smaller than Update: I've changed the fudge factor to be 0.5 because I saw that once when using EditableText directly. Maybe that makes more sense if it's a whole number rounding thing... |
62a9168 to
590678f
Compare
590678f to
a6514b9
Compare
f4d32fd to
ea0cf87
Compare
|
Ready for re-review @HansMuller @xster |
|
Yes, the 0.5 should cover the rounding error. in practice, it is usually < 0.1, but I do think it can be up to 0.5, as the rounding is to closest int. |
xster
left a comment
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.
LGTM
| ); | ||
|
|
||
| _selectionStartInViewport.value = visibleRegion.contains(startOffset + effectiveOffset); | ||
| // Check if the caret is visible with an approximation because a difference |
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.
s/caret/handle right? I think the code in here all reference the blinking cursor as caret and the edges of a selection as handles.
| expect(editableText.cursorColor, const Color(0xFFF44336)); | ||
| }); | ||
|
|
||
| testWidgets('iOS shows selection carets', (WidgetTester tester) async { |
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.
'Shows' might be a bit optimistic :D since you're not fundamentally checking that it's visible.
Maybe iOS selection handles aren't faded away by default
| expect(topLeft.dx, equals(383)); // Should be same as equivalent in 'Caret center position' | ||
| }); | ||
|
|
||
| testWidgets('shows selection carets', (WidgetTester tester) async { |
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.
Check the verbiage of caret vs handle everywhere
HansMuller
left a comment
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.
LGTM
| // Check if the caret is visible with an approximation because a difference | ||
| // between rounded and unrounded values causes the caret to be reported as | ||
| // having a slightly (< 0.5) negative y offset. This rounding happens in | ||
| // paragraph.cc's layout and TextPainer's _applyFloatingPointHack. Ideally, |
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.
TextPainer => TextPainter
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.
Whoops 🎨
|
@GaryQian Thanks, that's what it looked like to me. I always saw <0.1 until I copied a weird case from the tests into an app where we render an EditableText directly and it was ~0.5. |
3307a9f to
405446e
Compare
Fix a bug where text selection handles were invisible in iOS
Issue: #31287
The selection handles in iOS don't show up anymore. It seems the cursor is being reported by the engine as being up out of the text input by ~-0.1, which with the recent PR to hide handles when they're out of the input, the handles are always hidden.
It seems to be caused by rounding in the engine and not something that can be reasonably fixed there, so my current POC solution just does a more fuzzy check on where the cursor is.
TODO