Skip to content

Conversation

@justinmc
Copy link
Contributor

@justinmc justinmc commented Apr 19, 2019

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

  • Test so this doesn't happen again.
  • Write a serious comment explaining the hackiness. Point to place in the engine specifically.

@justinmc justinmc self-assigned this Apr 19, 2019
@goderbauer goderbauer added framework flutter/packages/flutter repository. See also f: labels. platform-ios iOS applications specifically labels Apr 22, 2019
@justinmc justinmc force-pushed the invisible-ios-handles branch from 1d69883 to ad48e16 Compare April 22, 2019 17:41
@justinmc justinmc changed the title WIP iOS selection handles are invisible iOS selection handles are invisible Apr 22, 2019
Copy link
Contributor Author

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?

Copy link
Contributor

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".

Copy link
Contributor

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.

Copy link
Contributor Author

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?

Copy link
Contributor

@GaryQian GaryQian Apr 22, 2019

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.

Copy link
Contributor

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.

@justinmc justinmc requested review from GaryQian and HansMuller April 22, 2019 22:44
@justinmc justinmc force-pushed the invisible-ios-handles branch from 25f6fe8 to 777c755 Compare April 22, 2019 22:46
@GaryQian
Copy link
Contributor

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.

@HansMuller
Copy link
Contributor

Lets fix this problem with a fuzzy check now. It would be nice to be able tighten up the logic in the future.

@justinmc
Copy link
Contributor Author

@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

Copy link
Contributor

@HansMuller HansMuller left a 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)
Copy link
Contributor

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

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.

Copy link
Member

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));
Copy link
Contributor

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

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).
Copy link
Contributor

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?

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 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.

Copy link
Contributor

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

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

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.

Copy link
Contributor Author

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

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

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

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)

Copy link
Member

@xster xster left a 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
Copy link
Member

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?

Copy link
Contributor Author

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

Copy link
Member

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;
Copy link
Member

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];
Copy link
Member

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

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';
Copy link
Member

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(
Copy link
Member

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,
Copy link
Member

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

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 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.

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 const can not be used before type declarations?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Whoops, good catch.

@justinmc justinmc force-pushed the invisible-ios-handles branch from e3bf76a to 36389e3 Compare April 24, 2019 01:21
@justinmc
Copy link
Contributor Author

justinmc commented Apr 24, 2019

@GaryQian Can we be certain that the rounding error will always be smaller than -0.1? That's what I see experimentally, but I want to add some details in my comment if we know why #31332 (comment)

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...

@justinmc justinmc force-pushed the invisible-ios-handles branch 2 times, most recently from 62a9168 to 590678f Compare April 24, 2019 16:59
@justinmc justinmc force-pushed the invisible-ios-handles branch from 590678f to a6514b9 Compare April 24, 2019 17:03
@justinmc justinmc force-pushed the invisible-ios-handles branch from f4d32fd to ea0cf87 Compare April 24, 2019 22:15
@justinmc
Copy link
Contributor Author

Ready for re-review @HansMuller @xster

@GaryQian
Copy link
Contributor

GaryQian commented Apr 24, 2019

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.

Copy link
Member

@xster xster left a 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
Copy link
Member

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

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

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

Copy link
Contributor

@HansMuller HansMuller left a 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,
Copy link
Contributor

Choose a reason for hiding this comment

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

TextPainer => TextPainter

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Whoops 🎨

@justinmc
Copy link
Contributor Author

justinmc commented Apr 25, 2019

@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.

@justinmc justinmc force-pushed the invisible-ios-handles branch from 3307a9f to 405446e Compare April 25, 2019 18:24
@justinmc justinmc merged commit a21a1f4 into flutter:master Apr 25, 2019
@justinmc justinmc deleted the invisible-ios-handles branch April 25, 2019 19:32
Hixie pushed a commit to Hixie/flutter that referenced this pull request Apr 30, 2019
Fix a bug where text selection handles were invisible in iOS
Hixie added a commit that referenced this pull request Apr 30, 2019
Fix a bug where text selection handles were invisible in iOS
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 6, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

framework flutter/packages/flutter repository. See also f: labels. platform-ios iOS applications specifically

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants