Skip to content

Commit 6e10fde

Browse files
committed
sticky_header: Handle GrowthDirection.reverse
This resolves the last of the "TODO dir" comments in this library. We had originally written this library to rely on the assumption that `constraints.growthDirection` was `GrowthDirection.forward`, i.e. that the direction in which `constraints.scrollOffset` increases was the same as `constraints.axisDirection`, in order to keep down the number of distinct directions one needs to keep track of in writing and reading (and debugging) the implementation. But now that this logic is relatively solid within that case -- and now that we have a solid test suite for this library -- we can go back and identify the handful of places that need to be flipped around for `GrowthDirection.reverse`, and do so. This support isn't yet especially *useful*: there are a couple of unrelated bugs which are triggered when the area allotted to this sliver isn't the entire viewport, and there isn't a lot of reason to have a sliver with GrowthDirection.reverse when no other sliver is potentially occupying part of the viewport. We'll fix those separately.
1 parent 96b10cf commit 6e10fde

File tree

2 files changed

+103
-47
lines changed

2 files changed

+103
-47
lines changed

lib/widgets/sticky_header.dart

+52-12
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,29 @@ class StickyHeaderListView extends BoxScrollView {
272272
/// For example if the list scrolls to the left, then
273273
/// [scrollingStart] means the right edge of the list, regardless of whether
274274
/// the ambient [Directionality] is RTL or LTR.
275-
enum HeaderPlacement { scrollingStart, scrollingEnd }
275+
enum HeaderPlacement {
276+
scrollingStart,
277+
scrollingEnd;
278+
279+
_HeaderGrowthPlacement _byGrowth(GrowthDirection growthDirection) {
280+
return switch ((growthDirection, this)) {
281+
(GrowthDirection.forward, scrollingStart) => _HeaderGrowthPlacement.growthStart,
282+
(GrowthDirection.forward, scrollingEnd) => _HeaderGrowthPlacement.growthEnd,
283+
(GrowthDirection.reverse, scrollingStart) => _HeaderGrowthPlacement.growthEnd,
284+
(GrowthDirection.reverse, scrollingEnd) => _HeaderGrowthPlacement.growthStart,
285+
};
286+
}
287+
}
288+
289+
/// Where a header goes, in terms of the list sliver's growth direction.
290+
///
291+
/// This will agree with the [HeaderPlacement] value if the growth direction
292+
/// is [GrowthDirection.forward], but contrast with it if the growth direction
293+
/// is [GrowthDirection.reverse]. See [HeaderPlacement._byGrowth].
294+
enum _HeaderGrowthPlacement {
295+
growthStart,
296+
growthEnd
297+
}
276298

277299
class SliverStickyHeaderList extends RenderObjectWidget {
278300
SliverStickyHeaderList({
@@ -427,10 +449,10 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
427449
double? endBound;
428450
if (item != null && !item.allowOverflow) {
429451
final childParentData = listChild!.parentData! as SliverMultiBoxAdaptorParentData;
430-
endBound = switch (_widget.headerPlacement) {
431-
HeaderPlacement.scrollingStart =>
452+
endBound = switch (_widget.headerPlacement._byGrowth(constraints.growthDirection)) {
453+
_HeaderGrowthPlacement.growthStart =>
432454
childParentData.layoutOffset! + listChild.size.onAxis(constraints.axis),
433-
HeaderPlacement.scrollingEnd =>
455+
_HeaderGrowthPlacement.growthEnd =>
434456
childParentData.layoutOffset!,
435457
};
436458
}
@@ -562,7 +584,7 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
562584
} else {
563585
// The limiting edge of the header's item,
564586
// in the outer, non-scrolling coordinates.
565-
final endBoundAbsolute = axisDirectionIsReversed(constraints.axisDirection)
587+
final endBoundAbsolute = axisDirectionIsReversed(constraints.growthAxisDirection)
566588
? geometry.layoutExtent - (_headerEndBound! - constraints.scrollOffset)
567589
: _headerEndBound! - constraints.scrollOffset;
568590

@@ -614,7 +636,7 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
614636
// need to convert to the sliver's coordinate system.
615637
final headerParentData = (header!.parentData as SliverPhysicalParentData);
616638
final paintOffset = headerParentData.paintOffset;
617-
return switch (constraints.axisDirection) {
639+
return switch (constraints.growthAxisDirection) {
618640
AxisDirection.right => paintOffset.dx,
619641
AxisDirection.left => geometry!.layoutExtent - header!.size.width - paintOffset.dx,
620642
AxisDirection.down => paintOffset.dy,
@@ -710,18 +732,36 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList {
710732

711733
@override
712734
void performLayout() {
713-
assert(constraints.growthDirection == GrowthDirection.forward); // TODO dir
714-
715735
super.performLayout();
716736

717-
final child = switch (widget.headerPlacement) {
718-
HeaderPlacement.scrollingStart => _findChildAtStart(),
719-
HeaderPlacement.scrollingEnd => _findChildAtEnd(),
720-
};
737+
final RenderBox? child;
738+
switch (widget.headerPlacement._byGrowth(constraints.growthDirection)) {
739+
case _HeaderGrowthPlacement.growthEnd:
740+
child = _findChildAtEnd();
741+
case _HeaderGrowthPlacement.growthStart:
742+
child = _findChildAtStart();
743+
}
744+
721745
(parent! as _RenderSliverStickyHeaderList)._rebuildHeader(child);
722746
}
723747
}
724748

749+
extension SliverConstraintsGrowthAxisDirection on SliverConstraints {
750+
AxisDirection get growthAxisDirection => switch (growthDirection) {
751+
GrowthDirection.forward => axisDirection,
752+
GrowthDirection.reverse => axisDirection.reversed,
753+
};
754+
}
755+
756+
extension AxisDirectionReversed on AxisDirection {
757+
AxisDirection get reversed => switch (this) {
758+
AxisDirection.down => AxisDirection.up,
759+
AxisDirection.up => AxisDirection.down,
760+
AxisDirection.right => AxisDirection.left,
761+
AxisDirection.left => AxisDirection.right,
762+
};
763+
}
764+
725765
extension AxisCoordinateDirection on Axis {
726766
AxisDirection get coordinateDirection => switch (this) {
727767
Axis.horizontal => AxisDirection.right,

test/widgets/sticky_header_test.dart

+51-35
Original file line numberDiff line numberDiff line change
@@ -67,38 +67,38 @@ void main() {
6767

6868
for (final reverse in [true, false]) {
6969
for (final reverseHeader in [true, false]) {
70-
for (final allowOverflow in [true, false]) {
71-
final name = 'sticky headers: '
72-
'scroll ${reverse ? 'up' : 'down'}, '
73-
'header at ${reverseHeader ? 'bottom' : 'top'}, '
74-
'headers ${allowOverflow ? 'overflow' : 'bounded'}';
75-
testWidgets(name, (tester) =>
76-
_checkSequence(tester,
77-
Axis.vertical,
78-
reverse: reverse,
79-
reverseHeader: reverseHeader,
80-
allowOverflow: allowOverflow,
81-
));
82-
}
83-
}
84-
}
85-
86-
for (final reverse in [true, false]) {
87-
for (final reverseHeader in [true, false]) {
88-
for (final allowOverflow in [true, false]) {
89-
for (final textDirection in TextDirection.values) {
70+
for (final growthDirection in GrowthDirection.values) {
71+
for (final allowOverflow in [true, false]) {
9072
final name = 'sticky headers: '
91-
'${textDirection.name.toUpperCase()} '
92-
'scroll ${reverse ? 'backward' : 'forward'}, '
93-
'header at ${reverseHeader ? 'end' : 'start'}, '
73+
'scroll ${reverse ? 'up' : 'down'}, '
74+
'header at ${reverseHeader ? 'bottom' : 'top'}, '
75+
'$growthDirection, '
9476
'headers ${allowOverflow ? 'overflow' : 'bounded'}';
9577
testWidgets(name, (tester) =>
9678
_checkSequence(tester,
97-
Axis.horizontal, textDirection: textDirection,
79+
Axis.vertical,
9880
reverse: reverse,
9981
reverseHeader: reverseHeader,
82+
growthDirection: growthDirection,
10083
allowOverflow: allowOverflow,
10184
));
85+
86+
for (final textDirection in TextDirection.values) {
87+
final name = 'sticky headers: '
88+
'${textDirection.name.toUpperCase()} '
89+
'scroll ${reverse ? 'backward' : 'forward'}, '
90+
'header at ${reverseHeader ? 'end' : 'start'}, '
91+
'$growthDirection, '
92+
'headers ${allowOverflow ? 'overflow' : 'bounded'}';
93+
testWidgets(name, (tester) =>
94+
_checkSequence(tester,
95+
Axis.horizontal, textDirection: textDirection,
96+
reverse: reverse,
97+
reverseHeader: reverseHeader,
98+
growthDirection: growthDirection,
99+
allowOverflow: allowOverflow,
100+
));
101+
}
102102
}
103103
}
104104
}
@@ -111,28 +111,43 @@ Future<void> _checkSequence(
111111
TextDirection? textDirection,
112112
bool reverse = false,
113113
bool reverseHeader = false,
114+
GrowthDirection growthDirection = GrowthDirection.forward,
114115
required bool allowOverflow,
115116
}) async {
116117
assert(textDirection != null || axis == Axis.vertical);
117118
final headerAtCoordinateEnd = switch (axis) {
118119
Axis.horizontal => reverseHeader ^ (textDirection == TextDirection.rtl),
119120
Axis.vertical => reverseHeader,
120121
};
122+
final reverseGrowth = (growthDirection == GrowthDirection.reverse);
121123

122124
final controller = ScrollController();
125+
const listKey = ValueKey("list");
126+
const emptyKey = ValueKey("empty");
123127
await tester.pumpWidget(Directionality(
124128
textDirection: textDirection ?? TextDirection.rtl,
125-
child: StickyHeaderListView(
129+
child: CustomScrollView(
126130
controller: controller,
127131
scrollDirection: axis,
128132
reverse: reverse,
129-
reverseHeader: reverseHeader,
130-
children: List.generate(100, (i) => StickyHeaderItem(
131-
allowOverflow: allowOverflow,
132-
header: _Header(i, height: 20),
133-
child: _Item(i, height: 100))))));
134-
135-
final overallSize = tester.getSize(find.byType(StickyHeaderListView));
133+
anchor: reverseGrowth ? 1.0 : 0.0,
134+
center: reverseGrowth ? emptyKey : listKey,
135+
slivers: [
136+
SliverStickyHeaderList(
137+
key: listKey,
138+
headerPlacement: (reverseHeader ^ reverse)
139+
? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart,
140+
delegate: SliverChildListDelegate(
141+
List.generate(100, (i) => StickyHeaderItem(
142+
allowOverflow: allowOverflow,
143+
header: _Header(i, height: 20),
144+
child: _Item(i, height: 100))))),
145+
const SliverPadding(
146+
key: emptyKey,
147+
padding: EdgeInsets.zero),
148+
])));
149+
150+
final overallSize = tester.getSize(find.byType(CustomScrollView));
136151
final extent = overallSize.onAxis(axis);
137152
assert(extent % 100 == 0);
138153

@@ -143,7 +158,7 @@ Future<void> _checkSequence(
143158
(extent / 2 - inset) * (headerAtCoordinateEnd ? 1 : -1));
144159
}
145160

146-
final first = !(reverse ^ reverseHeader);
161+
final first = !(reverse ^ reverseHeader ^ reverseGrowth);
147162

148163
final itemFinder = first ? find.byType(_Item).first : find.byType(_Item).last;
149164

@@ -155,10 +170,11 @@ Future<void> _checkSequence(
155170

156171
Future<void> checkState() async {
157172
// Check the header comes from the expected item.
158-
final scrollOffset = controller.position.pixels;
173+
final scrollOffset = controller.position.pixels * (reverseGrowth ? -1 : 1);
159174
final expectedHeaderIndex = first
160175
? (scrollOffset / 100).floor()
161176
: (extent ~/ 100 - 1) + (scrollOffset / 100).ceil();
177+
// print("$scrollOffset, $extent, $expectedHeaderIndex");
162178
check(tester.widget<_Item>(itemFinder).index).equals(expectedHeaderIndex);
163179
check(_headerIndex(tester)).equals(expectedHeaderIndex);
164180

@@ -180,7 +196,7 @@ Future<void> _checkSequence(
180196
}
181197

182198
Future<void> jumpAndCheck(double position) async {
183-
controller.jumpTo(position);
199+
controller.jumpTo(position * (reverseGrowth ? -1 : 1));
184200
await tester.pump();
185201
await checkState();
186202
}

0 commit comments

Comments
 (0)