From 9ce0a9e510444fcf2ef7de6fd6d7fd28ef63d6cf Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Fri, 7 Jun 2024 14:27:06 -0700 Subject: [PATCH] PinnedHeaderSliver (#143196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sliver that remains “pinned” to the top of the scroll view. Subsequent slivers scroll behind it. Typically the sliver is created as the first item in the list however it can be inserted anywhere and it will always stop at the top of the scroll view. When the scrolling axis is vertical, the PinnedHeaderSliver’s height is defined by its widget child. Multiple PinnedHeaderSlivers will layout one after the other, once they've scrolled to the top. This sliver is preferable to the general purpose SliverPersistentHeader - for its relatively narrow use case - because there's no need to create a [SliverPersistentHeaderDelegate] or to predict the header's size. Here's a [working demo in DartPad](https://dartpad.dev/?id=3b3f24c14fa201f752407a21ca9c9456). https://github.com/flutter/flutter/assets/1377460/943f2e02-8e73-48b7-90be-61168978ff71 Related sliver utility PRs: https://github.com/flutter/flutter/pull/143538, https://github.com/flutter/flutter/pull/143325, https://github.com/flutter/flutter/pull/127340. --- .../sliver/pinned_header_sliver.0.dart | 129 +++++++++ .../sliver/pinned_header_sliver.0_test.dart | 21 ++ .../lib/src/widgets/pinned_header_sliver.dart | 83 ++++++ .../src/widgets/sliver_resizing_header.dart | 8 +- packages/flutter/lib/widgets.dart | 1 + .../widgets/pinned_header_sliver_test.dart | 265 ++++++++++++++++++ 6 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart create mode 100644 examples/api/test/widgets/sliver/pinned_header_sliver.0_test.dart create mode 100644 packages/flutter/lib/src/widgets/pinned_header_sliver.dart create mode 100644 packages/flutter/test/widgets/pinned_header_sliver_test.dart diff --git a/examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart b/examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart new file mode 100644 index 0000000000..5eacfca82a --- /dev/null +++ b/examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart @@ -0,0 +1,129 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for [PinnedHeaderSliver]. + +void main() { + runApp(const PinnedHeaderSliverApp()); +} + +class PinnedHeaderSliverApp extends StatelessWidget { + const PinnedHeaderSliverApp({ super.key }); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: PinnedHeaderSliverExample(), + ); + } +} + +class PinnedHeaderSliverExample extends StatefulWidget { + const PinnedHeaderSliverExample({ super.key }); + + @override + State createState() => _PinnedHeaderSliverExampleState(); +} + +class _PinnedHeaderSliverExampleState extends State { + int count = 0; + late final ScrollController scrollController; + + @override + void initState() { + super.initState(); + scrollController = ScrollController(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + final Widget header = Container( + color: colorScheme.background, + padding: const EdgeInsets.all(4), + child: Material( + color: colorScheme.primaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + width: 7, + color: colorScheme.outline, + ), + ), + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 48), + child: Text( + count.isOdd ? 'Alternative Title\nWith Two Lines' : 'PinnedHeaderSliver', + style: theme.textTheme.headlineMedium!.copyWith( + color: colorScheme.onPrimaryContainer, + ), + ), + ), + ), + ); + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: CustomScrollView( + controller: scrollController, + slivers: [ + PinnedHeaderSliver(child: header), + const ItemList(), + ], + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + count += 1; + }); + }, + child: const Icon(Icons.add), + ), + ); + } +} + +// A placeholder SliverList of 25 items. +class ItemList extends StatelessWidget { + const ItemList({ + super.key, + this.itemCount = 25, + }); + + final int itemCount; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return Card( + color: colorScheme.onSecondary, + child: ListTile( + textColor: colorScheme.secondary, + title: Text('Item $index'), + ), + ); + }, + childCount: itemCount, + ), + ); + } +} diff --git a/examples/api/test/widgets/sliver/pinned_header_sliver.0_test.dart b/examples/api/test/widgets/sliver/pinned_header_sliver.0_test.dart new file mode 100644 index 0000000000..ac5dfbcacd --- /dev/null +++ b/examples/api/test/widgets/sliver/pinned_header_sliver.0_test.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/sliver/pinned_header_sliver.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('PinnedHeaderSliver example', (WidgetTester tester) async { + await tester.pumpWidget( + const example.PinnedHeaderSliverApp(), + ); + + expect(find.text('PinnedHeaderSliver'), findsOneWidget); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + expect(find.text('Alternative Title\nWith Two Lines'), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/src/widgets/pinned_header_sliver.dart b/packages/flutter/lib/src/widgets/pinned_header_sliver.dart new file mode 100644 index 0000000000..61f337eee8 --- /dev/null +++ b/packages/flutter/lib/src/widgets/pinned_header_sliver.dart @@ -0,0 +1,83 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +import 'framework.dart'; + +/// A sliver that keeps its Widget child at the top of the a [CustomScrollView]. +/// +/// This sliver is preferable to the general purpose [SliverPersistentHeader] +/// for its relatively narrow use case because there's no need to create a +/// [SliverPersistentHeaderDelegate] or to predict the header's size. +/// +/// {@tool dartpad} +/// This example demonstrates that the sliver's size can change. Pressing the +/// floating action button replaces the one line of header text with two lines. +/// +/// ** See code in examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart ** +/// {@end-tool} +/// +/// +/// See also: +/// +/// * [SliverResizingHeader] - which similarly pins the header at the top +/// of the [CustomScrollView] but reacts to scrolling by resizing the header +/// between its minimum and maximum extent limits. +/// * [SliverPersistentHeader] - a general purpose header that can be +/// configured as a pinned, resizing, or floating header. +class PinnedHeaderSliver extends SingleChildRenderObjectWidget { + /// Creates a sliver whose [Widget] child appears at the top of a + /// [CustomScrollView]. + const PinnedHeaderSliver({ + super.key, + super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderPinnedHeaderSliver(); + } +} + +class _RenderPinnedHeaderSliver extends RenderSliverSingleBoxAdapter { + _RenderPinnedHeaderSliver(); + + double get childExtent { + if (child == null) { + return 0.0; + } + assert(child!.hasSize); + return switch (constraints.axis) { + Axis.vertical => child!.size.height, + Axis.horizontal => child!.size.width, + }; + } + + @override + double childMainAxisPosition(covariant RenderObject child) => 0; + + @override + void performLayout() { + final SliverConstraints constraints = this.constraints; + child?.layout(constraints.asBoxConstraints(), parentUsesSize: true); + + final double layoutExtent = clampDouble(childExtent - constraints.scrollOffset, 0, constraints.remainingPaintExtent); + final double paintExtent = math.min(childExtent, constraints.remainingPaintExtent - constraints.overlap); + geometry = SliverGeometry( + scrollExtent: childExtent, + paintOrigin: constraints.overlap, + paintExtent: paintExtent, + layoutExtent: layoutExtent, + maxPaintExtent: childExtent, + maxScrollObstructionExtent: childExtent, + cacheExtent: calculateCacheOffset(constraints, from: 0.0, to: childExtent), + hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity. + ); + } +} diff --git a/packages/flutter/lib/src/widgets/sliver_resizing_header.dart b/packages/flutter/lib/src/widgets/sliver_resizing_header.dart index 6ab57c0988..38a32ad2c1 100644 --- a/packages/flutter/lib/src/widgets/sliver_resizing_header.dart +++ b/packages/flutter/lib/src/widgets/sliver_resizing_header.dart @@ -36,7 +36,13 @@ import 'slotted_render_object_widget.dart'; /// /// ** See code in examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart ** /// {@end-tool} -// TODO(hansmuller): add See also links to PersistentHeaderSliver, SliverFloatingHeader, etc +/// +/// See also: +/// +/// * [PinnedHeaderSliver] - which just pins the header at the top +/// of the [CustomScrollView]. +/// * [SliverPersistentHeader] - a general purpose header that can be +/// configured as a pinned, resizing, or floating header. class SliverResizingHeader extends StatelessWidget { /// Create a pinned header sliver that reacts to scrolling by resizing between /// the intrinsic sizes of the min and max extent prototypes. diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index ee4e4ba451..9cda8eeba5 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -93,6 +93,7 @@ export 'src/widgets/page_storage.dart'; export 'src/widgets/page_view.dart'; export 'src/widgets/pages.dart'; export 'src/widgets/performance_overlay.dart'; +export 'src/widgets/pinned_header_sliver.dart'; export 'src/widgets/placeholder.dart'; export 'src/widgets/platform_menu_bar.dart'; export 'src/widgets/platform_selectable_region_context_menu.dart'; diff --git a/packages/flutter/test/widgets/pinned_header_sliver_test.dart b/packages/flutter/test/widgets/pinned_header_sliver_test.dart new file mode 100644 index 0000000000..9cdd423df3 --- /dev/null +++ b/packages/flutter/test/widgets/pinned_header_sliver_test.dart @@ -0,0 +1,265 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('PinnedHeaderSliver basics', (WidgetTester tester) async { + Widget buildFrame({ required Axis axis, required bool reverse }) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + scrollDirection: axis, + reverse: reverse, + slivers: [ + const PinnedHeaderSliver( + child: Text('PinnedHeaderSliver'), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item $index'), + childCount: 100, + ), + ), + ], + ), + ), + ); + } + + Rect getHeaderRect() => tester.getRect(find.text('PinnedHeaderSliver')); + Rect getItemRect(int index) => tester.getRect(find.text('Item $index')); + + // axis: Axis.vertical, reverse: false + { + await tester.pumpWidget(buildFrame(axis: Axis.vertical, reverse: false)); + await tester.pumpAndSettle(); + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + + // The test viewport is 800 x 600 (width x height). + // The header's child is at the top of the scroll view and all items are the same height. + expect(getHeaderRect().topLeft, Offset.zero); + expect(getHeaderRect().width, 800); + expect(getHeaderRect().height, tester.getSize(find.text('PinnedHeaderSliver')).height); + + // First and last visible items + final double itemHeight = getItemRect(0).height; + final int visibleItemCount = (600 ~/ itemHeight) - 1; // less 1 for the header + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item ${visibleItemCount - 1}'), findsOneWidget); + + // Scrolling up and down leaves the header at the top. + position.moveTo(itemHeight * 5); + await tester.pumpAndSettle(); + expect(getHeaderRect().top, 0); + expect(getHeaderRect().width, 800); + position.moveTo(itemHeight * -5); + expect(getHeaderRect().top, 0); + expect(getHeaderRect().width, 800); + } + + // axis: Axis.horizontal, reverse: false + { + await tester.pumpWidget(buildFrame(axis: Axis.horizontal, reverse: false)); + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + await tester.pumpAndSettle(); + + expect(getHeaderRect().topLeft, Offset.zero); + expect(getHeaderRect().height, 600); + expect(getHeaderRect().width, tester.getSize(find.text('PinnedHeaderSliver')).width); + + // First and last visible items (assuming < 10 items visible) + final double itemWidth = getItemRect(0).width; + final int visibleItemCount = (800 - getHeaderRect().width) ~/ itemWidth; + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item ${visibleItemCount - 1}'), findsOneWidget); + + // Scrolling left and right leaves the header on the left. + position.moveTo(itemWidth * 5); + await tester.pumpAndSettle(); + expect(getHeaderRect().left, 0); + expect(getHeaderRect().height, 600); + position.moveTo(itemWidth * -5); + expect(getHeaderRect().left, 0); + expect(getHeaderRect().height, 600); + } + + // axis: Axis.vertical, reverse: true + { + await tester.pumpWidget(buildFrame(axis: Axis.vertical, reverse: true)); + await tester.pumpAndSettle(); + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + + expect(getHeaderRect().bottomLeft, const Offset(0, 600)); + expect(getHeaderRect().width, 800); + expect(getHeaderRect().height, tester.getSize(find.text('PinnedHeaderSliver')).height); + + // First and last visible items + final double itemHeight = getItemRect(0).height; + final int visibleItemCount = (600 ~/ itemHeight) - 1; // less 1 for the header + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item ${visibleItemCount - 1}'), findsOneWidget); + + // Scrolling up and down leaves the header at the bottom. + position.moveTo(itemHeight * 5); + await tester.pumpAndSettle(); + expect(getHeaderRect().bottomLeft, const Offset(0, 600)); + expect(getHeaderRect().width, 800); + position.moveTo(itemHeight * -5); + expect(getHeaderRect().bottomLeft, const Offset(0, 600)); + expect(getHeaderRect().width, 800); + } + + // axis: Axis.horizontal, reverse: true + { + await tester.pumpWidget(buildFrame(axis: Axis.horizontal, reverse: true)); + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + await tester.pumpAndSettle(); + + expect(getHeaderRect().topRight, const Offset(800, 0)); + expect(getHeaderRect().height, 600); + expect(getHeaderRect().width, tester.getSize(find.text('PinnedHeaderSliver')).width); + + // First and last visible items (assuming < 10 items visible) + final double itemWidth = getItemRect(0).width; + final int visibleItemCount = (800 - getHeaderRect().width) ~/ itemWidth; + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item ${visibleItemCount - 1}'), findsOneWidget); + + // Scrolling left and right leaves the header on the right. + position.moveTo(itemWidth * 5); + await tester.pumpAndSettle(); + expect(getHeaderRect().topRight, const Offset(800, 0)); + expect(getHeaderRect().height, 600); + position.moveTo(itemWidth * -5); + expect(getHeaderRect().topRight, const Offset(800, 0)); + expect(getHeaderRect().height, 600); + } + }); + + testWidgets('PinnedHeaderSliver: multiple headers layout one after the other', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + const PinnedHeaderSliver( + child: Text('PinnedHeaderSliver 0'), + ), + const PinnedHeaderSliver( + child: Text('PinnedHeaderSliver 1'), + ), + const PinnedHeaderSliver( + child: Text('PinnedHeaderSliver 2'), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item $index'), + childCount: 100, + ), + ), + ], + ), + ), + ), + ); + + final Rect rect0 = tester.getRect(find.text('PinnedHeaderSliver 0')); + expect(rect0.top, 0); + expect(rect0.width, 800); + + final Rect rect1 = tester.getRect(find.text('PinnedHeaderSliver 1')); + expect(rect1.top, rect0.bottom); + expect(rect1.width, 800); + + final Rect rect2 = tester.getRect(find.text('PinnedHeaderSliver 2')); + expect(rect2.top, rect1.bottom); + expect(rect2.width, 800); + }); + + testWidgets('PinnedHeaderSliver: headers that do not start at the top', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item 0.$index'), + childCount: 2, + ), + ), + const PinnedHeaderSliver( + child: Text('PinnedHeaderSliver 0'), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item 1.$index'), + childCount: 2, + ), + ), + const PinnedHeaderSliver( + child: Text('PinnedHeaderSliver 1'), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item 2.$index'), + childCount: 2, + ), + ), + const PinnedHeaderSliver( + child: Text('PinnedHeaderSliver 2'), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item $index'), + childCount: 100, + ), + ), + ], + ), + ), + ), + ); + + final double itemHeight = tester.getSize(find.text('Item 0.0')).height; + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + + // Scroll 'Item 0.0' and 'Item 0.1' off the top + position.moveTo(itemHeight * 2); + await tester.pumpAndSettle(); + + // That leaves 'PinnedHeaderSliver 0' at the top + final Rect rect0 = tester.getRect(find.text('PinnedHeaderSliver 0')); + expect(rect0.top, 0); + expect(rect0.width, 800); + + // Scroll 'Item 1.0' and 'Item 1.1' behind 'PinnedHeaderSliver 0' + position.moveTo(itemHeight * 4); + await tester.pumpAndSettle(); + + // That leaves 'PinnedHeaderSliver 1' below 'PinnedHeaderSliver 0' + final Rect rect1 = tester.getRect(find.text('PinnedHeaderSliver 1')); + expect(rect1.top, rect0.bottom); + expect(rect1.width, 800); + + // Scroll 'Item 2.0' and 'Item 2.1' behind 'PinnedHeaderSliver 1' + position.moveTo(itemHeight * 6); + await tester.pumpAndSettle(); + + // That leaves 'PinnedHeaderSliver 2' below 'PinnedHeaderSliver 1' + final Rect rect2 = tester.getRect(find.text('PinnedHeaderSliver 2')); + expect(rect2.top, rect1.bottom); + expect(rect2.width, 800); + + // Scroll some more. The headers are already as close to the top as they + // can go - they will not have moved. + position.moveTo(itemHeight * 10); + await tester.pumpAndSettle(); + expect(tester.getRect(find.text('PinnedHeaderSliver 0')), rect0); + expect(tester.getRect(find.text('PinnedHeaderSliver 1')), rect1); + expect(tester.getRect(find.text('PinnedHeaderSliver 2')), rect2); + }); +}