Skip to content

Commit dc2c9f0

Browse files
committed
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.
1 parent 732761f commit dc2c9f0

File tree

2 files changed

+73
-20
lines changed

2 files changed

+73
-20
lines changed

lib/widgets/sticky_header.dart

+14-6
Original file line numberDiff line numberDiff line change
@@ -630,22 +630,30 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
630630

631631
// This sliver's paint region consists entirely of the header.
632632
final paintExtent = headerExtent;
633-
headerOffset = _headerAtCoordinateEnd()
634-
? childExtent - headerExtent // TODO buggy, should be zero
635-
: 0.0;
633+
headerOffset = 0.0;
636634

637635
// Its layout region (affecting where the next sliver begins layout)
638636
// is that given by the child sliver.
639637
final layoutExtent = childExtent;
640638

641639
// The paint origin places this sliver's paint region relative to its
642-
// layout region.
643-
final paintOrigin = 0.0; // TODO buggy
640+
// layout region so that they share the edge the header appears at
641+
// (which should be the edge of the viewport).
642+
final headerGrowthPlacement =
643+
_widget.headerPlacement._byGrowth(constraints.growthDirection);
644+
final paintOrigin = switch (headerGrowthPlacement) {
645+
_HeaderGrowthPlacement.growthStart => 0.0,
646+
_HeaderGrowthPlacement.growthEnd => layoutExtent - paintExtent,
647+
};
648+
// TODO the child sliver should be painted at offset -paintOrigin
649+
// (This bug doesn't matter so long as the header is opaque,
650+
// because the header covers the child in that case.
651+
// For that reason the Zulip message list isn't affected.)
644652

645653
geometry = SliverGeometry( // TODO review interaction with other slivers
646654
scrollExtent: geometry.scrollExtent,
647655
layoutExtent: layoutExtent,
648-
paintExtent: childExtent, // TODO buggy
656+
paintExtent: paintExtent,
649657
paintOrigin: paintOrigin,
650658
maxPaintExtent: math.max(geometry.maxPaintExtent, paintExtent),
651659
hasVisualOverflow: geometry.hasVisualOverflow

test/widgets/sticky_header_test.dart

+59-14
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,48 @@ void main() {
228228
await tester.pump();
229229
checkState(103, item: 0, header: 0);
230230
});
231+
232+
testWidgets('hit-testing for header overflowing sliver', (tester) async {
233+
final controller = ScrollController();
234+
await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
235+
child: CustomScrollView(
236+
controller: controller,
237+
slivers: [
238+
SliverStickyHeaderList(
239+
headerPlacement: HeaderPlacement.scrollingStart,
240+
delegate: SliverChildListDelegate(
241+
List.generate(100, (i) => StickyHeaderItem(
242+
allowOverflow: true,
243+
header: _Header(i, height: 20),
244+
child: _Item(i, height: 100))))),
245+
SliverStickyHeaderList(
246+
headerPlacement: HeaderPlacement.scrollingStart,
247+
delegate: SliverChildListDelegate(
248+
List.generate(100, (i) => StickyHeaderItem(
249+
allowOverflow: true,
250+
header: _Header(100 + i, height: 20),
251+
child: _Item(100 + i, height: 100))))),
252+
])));
253+
254+
const topExtent = 100 * 100;
255+
for (double topHeight in [5, 10, 15, 20]) {
256+
controller.jumpTo(topExtent - topHeight);
257+
await tester.pump();
258+
// The top sliver occupies height [topHeight].
259+
// Its header overhangs by `20 - topHeight`.
260+
261+
final expected = <Condition<Object?>>[];
262+
for (int y = 1; y < 20; y++) {
263+
await tester.tapAt(Offset(400, y.toDouble()));
264+
expected.add((it) => it.isA<_Header>().index.equals(99));
265+
}
266+
for (int y = 21; y < 40; y += 2) {
267+
await tester.tapAt(Offset(400, y.toDouble()));
268+
expected.add((it) => it.isA<_Item>().index.equals(100));
269+
}
270+
check(_TapLogged.takeTapLog()).deepEquals(expected);
271+
}
272+
});
231273
}
232274

233275
enum _SliverConfig {
@@ -255,19 +297,6 @@ Future<void> _checkSequence(
255297
final headerPlacement = reverseHeader ^ reverse
256298
? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart;
257299

258-
if (allowOverflow
259-
&& ((sliverConfig == _SliverConfig.backToBack
260-
&& (reverse ^ reverseHeader))
261-
|| (sliverConfig == _SliverConfig.followed
262-
&& (reverse ^ reverseHeader ^ !reverseGrowth)))) {
263-
// (The condition for this skip is pretty complicated; it's just the
264-
// conditions where the bug gets triggered, and I haven't tried to
265-
// work through why this exact set of cases is what's affected.
266-
// The important thing is they all get fixed in an upcoming commit.)
267-
markTestSkipped('bug in header overflowing sliver'); // TODO fix
268-
return;
269-
}
270-
271300
Widget buildItem(int i) {
272301
return StickyHeaderItem(
273302
allowOverflow: allowOverflow,
@@ -377,7 +406,15 @@ Future<void> _checkSequence(
377406
100 - (first ? scrollOffset % 100 : (-scrollOffset) % 100);
378407
final double expectedHeaderInsetExtent =
379408
allowOverflow ? 20 : math.min(20, expectedItemInsetExtent);
380-
check(insetExtent(itemFinder)).equals(expectedItemInsetExtent);
409+
if (expectedItemInsetExtent < expectedHeaderInsetExtent) {
410+
// TODO there's a bug here if the header isn't opaque;
411+
// this check would exercise the bug:
412+
// check(insetExtent(itemFinder)).equals(expectedItemInsetExtent);
413+
// Instead, check that things will be fine if the header is opaque.
414+
check(insetExtent(itemFinder)).isLessOrEqual(expectedHeaderInsetExtent);
415+
} else {
416+
check(insetExtent(itemFinder)).equals(expectedItemInsetExtent);
417+
}
381418
check(insetExtent(find.byType(_Header))).equals(expectedHeaderInsetExtent);
382419

383420
// Check the header gets hit when it should, and not when it shouldn't.
@@ -572,6 +609,10 @@ class _Header extends StatelessWidget implements _TapLogged {
572609
}
573610
}
574611

612+
extension _HeaderChecks on Subject<_Header> {
613+
Subject<int> get index => has((x) => x.index, 'index');
614+
}
615+
575616
class _Item extends StatelessWidget implements _TapLogged {
576617
const _Item(this.index, {required this.height});
577618

@@ -595,6 +636,10 @@ class _Item extends StatelessWidget implements _TapLogged {
595636
}
596637
}
597638

639+
extension _ItemChecks on Subject<_Item> {
640+
Subject<int> get index => has((x) => x.index, 'index');
641+
}
642+
598643
/// Sets [DeviceGestureSettings.touchSlop] for the child subtree
599644
/// to the given value, by inserting a [MediaQuery].
600645
///

0 commit comments

Comments
 (0)