Skip to content

anchors 5/n: Control scroll position #1436

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

Merged
merged 9 commits into from
Apr 24, 2025
Merged

Conversation

gnprice
Copy link
Member

@gnprice gnprice commented Mar 26, 2025

This is the next round after #1435 (and is stacked atop it), toward #82.

In this PR, we take more control of how the scroll position works in the presence of back-to-back slivers. In particular the ScrollView.anchor property doesn't really have enough flexibility to express the behavior we need, so we replace the logic that consumes it.

Once that's done, we're very close to being able to start splitting the real message list into back-to-back slivers. That will come in the next PR, #1468.

Selected commit messages

8702ad3 msglist [nfc]: Introduce MessageListScrollView, not yet doing anything different

92676fd scroll [nfc]: Copy RenderViewport.performLayout and friends from upstream

Some of the behavior we'd like to customize isn't currently cleanly
exposed to subclasses any more than it is to parent widgets passing
constructor arguments. In particular, we'll want to change a few
bits of logic in [RenderViewport.performLayout], replacing the
handling of the anchor field with something more flexible.

In order to do that, we'll start from a copy of that method, so that
we can edit the copy.

Then the base class's performLayout refers to a private helper
method _attemptLayout, so we need a copy of that too; and they
each refer to a number of private fields, so we need copies of
those too; and to make those work correctly, we need copies of all
the other members that refer to those fields, so that they're all
referring correctly to the same version of those fields (namely
the one on the subclass) rather than to a mix of the versions on
the base class and those on the subclass.

Fortunately, flood-filling that graph of members which refer to
private members, which are referred to by other members, etc.,
terminates with a connected component which is... not small, but a
lot smaller and less unwieldy than if we had to copy the whole
upstream file these are defined in.

96b81dc msglist [nfc]: Introduce MessageListScrollPosition

1667c6e scroll: Start out scrolled to bottom of list

This is NFC as to the real message list, because so far the bottom
sliver there always has height 0, so that maxScrollExtent is always 0.

This is a step toward letting us move part of the message list into
the bottom sliver, because it means that doing so would preserve the
list's current behavior of starting out scrolled to the end.

07a3905 scroll: Keep short list pinned at bottom of viewport, not past bottom

This is NFC as to the real message list, because so far the bottom
sliver there always has height 0.

Before this change, the user could always scroll up (moving the
content down) so that the bottom sliver was entirely off the bottom
of the viewport, even if that exposed blank space at the top of the
viewport because the top sliver was shorter than the viewport.
After this change, it's never in bounds to have part of the viewport
be blank for lack of content while there's content scrolled out of
the viewport at the other end.

This is a step toward letting us move part of the message list into
the bottom sliver, because it fixes a bug that would otherwise create
in the case where the top sliver fits entirely on the screen.

0f7bc7a scroll: Stay at end once there

This is NFC as to the real message list, because so far the bottom
sliver there always has height 0, so that both maxScrollExtent and
this.maxScrollExtent are always 0.

This is a step toward letting us move part of the message list into
the bottom sliver, because it means that doing so would preserve the
list's current behavior of remaining scrolled to the end once there
as e.g. new messages arrive.

@gnprice
Copy link
Member Author

gnprice commented Mar 27, 2025

Hooray for tests 🙂 — a pair of failed tests in CI:

ScrollToBottomButton interactions scrolling changes visibility
ScrollToBottomButton interactions button functionality

show that the scroll-to-bottom button doesn't work properly after the main turn-the-switch-on commit:
e5e7967 wip msglist: Split into back-to-back slivers; TODO test?

I noticed the second failure during normal use of the app today, having used flutter run --release to put a build of this PR on my phone.

Both of those will be straightforward to fix for this stage of things, where the bottom sliver is short and we start out basically scrolled to the end of it. The second one (the button's functionality) gets somewhat more complex in a future with #82 where we might be in the middle of a long history; but we'll figure that out when we come to it.


There's one other failed test, also at that commit:

_UnreadMarker animations animation state persistence

    testWidgets('animation state persistence', (tester) async {
      // Check that _UnreadMarker maintains its in-progress animation
      // as the number of items changes in MessageList. See
      // `findChildIndexCallback` passed into [SliverStickyHeaderList]
      // at [_MessageListState._buildListView].

I believe that test failure is saying that if the latest message was unread, and you mark it read; and then a moment later, while the animation for the unread marker to fade away is still going, a new message arrives; then because that pushes the existing message across the boundary from the lower sliver to the upper sliver, the animation is interrupted and the unread marker abruptly finishes vanishing.

That's an issue that actually only applies at this stage, and won't apply when #82 is complete: when that's done, new messages will get added to the end of the lower sliver, without displacing messages from there into the upper sliver.

I think this is a pretty tolerable glitch. For this PR, I might fix it by just having the target message in the test be the next-to-last message, so it's in the upper sliver from the beginning and doesn't trigger the issue.

After those other changes for #82, I guess the whole situation this is testing will become less common, because new messages won't trigger it at all: it'll only apply in less-common situations like a message getting deleted, or moved into or out of the narrow. To keep the test effective, we'll want to switch it to exercise one of those.


Anyway, so the last 2 commits aren't quite ready, even apart from the need to write tests. I think the 8 commits before them are, though (again apart from the need to write tests for some of them), in addition to the 6 commits that are in #1435.

@gnprice gnprice force-pushed the pr-scroll-position branch 3 times, most recently from 5c98640 to 6e03b9e Compare April 4, 2025 06:09
@gnprice
Copy link
Member Author

gnprice commented Apr 4, 2025

With today's revision, I believe all the commits are ready except for the one that enables the new scroll behavior for the actual message list:
26d2d3a WIP msglist: Split into back-to-back slivers; TODO test?; TODO scroll-to-end show, act; TODO weaken test on crossing slivers

In particular all the other commits, including the various behavior of MessageListScrollView, are now tested, and they've also had some other cleanups.

That main commit needs fixes for the issues mentioned in the previous comment, and should probably also get a test or two (though the detailed tests will be those that are on MessageListScrollView itself).

@gnprice gnprice force-pushed the pr-scroll-position branch from 6e03b9e to 0f7bc7a Compare April 5, 2025 02:09
@gnprice gnprice changed the title anchors 5/n: Control scroll position; start splitting slivers! anchors 5/n: Control scroll position Apr 5, 2025
@gnprice
Copy link
Member Author

gnprice commented Apr 5, 2025

OK, I've moved those last two commits into a follow-up PR: #1468.

This PR is now fully ready for review. The changes in #1468 may be helpful for manual testing.

@gnprice gnprice added the maintainer review PR ready for review by Zulip maintainers label Apr 5, 2025
@@ -645,7 +645,6 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat

controller: scrollController,
semanticChildCount: length + 2,
anchor: 1.0,
Copy link
Collaborator

@chrisbobbe chrisbobbe Apr 8, 2025

Choose a reason for hiding this comment

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

msglist [nfc]: Force anchor to 1.0

I'm trying to understand anchor; could you confirm that this part of RenderViewport.anchor's dartdoc is correct?

  /// […] If the [anchor] is 1.0, and the
  /// [axisDirection] is [AxisDirection.right], then the zero scroll offset is
  /// on the left edge of the viewport.

Copy link
Collaborator

Choose a reason for hiding this comment

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

There's a code example at the doc link; here's what it looks like with the code as-is, with AxisDirection.right and anchor 0.5:

image

And if I just change anchor to 1.0:

image

Copy link
Member Author

Choose a reason for hiding this comment

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

That sounds backward to me — I'd say that's true if you replace either "1.0" with "0.0", or ".right" with ".left", or "left edge" with "right edge". (Or all three.)

Copy link
Collaborator

Choose a reason for hiding this comment

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

That's helpful, thanks. I'm curious if this also confused you when you were first learning it :)

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 think I did run across that bit of docs recently and puzzled for a bit over whether there was a way to interpret it that made that description correct. (The wording "the zero scroll offset is on the left edge of the viewport" is itself a bit vague.)

I don't recall when I was first figuring out what anchor means. It appears in
bdac26f and d4490b1 from last January, so I must have had at least an empirical understanding by then.

@chrisbobbe
Copy link
Collaborator

Exciting! This looks fine to me; please merge at will.

@gnprice
Copy link
Member Author

gnprice commented Apr 18, 2025

It turns out there was actually a bug here, which I discovered while working on fixing scroll-to-bottom (for #1468): the MessageListScrollPosition class, once it starts keeping state of its own, needs an override of the absorb method. Pushing a revision that replaces this commit:
1667c6e scroll: Start out scrolled to bottom of list

with this one:
bf3af70 scroll: Start out scrolled to bottom of list

PTAL.

@gnprice gnprice force-pushed the pr-scroll-position branch from 0f7bc7a to a5c65a0 Compare April 18, 2025 22:01
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! LGTM, just one comment on the new test.

Comment on lines +240 to +302
// Then cause the ScrollableState to have didChangeDependencies called…
await tester.pumpWidget(
MediaQuery(data: MediaQueryData(devicePixelRatio: 2.0),
child: widget));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Instead of repeating details of a widget tree, with pumpWidget, could we set tester.view.devicePixelRatio and add a tester.view.resetDevicePixelRatio teardown?

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. Just tried that, though, and it makes the behavior more complicated because the test framework (reasonably) takes the physical size and the device pixel ratio as fields, and the size in logical pixels as derived from those. So changing just the ratio causes the size in logical pixels to change too, which would change the layout in more-complicated ways that aren't what this test is about.

Could probably make that work by also setting the physical size, but then I think that's making the test setup more complicated.

This version changes just the device pixel ratio reported to the inner widgets, and nothing else. That isn't a super realistic scenario (if the ratio changes, then in practice that probably usually goes with the physical size remaining the same, and the user just changed their system display settings), but I think that's fine. It's really a stand-in anyway for an arbitrary reason that might cause the scroll position to get replaced:

      // Tests that [MessageListScrollPosition.absorb] does its job.
      //
      // In the app, this situation can be triggered by changing the device's
      // theme between light and dark.  For this simplified example for a test,
      // go for devicePixelRatio (which ScrollableState directly depends on).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Makes sense; thanks!

gnprice added 9 commits April 23, 2025 17:35
…bottom

This `i == 1` condition was never true, because in that situation the
caller would build a MarkAsReadWidget instead of calling this method.

This 8px vs. 11px distinction dates back to the prototype: 731b199
made it 8px instead of 0px, and the distinction itself goes back to
the commit
  9916194 msglist: Start on rendering messages
in the prototype's first hours.

The logic that drove it, though, became fragile with e7fe06c which
changed it from "i == 0" (the end of the list, OK that's fairly
canonical as a special value) to "i == 1" (more arbitrary).  So then
it naturally got broken a little later, in 56ab395 in 2023-11, and
it's been broken ever since: we just always show 11px of padding here.

We might further change the layout in the future, but if we do we'll
fix it forward starting from the behavior the app has already had for
over a year.
…ream

Some of the behavior we'd like to customize isn't currently cleanly
exposed to subclasses any more than it is to parent widgets passing
constructor arguments.  In particular, we'll want to change a few
bits of logic in [RenderViewport.performLayout], replacing the
handling of the `anchor` field with something more flexible.

In order to do that, we'll start from a copy of that method, so that
we can edit the copy.

Then the base class's `performLayout` refers to a private helper
method `_attemptLayout`, so we need a copy of that too; and they
each refer to a number of private fields, so we need copies of
those too; and to make those work correctly, we need copies of all
the other members that refer to those fields, so that they're all
referring correctly to the same version of those fields (namely
the one on the subclass) rather than to a mix of the versions on
the base class and those on the subclass.

Fortunately, flood-filling that graph of members which refer to
private members, which are referred to by other members, etc.,
terminates with a connected component which is... not small, but a
lot smaller and less unwieldy than if we had to copy the whole
upstream file these are defined in.
We'll rely on this assumption to simplify some upcoming
customizations.
This is NFC as to the real message list, because so far the bottom
sliver there always has height 0, so that maxScrollExtent is always 0.

This is a step toward letting us move part of the message list into
the bottom sliver, because it means that doing so would preserve the
list's current behavior of starting out scrolled to the end.
This is NFC as to the real message list, because so far the bottom
sliver there always has height 0.

Before this change, the user could always scroll up (moving the
content down) so that the bottom sliver was entirely off the bottom
of the viewport, even if that exposed blank space at the top of the
viewport because the top sliver was shorter than the viewport.
After this change, it's never in bounds to have part of the viewport
be blank for lack of content while there's content scrolled out of
the viewport at the other end.

This is a step toward letting us move part of the message list into
the bottom sliver, because it fixes a bug that would otherwise create
in the case where the top sliver fits entirely on the screen.
This is NFC as to the real message list, because so far the bottom
sliver there always has height 0, so that both maxScrollExtent and
this.maxScrollExtent are always 0.

This is a step toward letting us move part of the message list into
the bottom sliver, because it means that doing so would preserve the
list's current behavior of remaining scrolled to the end once there
as e.g. new messages arrive.
@gnprice gnprice force-pushed the pr-scroll-position branch from a5c65a0 to f20dfb7 Compare April 24, 2025 00:37
@gnprice
Copy link
Member Author

gnprice commented Apr 24, 2025

Thanks for the review! Merging.

@gnprice gnprice merged commit f20dfb7 into zulip:main Apr 24, 2025
1 check passed
@gnprice gnprice deleted the pr-scroll-position branch April 24, 2025 00: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