Skip to content

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

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 19 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
96bdc7d
sticky_header [nfc]: Document SliverStickyHeaderList
gnprice Jan 31, 2025
41c410c
sticky_header example: Enable ink splashes, to demo hit-testing
gnprice Jan 31, 2025
16308e5
sticky_header example: Set allowOverflow true in double-sliver example
gnprice Jan 24, 2025
4ab8121
sticky_header example: Make double slivers not back-to-back
gnprice Feb 1, 2025
ca394c0
sticky_header test: Favor drag gestures over taps, when they compete
gnprice Jan 31, 2025
63f66d1
sticky_header test [nfc]: Generalize tap-logging from headers
gnprice Jan 31, 2025
8bf8c24
sticky_header test: Record taps on _Item widgets too
gnprice Jan 31, 2025
b541ea3
sticky_header example: Add double slivers with header at bottom
gnprice Feb 8, 2025
628ac15
sticky_header test [nfc]: Make "first/last item" finders more robust
gnprice Feb 11, 2025
488c60c
sticky_header test [nfc]: Prepare generic test for more generality
gnprice Feb 11, 2025
7df2c37
sticky_header test [nfc]: Prepare list of slivers more uniformly
gnprice Feb 11, 2025
fba97d8
sticky_header test: Use 10 items instead of 100
gnprice Feb 11, 2025
f53521b
sticky_header test: Test slivers splitting viewport
gnprice Feb 11, 2025
d98b67b
sticky_header [nfc]: Fix childMainAxisPosition to properly use paintE…
gnprice Feb 8, 2025
4cc3cb1
sticky_header [nfc]: Split header-overflows-sliver condition explicitly
gnprice Feb 11, 2025
afeacd5
sticky_header: Cut wrong use of calculatePaintOffset
gnprice Jan 31, 2025
732761f
sticky_header [nfc]: Expand on the header-overflows-sliver case
gnprice Feb 8, 2025
dc2c9f0
sticky_header: Fix hit-testing when header overflows sliver
gnprice Jan 31, 2025
043ae10
sticky_header [nfc]: Doc overflow behavior and paint-order constraints
gnprice Jan 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 48 additions & 17 deletions lib/example/sticky_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,54 +125,75 @@ class ExampleVerticalDouble extends StatelessWidget {
super.key,
required this.title,
// this.reverse = false,
// this.headerDirection = AxisDirection.down,
}); // : assert(axisDirectionToAxis(headerDirection) == Axis.vertical);
required this.headerPlacement,
});

final String title;
// final bool reverse;
// final AxisDirection headerDirection;
final HeaderPlacement headerPlacement;

@override
Widget build(BuildContext context) {
const centerSliverKey = ValueKey('center sliver');
const numSections = 100;
const numSections = 4;
const numBottomSections = 2;
const numTopSections = numSections - numBottomSections;
const numPerSection = 10;

final headerAtBottom = switch (headerPlacement) {
HeaderPlacement.scrollingStart => false,
HeaderPlacement.scrollingEnd => true,
};

// Choose the "center" sliver so that the sliver which might need to paint
// a header overflowing the other header is the sliver that paints last.
final centerKey = headerAtBottom ?
const ValueKey('bottom') : const ValueKey('top');

// This is a side effect of our choice of centerKey.
final topSliverGrowsUpward = headerAtBottom;

return Scaffold(
appBar: AppBar(title: Text(title)),
body: CustomScrollView(
semanticChildCount: numSections,
anchor: 0.5,
center: centerSliverKey,
center: centerKey,
slivers: [
SliverStickyHeaderList(
headerPlacement: HeaderPlacement.scrollingStart,
key: const ValueKey('top'),
headerPlacement: headerPlacement,
delegate: SliverChildBuilderDelegate(
childCount: numSections - numBottomSections,
(context, i) {
final ii = i + numBottomSections;
final ii = numBottomSections
+ (topSliverGrowsUpward ? i : numTopSections - 1 - i);
return StickyHeaderItem(
allowOverflow: true,
header: WideHeader(i: ii),
child: Column(
verticalDirection: headerAtBottom
? VerticalDirection.up : VerticalDirection.down,
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,
key: const ValueKey('bottom'),
headerPlacement: headerPlacement,
delegate: SliverChildBuilderDelegate(
childCount: numBottomSections,
(context, i) {
final ii = numBottomSections - 1 - i;
return StickyHeaderItem(
allowOverflow: true,
header: WideHeader(i: ii),
child: Column(
verticalDirection: headerAtBottom
? VerticalDirection.up : VerticalDirection.down,
children: List.generate(numPerSection + 1, (j) {
if (j == 0) return WideHeader(i: ii);
return WideItem(i: ii, j: j-1);
})));
if (j == 0) return WideHeader(i: ii);
return WideItem(i: ii, j: j-1);
})));
})),
]));
}
Expand All @@ -197,6 +218,7 @@ class WideHeader extends StatelessWidget {
return Material(
color: Theme.of(context).colorScheme.primaryContainer,
child: ListTile(
onTap: () {}, // nop, but non-null so the ink splash appears
title: Text("Section ${i + 1}",
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer))));
Expand All @@ -211,7 +233,9 @@ class WideItem extends StatelessWidget {

@override
Widget build(BuildContext context) {
return ListTile(title: Text("Item ${i + 1}.${j + 1}"));
return ListTile(
onTap: () {}, // nop, but non-null so the ink splash appears
title: Text("Item ${i + 1}.${j + 1}"));
}
}

Expand Down Expand Up @@ -318,8 +342,15 @@ class MainPage extends StatelessWidget {
];
final otherItems = [
_buildButton(context,
title: 'Double slivers',
page: ExampleVerticalDouble(title: 'Double slivers')),
title: 'Double slivers, headers at top',
page: ExampleVerticalDouble(
title: 'Double slivers, headers at top',
headerPlacement: HeaderPlacement.scrollingStart)),
_buildButton(context,
title: 'Double slivers, headers at bottom',
page: ExampleVerticalDouble(
title: 'Double slivers, headers at bottom',
headerPlacement: HeaderPlacement.scrollingEnd)),
];
return Scaffold(
appBar: AppBar(title: const Text('Sticky Headers example')),
Expand Down
127 changes: 104 additions & 23 deletions lib/widgets/sticky_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ class RenderStickyHeaderItem extends RenderProxyBox {
/// or if [scrollDirection] is horizontal then to the start in the
/// reading direction of the ambient [Directionality].
/// It can be controlled with [reverseHeader].
///
/// Much like [ListView], a [StickyHeaderListView] is basically
/// a [CustomScrollView] with a single sliver in its [CustomScrollView.slivers]
/// property.
/// For a [StickyHeaderListView], that sliver is a [SliverStickyHeaderList].
///
/// If more than one sliver is needed, any code using [StickyHeaderListView]
/// can be ported to use [CustomScrollView] directly, in much the same way
/// as for code using [ListView]. See [ListView] for details.
///
/// See also:
/// * [SliverStickyHeaderList], which provides the sticky-header behavior.
class StickyHeaderListView extends BoxScrollView {
// Like ListView, but with sticky headers.
StickyHeaderListView({
Expand Down Expand Up @@ -296,6 +308,31 @@ enum _HeaderGrowthPlacement {
growthEnd
}

/// A list sliver with sticky headers.
///
/// This widget takes most of its behavior from [SliverList],
/// but adds sticky headers as described at [StickyHeaderListView].
///
/// ## Overflow across slivers
///
/// When the list item that controls the sticky header has
/// [StickyHeaderItem.allowOverflow] true, the header will be permitted
/// to overflow not only the item but this whole sliver.
///
/// The caller is responsible for arranging the paint order between slivers
/// so that this works correctly: a sliver that might overflow must be painted
/// after any sliver it might overflow onto.
/// For example if [headerPlacement] puts headers at the left of the viewport
/// (and any items with [StickyHeaderItem.allowOverflow] true are present),
/// then this [SliverStickyHeaderList] must paint after any slivers that appear
/// to the right of this sliver.
///
/// At present there's no off-the-shelf way to fully control the paint order
/// between slivers.
/// See the implementation of [RenderViewport.childrenInPaintOrder] for the
/// paint order provided by [CustomScrollView]; it meets the above needs
/// for some arrangements of slivers and values of [headerPlacement],
/// but not others.
class SliverStickyHeaderList extends RenderObjectWidget {
SliverStickyHeaderList({
super.key,
Expand All @@ -306,7 +343,16 @@ class SliverStickyHeaderList extends RenderObjectWidget {
delegate: delegate,
);

/// Whether the sticky header appears at the start or the end
/// in the scrolling direction.
///
/// For example, if the enclosing [Viewport] has [Viewport.axisDirection]
/// of [AxisDirection.down], then
/// [HeaderPlacement.scrollingStart] means the header appears at
/// the top of the viewport, and
/// [HeaderPlacement.scrollingEnd] means it appears at the bottom.
final HeaderPlacement headerPlacement;

final _SliverStickyHeaderListInner _child;

@override
Expand Down Expand Up @@ -592,26 +638,56 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
// 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: childExtent,
paintExtent: math.max(childExtent, paintedHeaderSize),
maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent),
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,
);
if (headerExtent <= childExtent) {
// The header fits within the child sliver.
// So it doesn't affect this sliver's overall geometry.

headerOffset = _headerAtCoordinateEnd()
? childExtent - headerExtent
: 0.0;
headerOffset = _headerAtCoordinateEnd()
? childExtent - headerExtent
: 0.0;
} else {
// The header will overflow the child sliver.
// That makes this sliver's geometry a bit more complicated.

// This sliver's paint region consists entirely of the header.
final paintExtent = headerExtent;
headerOffset = 0.0;

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

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

geometry = SliverGeometry( // TODO review interaction with other slivers
scrollExtent: geometry.scrollExtent,
layoutExtent: layoutExtent,
paintExtent: paintExtent,
paintOrigin: paintOrigin,
maxPaintExtent: math.max(geometry.maxPaintExtent, paintExtent),
hasVisualOverflow: geometry.hasVisualOverflow
|| paintExtent > 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,
);
}
} else {
// The header's item has [StickyHeaderItem.allowOverflow] false.
// Keep the header within the item, pushing the header partly out of
Expand Down Expand Up @@ -666,16 +742,21 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
double childMainAxisPosition(RenderObject child) {
if (child == this.child) return 0.0;
assert(child == header);

final headerParentData = (header!.parentData as SliverPhysicalParentData);
final paintOffset = headerParentData.paintOffset;

// We use Sliver*Physical*ParentData, so the header's position is stored in
// physical coordinates. To meet the spec of `childMainAxisPosition`, we
// need to convert to the sliver's coordinate system.
final headerParentData = (header!.parentData as SliverPhysicalParentData);
final paintOffset = headerParentData.paintOffset;
// This is all a bit silly because callers like [hitTestBoxChild] are just
// going to do the same things in reverse to get physical coordinates.
// Ah well; that's the API.
return switch (constraints.growthAxisDirection) {
AxisDirection.right => paintOffset.dx,
AxisDirection.left => geometry!.layoutExtent - header!.size.width - paintOffset.dx,
AxisDirection.left => geometry!.paintExtent - header!.size.width - paintOffset.dx,
AxisDirection.down => paintOffset.dy,
AxisDirection.up => geometry!.layoutExtent - header!.size.height - paintOffset.dy,
AxisDirection.up => geometry!.paintExtent - header!.size.height - paintOffset.dy,
};
}

Expand Down
Loading