Skip to content
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

anchors 3/n: Fix hit-testing when header overflows sliver #1316

Merged
merged 19 commits into from
Feb 14, 2025

Conversation

gnprice
Copy link
Member

@gnprice gnprice commented Feb 1, 2025

This is the next round after #1312 (and is stacked atop #1312), fixing another cluster of latent bugs in the sticky_header library to prepare for having two slivers share space back to back in the list.

After #1312, the headers paint correctly in that case. But when the sliver boundary is scrolled to within the header, the hit-testing behavior isn't yet right: trying to tap on the bottom part of the header, where it's overflowing over the bottom sliver, ends up hitting the bottom sliver instead of the header. This PR fixes that.

… Well, it does if the viewport gives the slivers the right paint order (and hit-test order). Making that happen with back-to-back slivers, like the message list needs, requires some more code; and this PR is long enough already. So we'll save that for the next PR.

Selected commit messages

54c2b68 sticky_header example: Enable ink splashes, to demo hit-testing

2c3a6ea sticky_header example: Set allowOverflow true in double-sliver example

421415b sticky_header [nfc]: Fix childMainAxisPosition to handle paintOrigin nonzero

This fixes a latent bug: this method would give wrong answers if the
sliver's paintOrigin were nonzero. See the new comments.

The bug is latent because performLayout currently always produces a
zero paintOrigin. But we'll start using paintOrigin soon, as part of
making hit-testing work correctly when a sticky header is painted by
one sliver but needs to encroach on the layout area of another sliver.
The framework calls this method as part of hit-testing, so that
requires fixing this bug too.

a76ba67 sticky_header: Fix hit-testing when header overflows sliver

When the sticky header overflows the sliver that provides it -- that
is, when the sliver boundary is scrolled to within the area the
header covers -- the existing code already got the right visual
result, painting the header at its full size.

But it didn't work properly for hit-testing: trying to tap the
header in the portion where it's overflowing wouldn't work, and
would instead go through to whatever's underneath (like the top of
the next sliver). That's because the geometry it was reporting from
this performLayout method didn't reflect the geometry it would
actually paint in the paint method. When hit-testing, that
reported geometry gets interpreted by the framework code before
calling this render object's other methods.

Fix that by reporting an accurate paintOrigin and paintExtent.

After this fix, sticky headers overflowing into the next sliver
seem to work completely correctly... as long as the viewport paints
the slivers in the necessary order. We'll take care of that next.

90e4b5f sticky_header [nfc]: Doc overflow behavior and paint-order constraints

@gnprice gnprice added the maintainer review PR ready for review by Zulip maintainers label Feb 1, 2025
@gnprice gnprice force-pushed the pr-sticky-hittest branch 2 times, most recently from 9ec7683 to 0770222 Compare February 3, 2025 22:00
@chrisbobbe
Copy link
Collaborator

Hmm, CI is failing.

@gnprice
Copy link
Member Author

gnprice commented Feb 4, 2025

Yeah, in the android suite. That's happening to all PRs at the moment — chat thread:
https://chat.zulip.org/#narrow/channel/516-mobile-dev-help/topic/Execution.20failed.20for.20task.20'.3Aapp.3AcompileDebugKotlin'/near/2076887

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

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

Thanks! One comment below about some things I'm not understanding yet.

Comment on lines 711 to 715
// Then the [childMainAxisPosition] spec wants a location relative to the
// "leading _visible_ edge" of this sliver; and "visible" means paint,
// not layout. The leading visible edge, expressed again in the sliver's
// layout coordinates, is at `paintOrigin`.
return fromScrollOffset - geometry!.paintOrigin;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm having a hard time understanding upstream's SliverGeometry.layoutExtent. Its dartdoc is written carelessly; it assumes there are multiple slivers—multiple visible slivers:

The distance from the first visible part of this sliver to the first visible part of the next sliver

It's possible that there are multiple slivers (an important case for us), even multiple visible slivers, but I feel a little frustrated that it starts from that as an assumption, and it's not yet easy for me to guess what they really meant.

Then could you please expand on this part a bit?:

"visible" means paint, not layout

Is there an upstream doc that can help clarify this distinction? SliverConstraints.scrollOffset, SliverPhysicalParentData.paintOffset, SliverGeometry.layoutExtent (quoted above), and this RenderSliverMultiBoxAdaptor.childMainAxisPosition all use this word "visible" and I don't yet see this distinction drawn in any of those docs, even when the word appears in italics.

I guess one specific question: When the constraints.growthAxisDirection is right or down, fromScrollOffset comes directly from a "paint offset" value which doesn't sound like it's about layout. Why is this new adjustment needed in those two cases?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm. Yeah, that layoutExtent doc sure seems to be using "visible" to refer to layout, not paint, doesn't it. :-)

Maybe it'd be more accurate to say that the sliver docs use "visible" ambiguously to mean either layout or paint, and it contrasts only with "outside the viewport". Rereading the childMainAxisPosition doc, when it says "the leading visible edge" (complete with italics), that may well be the only contrast it means.

I'll expand the comment to say why for this method the intended semantics are about paint, not layout.

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess one specific question: When the constraints.growthAxisDirection is right or down, fromScrollOffset comes directly from a "paint offset" value which doesn't sound like it's about layout. Why is this new adjustment needed in those two cases?

Hmm, that does sound suspicious.

… And I think it points to a bug! If I adjust the example app so that the headers go at bottom instead of top (and also set center to adjust the paint order, so that now the bottom sliver paints last, as it needs to in that case):

--- lib/example/sticky_header.dart
+++ lib/example/sticky_header.dart
@@ -143,5 +143,6 @@ class ExampleVerticalDouble extends StatelessWidget {
         semanticChildCount: numSections,
+        center: const ValueKey('center'),
         slivers: [
           SliverStickyHeaderList(
-            headerPlacement: HeaderPlacement.scrollingStart,
+            headerPlacement: HeaderPlacement.scrollingEnd,
             delegate: SliverChildBuilderDelegate(
@@ -160,3 +161,4 @@ class ExampleVerticalDouble extends StatelessWidget {
           SliverStickyHeaderList(
-            headerPlacement: HeaderPlacement.scrollingStart,
+            key: const ValueKey('center'),
+            headerPlacement: HeaderPlacement.scrollingEnd,
             delegate: SliverChildBuilderDelegate(

then the painting and hit-testing don't work out correctly if I scroll to a point where the sticky header (now at the bottom) overflows the bottom sliver onto the top sliver.

I'll see about fixing that. Thanks for the careful reading.

@gnprice
Copy link
Member Author

gnprice commented Feb 12, 2025

OK, pushed a new revision. In order to have a better shot at getting it right this time, I added another example, and a new swath of systematic test cases (another layer in that nested loop in sticky_header_test.dart). I also refactored the performLayout method a bit more to try to make it cleaner to think through.

Selected commit messages, of new or revised commits:

1a1c501 sticky_header test [nfc]: Make "first/last item" finders more robust

The existing .first and .last versions rely on the order that
children appear in the Flutter element tree.

As is, that works because the _Item widgets are all children of one
list sliver, and it manages its children in a nice straightforward
order. But when we add multiple list slivers, the order will depend
also on the order those appear as children of the viewport; and in
particular the items at the edges of the viewport will no longer
always be the first or last items in the tree. So introduce a couple
of custom finders to keep finding the first or last items in the
sense we mean.

For examples of using the APIs these finders use, compare the
implementation of [FinderBase.first].

1907fba sticky_header test: Test slivers splitting viewport

There are still some bugs affecting the sticky_header library when a
SliverStickyHeaderList occupies only part of the viewport (which is
a configuration we'll need for letting the message list grow in both
directions, for #82).

I sent a PR which aimed to fix a cluster of those, in which I tried
to get away without writing these systematic test cases for them.
It worked for the cases I did test -- including the cases that would
actually arise for the Zulip message list -- and I believed the
changes were correct when I sent the PR. But that version was still
conceptually confused, as evidenced by the fact that it turned out
to break other cases:
#1316 (comment)

So that seems like a sign that this really should get systematic
all-cases tests.

Some of these new test cases don't yet work properly, because they
exercise the aforementioned bugs.

The "header overflowing sliver" skip condition will be removed later
in this series.

The "paint order" skips will be addressed in an upcoming PR.

2b75ad8 sticky_header [nfc]: Fix childMainAxisPosition to properly use paintExtent

This fixes a latent bug: this method would give wrong answers if the
sliver's paintExtent differed from its layoutExtent.

The bug is latent because performLayout currently always produces a
layoutExtent equal to paintExtent. But we'll start making them differ
soon, as part of making hit-testing work correctly when a sticky
header is painted by one sliver but needs to encroach on the layout
area of another sliver. The framework calls this method as part of
hit-testing, so that requires fixing this bug too.

5e774ec sticky_header [nfc]: Split header-overflows-sliver condition explicitly

This makes for fewer situations to think about at a given point in
the code, and will make the logic a bit easier to follow when we
make some corrections to the overflow case.

229434a sticky_header [nfc]: Expand on the header-overflows-sliver case

This case has several bugs in it. Not coincidentally, it's tricky to
think through: there are several sub-cases and variables involved
(the growth direction, vs. the header-placement direction, vs. the
coordinate direction, ...). And in fact my original PR revision
which fixed the cases that would affect the Zulip message list
was still conceptually confused, as evidenced by the fact that it
turned out to break other cases:
#1316 (comment)

One step in sorting that out was the preceding commit which split
this overflows-sliver case from the alternative. As a next step,
let's expand on the reasoning here a bit, with named variables
and comments. In doing so, it becomes more apparent that several
points in this calculation are wrong; for this NFC commit, mark
those with TODO-comments. We'll fix them shortly.

7b9ff0d sticky_header: Fix hit-testing when header overflows sliver

When the sticky header overflows the sliver that provides it -- that
is, when the sliver boundary is scrolled to within the area the
header covers -- the existing code already got the right visual
result, painting the header at its full size.

But it didn't work properly for hit-testing: trying to tap the
header in the portion where it's overflowing wouldn't work, and
would instead go through to whatever's underneath (like the top of
the next sliver). That's because the geometry it was reporting from
this performLayout method didn't reflect the geometry it would
actually paint in the paint method. When hit-testing, that
reported geometry gets interpreted by the framework code before
calling this render object's other methods.

Specifically, this sliver was reporting to its parent that its
paint region (described by paintOrigin and paintExtent) was
the same as the child sliver's paint region. In reality, this
sliver in this case will paint a larger region than that.

Fix by accurately reporting this sliver's paint region (via
paintOrigin and paintExtent), while adjusting headerOffset
to be relative to the new truthful paint region rather than the
old inaccurate one.

This fix lets us mark a swath of test cases as no longer skipped.

On the other hand, this change introduces a different bug in this
overflow case: the child sliver is now painted in the wrong place if
_headerAtCoordinateEnd(). (It gets lined up with the inner edge of
the header, even though it's too short to reach the viewport edge
from there.) That bug is latent as far as the Zulip app is concerned,
so we leave it for now with a TODO.

Other than that, after this fix, sticky headers overflowing into
the next sliver seem to work completely correctly... as long as
the viewport paints the slivers in the necessary order. We'll
take care of that in an upcoming PR.

@chrisbobbe
Copy link
Collaborator

chrisbobbe commented Feb 12, 2025

Great! Thanks; I appreciate your careful response to the point I raised in my last review. Thanks also for answering my questions in the office.

LGTM, please merge at will.

This is the class we actually use at this point -- not
StickyHeaderListView -- so it's good for it to have some docs too.
This way the example can be used to demonstrate the next cluster
of bug fixes working correctly, before yet fixing the next issue
after those.
This is nearly NFC.  The sense in which it isn't is that if any of
the `tester.drag` steps touched a header -- which listens for tap
gestures -- then this would ensure that step got interpreted as a
drag.  The old version would (a) interpret it as a tap, not a drag,
if it was less than 20px in length; (b) leave those first 20px out
of the effective length of the drag.

The use of DragStartBehavior.down fixes (b).  Then there are some
drags of 5px in these tests, subject to (a); TouchSlop fixes those
(by reducing the touch slop to 1px, instead of 20px).

This change will be needed in order to have the item widgets start
recording taps too, like the header widgets do, without messing up
the drags in these tests.
This will be useful in a test we'll add for an upcoming change.
These changes are NFC for the existing double-sliver example,
with the sticky header at the top.
The existing `.first` and `.last` versions rely on the order that
children appear in the Flutter element tree.

As is, that works because the `_Item` widgets are all children of one
list sliver, and it manages its children in a nice straightforward
order.  But when we add multiple list slivers, the order will depend
also on the order those appear as children of the viewport; and in
particular the items at the edges of the viewport will no longer
always be the first or last items in the tree.  So introduce a couple
of custom finders to keep finding the first or last items in the
sense we mean.

For examples of using the APIs these finders use, compare the
implementation of [FinderBase.first].
This will be helpful for keeping complexity down when we add more
slivers to this list.
This is still enough to fill more than the viewport, and will be more
helpful for scrolling past an entire sliver when we add more slivers.
There are still some bugs affecting the sticky_header library when a
SliverStickyHeaderList occupies only part of the viewport (which is
a configuration we'll need for letting the message list grow in both
directions, for zulip#82).

I sent a PR which aimed to fix a cluster of those, in which I tried
to get away without writing these systematic test cases for them.
It worked for the cases I did test -- including the cases that would
actually arise for the Zulip message list -- and I believed the
changes were correct when I sent the PR.  But that version was still
conceptually confused, as evidenced by the fact that it turned out
to break other cases:
  zulip#1316 (comment)

So that seems like a sign that this really should get systematic
all-cases tests.

Some of these new test cases don't yet work properly, because they
exercise the aforementioned bugs.

The "header overflowing sliver" skip condition will be removed later
in this series.

The "paint order" skips will be addressed in an upcoming PR.
…xtent

This fixes a latent bug: this method would give wrong answers if the
sliver's paintExtent differed from its layoutExtent.

The bug is latent because performLayout currently always produces a
layoutExtent equal to paintExtent.  But we'll start making them differ
soon, as part of making hit-testing work correctly when a sticky
header is painted by one sliver but needs to encroach on the layout
area of another sliver.  The framework calls this method as part of
hit-testing, so that requires fixing this bug too.
This makes for fewer situations to think about at a given point in
the code, and will make the logic a bit easier to follow when we
make some corrections to the overflow case.
This commit is NFC for the actual app, or at least nearly so.

This call to calculatePaintOffset was conceptually wrong: it's
asking how much of this sliver's region to be painted is within the
range of scroll offsets from zero to headerExtent.  That'd be a
pertinent question if we were locating something in that range of
scroll offsets... but that range is not at all where the header
goes, unless by happenstance.  So the value returned is meaningless.

One reason this buggy line has survived is that the bug is largely
latent -- we can remove it entirely, as in this commit, and get
exactly the same behavior except in odd circumstances.
Specifically:
 * This paintedHeaderSize variable can only have any effect
   by being greater than childExtent.
 * In this case childExtent is smaller than headerExtent, too.
 * The main way that childExtent can be so small is if
   remainingPaintExtent, which constrains it, is equally small.
 * But calculatePaintOffset constrains its result, aka
   paintedHeaderSize, to at most remainingPaintExtent too,
   so then paintedHeaderSize still won't exceed childExtent.

I say "main way" because the alternative is for the child to run out
of content before finding as much as headerExtent of content to
show.  That could happen if the list just has less than that much
content; but that means the header's own item is smaller than the
header, which is a case that sticky_header doesn't really support
well anyway and we don't have in the app.  Otherwise, this would
have to mean that some of the content was scrolled out of the
viewport and then the child ran out of content before filling its
allotted remainingPaintExtent of the viewport (and indeed before
even reaching a headerExtent amount of content).  This is actually
not quite impossible, if the scrollable permits overscroll... but
making it happen would require piling edge case upon edge case.

Anyway, this call never made sense, so remove it.

The resulting code in this headerExtent > childExtent case still
isn't right.  Removing this wrong logic helps clear the ground for
fixing that.
This case has several bugs in it.  Not coincidentally, it's tricky to
think through: there are several sub-cases and variables involved
(the growth direction, vs. the header-placement direction, vs. the
coordinate direction, ...).  And in fact my original PR revision
which fixed the cases that would affect the Zulip message list
was still conceptually confused, as evidenced by the fact that it
turned out to break other cases:
  zulip#1316 (comment)

One step in sorting that out was the preceding commit which split
this overflows-sliver case from the alternative.  As a next step,
let's expand on the reasoning here a bit, with named variables
and comments.  In doing so, it becomes more apparent that several
points in this calculation are wrong; for this NFC commit, mark
those with TODO-comments.  We'll fix them shortly.
When the sticky header overflows the sliver that provides it -- that
is, when the sliver boundary is scrolled to within the area the
header covers -- the existing code already got the right visual
result, painting the header at its full size.

But it didn't work properly for hit-testing: trying to tap the
header in the portion where it's overflowing wouldn't work, and
would instead go through to whatever's underneath (like the top of
the next sliver).  That's because the geometry it was reporting from
this `performLayout` method didn't reflect the geometry it would
actually paint in the `paint` method.  When hit-testing, that
reported geometry gets interpreted by the framework code before
calling this render object's other methods.

Specifically, this sliver was reporting to its parent that its
paint region (described by `paintOrigin` and `paintExtent`) was
the same as the child sliver's paint region.  In reality, this
sliver in this case will paint a larger region than that.

Fix by accurately reporting this sliver's paint region (via
`paintOrigin` and `paintExtent`), while adjusting `headerOffset`
to be relative to the new truthful paint region rather than the
old inaccurate one.

This fix lets us mark a swath of test cases as no longer skipped.

On the other hand, this change introduces a different bug in this
overflow case: the child sliver is now painted in the wrong place if
_headerAtCoordinateEnd().  (It gets lined up with the inner edge of
the header, even though it's too short to reach the viewport edge
from there.)  That bug is latent as far as the Zulip app is concerned,
so we leave it for now with a TODO.

Other than that, after this fix, sticky headers overflowing into
the next sliver seem to work completely correctly... as long as
the viewport paints the slivers in the necessary order.  We'll
take care of that in an upcoming PR.
@gnprice gnprice merged commit 043ae10 into zulip:main Feb 14, 2025
1 check passed
@gnprice
Copy link
Member Author

gnprice commented Feb 14, 2025

Thanks! Merged.

@gnprice gnprice deleted the pr-sticky-hittest branch February 14, 2025 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
maintainer review PR ready for review by Zulip maintainers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants