Repository: zulip/zulip-flutter (language: dart)

Issue:
Gap in message list
There's a symptom I've seen happen just a couple of times, and just now while working on the sticky-header code (for #82) I realized what's causing it and how to fix it.

Repro recipe:
* Scroll up in history, a screenful or two past some message that you sent and can still edit.
* From a different client, edit that message to be much taller than it was.
* Scroll back down quickly, say by using the scroll-to-end button.

The result might look like this:
| Actual | Expected |
|--------|--------|
| ![Image](https://github.com/user-attachments/assets/9d2040fa-8071-4420-be2d-3a7e683b8e64) | ![Image](https://github.com/user-attachments/assets/e2b084b2-545a-4648-964c-049b95c1b432) | 

Note the big gap above the "3" message; the recipient header at the very bottom of the list; and the recipient header out of order relative to the message (it has the wrong text for any later message).

Other times the gap can be smaller, like the height of a short message.

I saw a similar gap as part of #725. It's possible the cause of this issue was also the root cause of #725.

### Diagnosis

The cause of the issue is that the sticky-header implementation isn't properly handling one aspect of the sliver layout protocol. Specifically, when `_RenderSliverStickyHeaderListInner.performLayout` produces a `SliverGeometry` with `scrollOffsetCorrection` non-null, `_RenderSliverStickyHeaderList.performLayout` is swallowing that information instead of passing it on to its parent.

I'll send a fix as part of the sticky-headers PR I'm developing for #82.

Freeze in scrolling message list, null parentData.layoutOffset
Using the app today, I was scrolling in the message list and then it froze — stopped responding to my attempts to scroll.

I went and connected the phone to my desktop and looked at logcat. It shows the following stack trace:
```
Null check operator used on a null value
#0      _RenderSliverStickyHeaderListInner._findChildAtEnd (package:zulip/widgets/sticky_header.dart:719)
#1      _RenderSliverStickyHeaderListInner.performLayout (package:zulip/widgets/sticky_header.dart:740)
#2      RenderObject.layout (package:flutter/src/rendering/object.dart:2577)
#3      _RenderSliverStickyHeaderList.performLayout (package:zulip/widgets/sticky_header.dart:556)
#4      RenderObject.layout (package:flutter/src/rendering/object.dart:2577)
#5      RenderViewportBase.layoutChildSequence (package:flutter/src/rendering/viewport.dart:601)
#6      RenderViewport._attemptLayout (package:flutter/src/rendering/viewport.dart:1497)
#7      RenderViewport.performLayout (package:flutter/src/rendering/viewport.dart:1427)
#8      RenderObject._layoutWithoutResize (package:flutter/src/rendering/object.dart:2416)
#9      PipelineOwner.flushLayout (package:flutter/src/rendering/object.dart:1052)
#10     PipelineOwner.flushLayout (package:flutter/src/rendering/object.dart:1065)
#11     RendererBinding.drawFrame (package:flutter/src/rendering/binding.dart:602)
#12     WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1164)
#13     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:468)
#14     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1392)
#15     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1313)
#16     SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1171)
#17     _invoke (dart:ui/hooks.dart:312)
#18     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:419)
#19     _drawFrame (dart:ui/hooks.dart:283)
```

Then over time after that there's this error, again and again many times:
```
Another exception was thrown: Instance of 'DiagnosticsProperty<void>'
```
There's a fresh burst of those any time I touch the screen and try to scroll, or when 

---

The stack trace shows the exception is from our `sticky_header` library. The null value in question seems to be the `parentData.layoutOffset` below, quoting some code ending on the line that has the `!` that blew up:
```dart
    RenderBox? child;
    for (child = lastChild; ; child = childBefore(child)) {
      if (child == null) {
        // Ran out of children.
        return null;
      }
      final parentData = child.parentData! as SliverMultiBoxAdaptorParentData;
      assert(parentData.layoutOffset != null);
      if (endOffset > parentData.layoutOffset! + child.size.onAxis(constraints.axis)) {
```

So this function is assuming that the children have their `parentData` all set up, including `layoutOffset`, but there's a child that doesn't have that. I haven't debugged further.

---

Further observations from the context and symptoms:
* The app had been open a while earlier, showing the message list in question (namely "Combined feed"). Then it was in the background and I re-opened it.
  * Probably it was over 10 minutes, so the app would have re-loaded data from scratch.
  * It's possible that the immediate trigger for the bug was when the new data arrived and we swapped out the PerAccountStore. That causes the message list to go back to being empty and re-fetch its messages from scratch (#464).
* When the freeze happened, I was scrolled up a few screenfuls in the history.
* The visible state on the screen was a bit funny-looking: mostly full of messages as normal, but with a gray gap at the bottom.
* The app didn't crash or completely stop. As new messages subsequently come in, the message list keeps updating to show new messages. (At some point it jumped to the bottom of the history.)
  * The visible state continues to look funny with variations on the original: mostly full of messages, but with a gray gap somewhere between them.
  * Specifically at the moment it looks like one of the recipient headers, the one for the 4th out of 5 messages currently visible, is replaced with a gray gap that's about the height the recipient header should be. (I compared with the same narrow on web to see that there's no messages missing.)
  * At another time there was a much wider gray gap, immediately after a recipient header. So perhaps a message itself was being replaced with a gray gap. … OK, and now I'm seeing that again, and indeed it's one message missing. … And now again, and it's a message missing which isn't immediately after a recipient header (it's between two messages).
* Although it's updating in response to server events, nothing I do when I try to interact with the message list has any effect: not scrolling (as mentioned), nor touching a link, nor a recipient header, nor an image. They all just trigger another `Another exception was thrown: Instance of 'DiagnosticsProperty<void>'` in the log.
* After all those observations, I touched the back button at top left of the app, and it worked immediately. Then I navigated back into "Combined feed", and it's working normally.


So my best guess for how to potentially reproduce the issue is:
* Scroll up a few screenfuls in history.
* Have the `PerAccountState` get swapped out for a new one. (To do this without waiting 10 minutes, one can fiddle with the retry logic in `UpdateMachine.poll`.)

---

(Meanwhile the "Instance of 'DiagnosticsProperty<void>'" error messages are a bit of a mystery. Those seem like there's probably a bug in error-reporting somewhere, which would be good to fix in order to better enable debugging other bugs, like this one.)



Discussion hints:

Moving this to the post-launch milestone because it's only ever happened the one time as far as I know, and it's been a couple of months since then.

If we see it another time or two, or hear a user report of it, we can bump it back up in priority.

Pull request: anchors 2/n: Fix three sticky-header bugs
Fixes #1309.
Fixes #725. (which is the same underlying bug)

In addition to that live bug in the app (involving scrollOffsetCorrection, which we previously weren't handling), the last two commits in this branch fix a couple of bugs in the `sticky_header` library that were latent bugs in the app — they can only trigger once we start having two slivers share space in the list. That's a scenario we'll need as part of #80 / #82, as seen for example in #1169 (fyi @rajveermalviya).

Specifically, when both slivers are visible, the version in main
* will fail to show the sticky header for the top sliver;
* and will show a sticky header for the bottom sliver.

After this PR, there's one remaining bug I'm aware of that affects sticky headers in the presence of multiple slivers: ~~if the top sliver should show a header, but the header doesn't fit within the sliver's share of the viewport, then the header will get pushed off screen even if it has `allowOverflow` true. Concretely for the message list, this means that the last message in the top sliver will fail to share its recipient header with the first message in the bottom sliver, even if they're meant to share a recipient header.~~

**edit:** @chrisbobbe showed me the current symptom is a bit different from this. Rather than get pushed off screen, the header will have its bottom part covered up by the bottom sliver. The fix doesn't change, though.

I have a draft fix for that bug too, and I believe it works, but it still needs tests and a bit of other polishing-up. So it'll come as a separate PR later.

This is the long-awaited followup to #496, from a year ago this week. The two fixes here involving multiple slivers, as well as the upcoming fix coming in another PR, were all in the draft branch I had then. The scrollOffsetCorrection fix (which fixes the two live bugs) is new today, after I took a fresh look last week at some of the underlying framework code as part of trying to really understand what this `performLayout` method needs to do clearly enough to get to an implementation I'm confident is correct.


# Selected commit messages

#### 4b290452b sticky_header: Add example app

This is adapted lightly from an example app I made back in the first
week of the zulip-flutter project, 2022-12-23, as part of developing
the sticky_header library.

The example app was extremely helpful then for experimenting with
changes and seeing the effects visually and interactively, as well as
for print-debugging such experiments.  So let's get it into the tree.

The main reason I didn't send the example app back then is that it
was a whole stand-alone app tree under example/, complete with all
the stuff in android/ and ios/ and so on that `flutter create` spits
out for setting up a Flutter app.  That's pretty voluminous: well
over 100 different files totalling about 1.1MB on disk.  I did't
want to permanently burden the repo with all that, nor have to
maintain it all over time.

Happily, I realized today that we can skip that, and still have a
perfectly good example app, by reusing that infrastructure from the
actual Zulip app.  That way all we need is a Dart file with a `main`
function, corresponding to the old example's `lib/main.dart` which
was the only not-from-the-template code in the whole example app.

So here it is.  Other than moving the Dart file and discarding the
rest, the code I wrote back then has been updated to our current
formatting style; adjusted slightly for changes in Flutter's
Material widgets; and updated for changes I made to the
sticky_header API after that first week.


#### 3de58fe3e sticky_header [nfc]: Add asserts from studying possible child geometry; note a bug

The logic below -- particularly in the allowOverflow true case,
where it constructs a new SliverGeometry from scratch -- has been
implicitly relying on several of these facts already.  These
wouldn't be true of an arbitrary sliver child; but this sliver knows
what type of child it actually has, and they are true of that one.
So write down the specific assumptions we can take from that.

Reading through [RenderSliverList.performLayout] to see what it can
produce as the child geometry also made clear there's another case
that this method isn't currently correctly handling at all: the case
where scrollOffsetCorrection is non-null.  Filed an issue for that;
add a todo-comment for it.


#### 075a8cd79 sticky_header: Handle scrollOffsetCorrection

Fixes #1309.
Fixes #725.

This is necessary when scrolling back to the origin over items that
grew taller while off screen (and beyond the 250px of near-on-screen
cached items).  For example that can happen because a message was
edited, or because new messages came in that were taller than those
previously present.


#### de2006098 sticky_header: Fix _findChildAtEnd when viewport partly consumed already

In particular this will affect the upper sliver (with older messages)
in the message list, when we start using two slivers there in earnest.


#### 7583bb4b0 sticky_header: Avoid header at sliver/sliver boundary




Test command: flutter test test/widgets/sticky_header_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart
new file mode 100644
index 0000000000..3fa2185c1a
--- /dev/null
+++ b/lib/example/sticky_header.dart
@@ -0,0 +1,428 @@
+/// Example app for exercising the sticky_header library.
+///
+/// This is useful when developing changes to [StickyHeaderListView],
+/// [SliverStickyHeaderList], and [StickyHeaderItem],
+/// for experimenting visually with changes.
+///
+/// To use this example app, run the command:
+///     flutter run lib/example/sticky_header.dart
+/// or run this file from your IDE.
+///
+/// One inconvenience: this means the example app will use the same app ID
+/// as the actual Zulip app.  The app's data remains untouched, though, so
+/// a normal `flutter run` will put things back as they were.
+/// This inconvenience could be fixed with a bit more work: we'd use
+/// `flutter run --flavor`, and define an Android flavor in build.gradle
+/// and an Xcode scheme in the iOS build config
+/// so as to set the app ID differently.
+library;
+
+import 'package:flutter/material.dart';
+
+import '../widgets/sticky_header.dart';
+
+/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a
+/// vertically-scrolling list.
+class ExampleVertical extends StatelessWidget {
+  ExampleVertical({
+    super.key,
+    required this.title,
+    this.reverse = false,
+    this.headerDirection = AxisDirection.down,
+  }) : assert(axisDirectionToAxis(headerDirection) == Axis.vertical);
+
+  final String title;
+  final bool reverse;
+  final AxisDirection headerDirection;
+
+  @override
+  Widget build(BuildContext context) {
+    final headerAtBottom = axisDirectionIsReversed(headerDirection);
+
+    const numSections = 100;
+    const numPerSection = 10;
+    return Scaffold(
+      appBar: AppBar(title: Text(title)),
+
+      // Invoke StickyHeaderListView the same way you'd invoke ListView.
+      // The constructor takes the same arguments.
+      body: StickyHeaderListView.separated(
+        reverse: reverse,
+        reverseHeader: headerAtBottom,
+        itemCount: numSections,
+        separatorBuilder: (context, i) => const SizedBox.shrink(),
+
+        // Use StickyHeaderItem as an item widget in the ListView.
+        // A header will float over the item as needed in order to
+        // "stick" at the edge of the viewport.
+        //
+        // You can also include non-StickyHeaderItem items in the list.
+        // They'll behave just like in a plain ListView.
+        //
+        // Each StickyHeaderItem needs to be an item directly in the list, not
+        // wrapped inside other widgets that affect layout, in order to get
+        // the sticky-header behavior.
+        itemBuilder: (context, i) => StickyHeaderItem(
+          header: WideHeader(i: i),
+          child: Column(
+            verticalDirection: headerAtBottom
+              ? VerticalDirection.up : VerticalDirection.down,
+            children: List.generate(
+              numPerSection + 1, (j) {
+                if (j == 0) return WideHeader(i: i);
+                return WideItem(i: i, j: j-1);
+              })))));
+  }
+}
+
+/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a
+/// horizontally-scrolling list.
+class ExampleHorizontal extends StatelessWidget {
+  ExampleHorizontal({
+    super.key,
+    required this.title,
+    this.reverse = false,
+    required this.headerDirection,
+  }) : assert(axisDirectionToAxis(headerDirection) == Axis.horizontal);
+
+  final String title;
+  final bool reverse;
+  final AxisDirection headerDirection;
+
+  @override
+  Widget build(BuildContext context) {
+    final headerAtRight = axisDirectionIsReversed(headerDirection);
+    const numSections = 100;
+    const numPerSection = 10;
+    return Scaffold(
+      appBar: AppBar(title: Text(title)),
+      body: StickyHeaderListView.separated(
+
+        // StickyHeaderListView and StickyHeaderItem also work for horizontal
+        // scrolling.  Pass `scrollDirection: Axis.horizontal` to the
+        // StickyHeaderListView constructor, just like for ListView.
+        scrollDirection: Axis.horizontal,
+        reverse: reverse,
+        reverseHeader: headerAtRight,
+        itemCount: numSections,
+        separatorBuilder: (context, i) => const SizedBox.shrink(),
+        itemBuilder: (context, i) => StickyHeaderItem(
+          header: TallHeader(i: i),
+          child: Row(
+            textDirection: headerAtRight ? TextDirection.rtl : TextDirection.ltr,
+            children: List.generate(
+              numPerSection + 1,
+              (j) {
+                if (j == 0) return TallHeader(i: i);
+                return TallItem(i: i, j: j-1, numPerSection: numPerSection);
+              })))));
+  }
+}
+
+/// An experimental example approximating the Zulip message list.
+class ExampleVerticalDouble extends StatelessWidget {
+  const ExampleVerticalDouble({
+    super.key,
+    required this.title,
+    // this.reverse = false,
+    // this.headerDirection = AxisDirection.down,
+  }); // : assert(axisDirectionToAxis(headerDirection) == Axis.vertical);
+
+  final String title;
+  // final bool reverse;
+  // final AxisDirection headerDirection;
+
+  @override
+  Widget build(BuildContext context) {
+    const centerSliverKey = ValueKey('center sliver');
+    const numSections = 100;
+    const numBottomSections = 2;
+    const numPerSection = 10;
+    return Scaffold(
+      appBar: AppBar(title: Text(title)),
+      body: CustomScrollView(
+        semanticChildCount: numSections,
+        anchor: 0.5,
+        center: centerSliverKey,
+        slivers: [
+          SliverStickyHeaderList(
+            headerPlacement: HeaderPlacement.scrollingStart,
+            delegate: SliverChildBuilderDelegate(
+              childCount: numSections - numBottomSections,
+              (context, i) {
+                final ii = i + numBottomSections;
+                return StickyHeaderItem(
+                  header: WideHeader(i: ii),
+                  child: Column(
+                    children: List.generate(numPerSection + 1, (j) {
+                      if (j == 0) return WideHeader(i: ii);
+                      return WideItem(i: ii, j: j-1);
+                    })));
+              })),
+          SliverStickyHeaderList(
+            key: centerSliverKey,
+            headerPlacement: HeaderPlacement.scrollingStart,
+            delegate: SliverChildBuilderDelegate(
+              childCount: numBottomSections,
+              (context, i) {
+                final ii = numBottomSections - 1 - i;
+                return StickyHeaderItem(
+                  header: WideHeader(i: ii),
+                  child: Column(
+                    children: List.generate(numPerSection + 1, (j) {
+                        if (j == 0) return WideHeader(i: ii);
+                        return WideItem(i: ii, j: j-1);
+                      })));
+              })),
+        ]));
+  }
+}
+
+////////////////////////////////////////////////////////////////////////////
+//
+// That's it!
+//
+// The rest of this file is boring infrastructure for navigating to the
+// different examples, and for having some content to put inside them.
+//
+////////////////////////////////////////////////////////////////////////////
+
+class WideHeader extends StatelessWidget {
+  const WideHeader({super.key, required this.i});
+
+  final int i;
+
+  @override
+  Widget build(BuildContext context) {
+    return Material(
+      color: Theme.of(context).colorScheme.primaryContainer,
+      child: ListTile(
+        title: Text("Section ${i + 1}",
+          style: TextStyle(
+            color: Theme.of(context).colorScheme.onPrimaryContainer))));
+  }
+}
+
+class WideItem extends StatelessWidget {
+  const WideItem({super.key, required this.i, required this.j});
+
+  final int i;
+  final int j;
+
+  @override
+  Widget build(BuildContext context) {
+    return ListTile(title: Text("Item ${i + 1}.${j + 1}"));
+  }
+}
+
+class TallHeader extends StatelessWidget {
+  const TallHeader({super.key, required this.i});
+
+  final int i;
+
+  @override
+  Widget build(BuildContext context) {
+    final contents = Column(children: [
+      Text("Section ${i + 1}",
+        style: TextStyle(
+          fontWeight: FontWeight.bold,
+          color: Theme.of(context).colorScheme.onPrimaryContainer)),
+      const SizedBox(height: 8),
+      const Expanded(child: SizedBox.shrink()),
+      const SizedBox(height: 8),
+      const Text("end"),
+    ]);
+
+    return Container(
+      alignment: Alignment.center,
+      child: Card(
+        color: Theme.of(context).colorScheme.primaryContainer,
+        child: Padding(padding: const EdgeInsets.all(8), child: contents)));
+  }
+}
+
+class TallItem extends StatelessWidget {
+  const TallItem({super.key,
+    required this.i,
+    required this.j,
+    required this.numPerSection,
+  });
+
+  final int i;
+  final int j;
+  final int numPerSection;
+
+  @override
+  Widget build(BuildContext context) {
+    final heightFactor = (1 + j) / numPerSection;
+
+    final contents = Column(children: [
+      Text("Item ${i + 1}.${j + 1}"),
+      const SizedBox(height: 8),
+      Expanded(
+        child: FractionallySizedBox(
+          heightFactor: heightFactor,
+          child: ColoredBox(
+            color: Theme.of(context).colorScheme.secondary,
+            child: const SizedBox(width: 4)))),
+      const SizedBox(height: 8),
+      const Text("end"),
+    ]);
+
+    return Container(
+      alignment: Alignment.center,
+      child: Card(
+        child: Padding(padding: const EdgeInsets.all(8), child: contents)));
+  }
+}
+
+enum _ExampleType { vertical, horizontal }
+
+class MainPage extends StatelessWidget {
+  const MainPage({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final verticalItems = [
+      _buildItem(context, _ExampleType.vertical,
+        primary: true,
+        title: 'Scroll down, headers at top (a standard list)',
+        headerDirection: AxisDirection.down),
+      _buildItem(context, _ExampleType.vertical,
+        title: 'Scroll up, headers at top',
+        reverse: true,
+        headerDirection: AxisDirection.down),
+      _buildItem(context, _ExampleType.vertical,
+        title: 'Scroll down, headers at bottom',
+        headerDirection: AxisDirection.up),
+      _buildItem(context, _ExampleType.vertical,
+        title: 'Scroll up, headers at bottom',
+        reverse: true,
+        headerDirection: AxisDirection.up),
+    ];
+    final horizontalItems = [
+      _buildItem(context, _ExampleType.horizontal,
+        title: 'Scroll right, headers at left',
+        headerDirection: AxisDirection.right),
+      _buildItem(context, _ExampleType.horizontal,
+        title: 'Scroll left, headers at left',
+        reverse: true,
+        headerDirection: AxisDirection.right),
+      _buildItem(context, _ExampleType.horizontal,
+        title: 'Scroll right, headers at right',
+        headerDirection: AxisDirection.left),
+      _buildItem(context, _ExampleType.horizontal,
+        title: 'Scroll left, headers at right',
+        reverse: true,
+        headerDirection: AxisDirection.left),
+    ];
+    final otherItems = [
+      _buildButton(context,
+        title: 'Double slivers',
+        page: ExampleVerticalDouble(title: 'Double slivers')),
+    ];
+    return Scaffold(
+        appBar: AppBar(title: const Text('Sticky Headers example')),
+        body: CustomScrollView(slivers: [
+          SliverToBoxAdapter(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24),
+              child: Center(
+                child: Text("Vertical lists",
+                  style: Theme.of(context).textTheme.headlineMedium)))),
+          SliverPadding(
+            padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+            sliver: SliverGrid.count(
+              childAspectRatio: 2,
+              crossAxisCount: 2,
+              children: verticalItems)),
+          SliverToBoxAdapter(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24),
+              child: Center(
+                child: Text("Horizontal lists",
+                  style: Theme.of(context).textTheme.headlineMedium)))),
+          SliverPadding(
+            padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+            sliver: SliverGrid.count(
+              childAspectRatio: 2,
+              crossAxisCount: 2,
+              children: horizontalItems)),
+          SliverToBoxAdapter(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24),
+              child: Center(
+                child: Text("Other examples",
+                  style: Theme.of(context).textTheme.headlineMedium)))),
+          SliverPadding(
+            padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+            sliver: SliverGrid.count(
+              childAspectRatio: 2,
+              crossAxisCount: 2,
+              children: otherItems)),
+        ]));
+  }
+
+  Widget _buildItem(BuildContext context, _ExampleType exampleType, {
+    required String title,
+    bool reverse = false,
+    required AxisDirection headerDirection,
+    bool primary = false,
+  }) {
+    Widget page;
+    switch (exampleType) {
+      case _ExampleType.vertical:
+        page = ExampleVertical(
+          title: title, reverse: reverse, headerDirection: headerDirection);
+        break;
+      case _ExampleType.horizontal:
+        page = ExampleHorizontal(
+          title: title, reverse: reverse, headerDirection: headerDirection);
+        break;
+    }
+    return _buildButton(context, title: title, page: page);
+  }
+
+  Widget _buildButton(BuildContext context, {
+    bool primary = false,
+    required String title,
+    required Widget page,
+  }) {
+    var label = Text(title,
+      textAlign: TextAlign.center,
+      style: TextStyle(
+        inherit: true,
+        fontSize: Theme.of(context).textTheme.titleMedium?.fontSize));
+    var buttonStyle = primary
+      ? null
+      : ElevatedButton.styleFrom(
+          foregroundColor: Theme.of(context).colorScheme.onSecondary,
+          backgroundColor: Theme.of(context).colorScheme.secondary);
+    return Container(
+      padding: const EdgeInsets.all(16),
+      child: ElevatedButton(
+        style: buttonStyle,
+        onPressed: () => Navigator.of(context)
+          .push(MaterialPageRoute<void>(builder: (_) => page)),
+        child: label));
+  }
+}
+
+class ExampleApp extends StatelessWidget {
+  const ExampleApp({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: 'Sticky Headers example',
+      theme: ThemeData(
+        colorScheme:
+          ColorScheme.fromSeed(seedColor: const Color(0xff3366cc))),
+      home: const MainPage(),
+    );
+  }
+}
+
+void main() {
+  runApp(const ExampleApp());
+}
diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart
index 356a056094..24eeb8d518 100644
--- a/lib/widgets/sticky_header.dart
+++ b/lib/widgets/sticky_header.dart
@@ -489,6 +489,11 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
     if (_header != null) adoptChild(_header!);
   }
 
+  /// This sliver's child sliver, a modified [RenderSliverList].
+  ///
+  /// The child manages the items in the list (deferring to [RenderSliverList]);
+  /// and identifies which list item, if any, should be consulted
+  /// for a sticky header.
   _RenderSliverStickyHeaderListInner? get child => _child;
   _RenderSliverStickyHeaderListInner? _child;
   set child(_RenderSliverStickyHeaderListInner? value) {
@@ -552,44 +557,74 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
 
   @override
   void performLayout() {
+    // First, lay out the child sliver.  This does all the normal work of
+    // [RenderSliverList], then calls [_rebuildHeader] on this sliver
+    // so that [header] and [_headerEndBound] are up to date.
     assert(child != null);
     child!.layout(constraints, parentUsesSize: true);
     SliverGeometry geometry = child!.geometry!;
 
+    if (geometry.scrollOffsetCorrection != null) {
+      this.geometry = geometry;
+      return;
+    }
+
+    // We assume [child]'s geometry is free of certain complications.
+    // Probably most or all of these *could* be handled if necessary, just at
+    // the cost of further complicating this code.  Fortunately they aren't,
+    // because [RenderSliverList.performLayout] never has these complications.
+    assert(geometry.paintOrigin == 0);
+    assert(geometry.layoutExtent == geometry.paintExtent);
+    assert(geometry.hitTestExtent == geometry.paintExtent);
+    assert(geometry.visible == (geometry.paintExtent > 0));
+    assert(geometry.maxScrollObstructionExtent == 0);
+    assert(geometry.crossAxisExtent == null);
+    final childExtent = geometry.layoutExtent;
+
     if (header != null) {
       header!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
-
       final headerExtent = header!.size.onAxis(constraints.axis);
+
       final double headerOffset;
       if (_headerEndBound == null) {
-        final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent);
-        final cacheExtent = calculateCacheOffset(constraints, from: 0, to: headerExtent);
-
-        assert(0 <= paintedHeaderSize && paintedHeaderSize.isFinite);
+        // The header's item has [StickyHeaderItem.allowOverflow] true.
+        // Show the header in full, with one edge at the edge of the viewport,
+        // even if the (visible part of the) item is smaller than the header,
+        // and even if the whole child sliver is smaller than the header.
 
+        final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent);
         geometry = SliverGeometry( // TODO review interaction with other slivers
           scrollExtent: geometry.scrollExtent,
-          layoutExtent: geometry.layoutExtent,
-          paintExtent: math.max(geometry.paintExtent, paintedHeaderSize),
-          cacheExtent: math.max(geometry.cacheExtent, cacheExtent),
+          layoutExtent: childExtent,
+          paintExtent: math.max(childExtent, paintedHeaderSize),
           maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent),
-          hitTestExtent: math.max(geometry.hitTestExtent, paintedHeaderSize),
           hasVisualOverflow: geometry.hasVisualOverflow
             || headerExtent > constraints.remainingPaintExtent,
+
+          // The cache extent is an extension of layout, not paint; it controls
+          // where the next sliver should start laying out content.  (See
+          // [SliverConstraints.remainingCacheExtent].)  The header isn't meant
+          // to affect where the next sliver gets laid out, so it shouldn't
+          // affect the cache extent.
+          cacheExtent: geometry.cacheExtent,
         );
 
         headerOffset = _headerAtCoordinateEnd()
-          ? geometry.layoutExtent - headerExtent
+          ? childExtent - headerExtent
           : 0.0;
       } else {
+        // The header's item has [StickyHeaderItem.allowOverflow] false.
+        // Keep the header within the item, pushing the header partly out of
+        // the viewport if the item's visible part is smaller than the header.
+
         // The limiting edge of the header's item,
         // in the outer, non-scrolling coordinates.
         final endBoundAbsolute = axisDirectionIsReversed(constraints.growthAxisDirection)
-          ? geometry.layoutExtent - (_headerEndBound! - constraints.scrollOffset)
+          ? childExtent - (_headerEndBound! - constraints.scrollOffset)
           : _headerEndBound! - constraints.scrollOffset;
 
         headerOffset = _headerAtCoordinateEnd()
-          ? math.max(geometry.layoutExtent - headerExtent, endBoundAbsolute)
+          ? math.max(childExtent - headerExtent, endBoundAbsolute)
           : math.min(0.0, endBoundAbsolute - headerExtent);
       }
 
@@ -706,7 +741,10 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList {
   ///
   /// This means (child start) < (viewport end) <= (child end).
   RenderBox? _findChildAtEnd() {
-    final endOffset = constraints.scrollOffset + constraints.viewportMainAxisExtent;
+    /// The end of the visible area available to this sliver,
+    /// in this sliver's "scroll offset" coordinates.
+    final endOffset = constraints.scrollOffset
+      + constraints.remainingPaintExtent;
 
     RenderBox? child;
     for (child = lastChild; ; child = childBefore(child)) {
@@ -736,10 +774,23 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList {
 
     final RenderBox? child;
     switch (widget.headerPlacement._byGrowth(constraints.growthDirection)) {
+      case _HeaderGrowthPlacement.growthStart:
+        if (constraints.remainingPaintExtent < constraints.viewportMainAxisExtent) {
+          // Part of the viewport is occupied already by other slivers.  The way
+          // a RenderViewport does layout means that the already-occupied part is
+          // the part that's before this sliver in the growth direction.
+          // Which means that's the place where the header would go.
+          child = null;
+        } else {
+          child = _findChildAtStart();
+        }
       case _HeaderGrowthPlacement.growthEnd:
+        // The edge this sliver wants to place a header at is the one where
+        // this sliver is free to run all the way to the viewport's edge; any
+        // further slivers in that direction will be laid out after this one.
+        // So if this sliver placed a child there, it's at the edge of the
+        // whole viewport and should determine a header.
         child = _findChildAtEnd();
-      case _HeaderGrowthPlacement.growthStart:
-        child = _findChildAtStart();
     }
 
     (parent! as _RenderSliverStickyHeaderList)._rebuildHeader(child);


Test patch:
diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart
index fc363c71e8..c283652ae1 100644
--- a/test/widgets/sticky_header_test.dart
+++ b/test/widgets/sticky_header_test.dart
@@ -103,6 +103,116 @@ void main() {
       }
     }
   }
+
+  testWidgets('sticky headers: propagate scrollOffsetCorrection properly', (tester) async {
+    Widget page(Widget Function(BuildContext, int) itemBuilder) {
+      return Directionality(textDirection: TextDirection.ltr,
+        child: StickyHeaderListView.builder(
+          cacheExtent: 0,
+          itemCount: 10, itemBuilder: itemBuilder));
+    }
+
+    await tester.pumpWidget(page((context, i) =>
+      StickyHeaderItem(
+        allowOverflow: true,
+        header: _Header(i, height: 40),
+        child: _Item(i, height: 200))));
+    check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 400));
+
+    // Scroll down (dragging up) to get item 0 off screen.
+    await tester.drag(find.text("Item 2"), Offset(0, -300));
+    await tester.pump();
+    check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 100));
+
+    // Make the off-screen item 0 taller, so scrolling back up will underflow.
+    await tester.pumpWidget(page((context, i) =>
+      StickyHeaderItem(
+        allowOverflow: true,
+        header: _Header(i, height: 40),
+        child: _Item(i, height: i == 0 ? 400 : 200))));
+    // Confirm the change in item 0's height hasn't already been applied,
+    // as it would if the item were within the viewport or its cache area.
+    check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 100));
+
+    // Scroll back up (dragging down).  This will cause a correction as the list
+    // discovers that moving 300px up doesn't reach the start anymore.
+    await tester.drag(find.text("Item 2"), Offset(0, 300));
+
+    // As a bonus, mark one of the already-visible items as needing layout.
+    // (In a real app, this would typically happen because some state changed.)
+    tester.firstElement(find.widgetWithText(SizedBox, "Item 2"))
+      .renderObject!.markNeedsLayout();
+
+    // If scrollOffsetCorrection doesn't get propagated to the viewport, this
+    // pump will record an exception (causing the test to fail at the end)
+    // because the marked item won't get laid out.
+    await tester.pump();
+    check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 400));
+
+    // Moreover if scrollOffsetCorrection doesn't get propagated, this item
+    // will get placed at zero rather than properly extend up off screen.
+    check(tester.getTopLeft(find.text("Item 0"))).equals(Offset(0, -200));
+  });
+
+  testWidgets('sliver only part of viewport, header at end', (tester) async {
+    const centerKey = ValueKey('center');
+    final controller = ScrollController();
+    await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
+      child: CustomScrollView(
+        controller: controller,
+        anchor: 0.5,
+        center: centerKey,
+        slivers: [
+          SliverStickyHeaderList(
+            headerPlacement: HeaderPlacement.scrollingStart,
+            delegate: SliverChildListDelegate(
+              List.generate(100, (i) => StickyHeaderItem(
+                header: _Header(99 - i, height: 20),
+                child: _Item(99 - i, height: 100))))),
+          SliverStickyHeaderList(
+            key: centerKey,
+            headerPlacement: HeaderPlacement.scrollingStart,
+            delegate: SliverChildListDelegate(
+              List.generate(100, (i) => StickyHeaderItem(
+                header: _Header(100 + i, height: 20),
+                child: _Item(100 + i, height: 100))))),
+        ])));
+
+    final overallSize = tester.getSize(find.byType(CustomScrollView));
+    final extent = overallSize.onAxis(Axis.vertical);
+    assert(extent == 600);
+
+    void checkState(int index, {required double item, required double header}) {
+      final itemElement = tester.firstElement(find.byElementPredicate((element) {
+        if (element.widget is! _Item) return false;
+        final renderObject = element.renderObject as RenderBox;
+        return (renderObject.size.contains(renderObject.globalToLocal(
+            Offset(overallSize.width / 2, 1)
+        )));
+      }));
+      final itemWidget = itemElement.widget as _Item;
+      check(itemWidget.index).equals(index);
+      check(_headerIndex(tester)).equals(index);
+      check((itemElement.renderObject as RenderBox).localToGlobal(Offset(0, 0)))
+        .equals(Offset(0, item));
+      check(tester.getTopLeft(find.byType(_Header))).equals(Offset(0, header));
+    }
+
+    check(controller.offset).equals(0);
+    checkState( 97, item:   0, header:   0);
+
+    controller.jumpTo(-5);
+    await tester.pump();
+    checkState( 96, item: -95, header: -15);
+
+    controller.jumpTo(-600);
+    await tester.pump();
+    checkState( 91, item:   0, header:   0);
+
+    controller.jumpTo(600);
+    await tester.pump();
+    checkState(103, item:   0, header:   0);
+  });
 }
 
 Future<void> _checkSequence(
@@ -174,7 +284,6 @@ Future<void> _checkSequence(
     final expectedHeaderIndex = first
       ? (scrollOffset / 100).floor()
       : (extent ~/ 100 - 1) + (scrollOffset / 100).ceil();
-    // print("$scrollOffset, $extent, $expectedHeaderIndex");
     check(tester.widget<_Item>(itemFinder).index).equals(expectedHeaderIndex);
     check(_headerIndex(tester)).equals(expectedHeaderIndex);