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); + }); +}