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(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); 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 _checkSequence( @@ -174,7 +284,6 @@ Future _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);