diff --git a/examples/api/lib/widgets/sliver/pinned_header_sliver.1.dart b/examples/api/lib/widgets/sliver/pinned_header_sliver.1.dart new file mode 100644 index 00000000000..a340e5425e2 --- /dev/null +++ b/examples/api/lib/widgets/sliver/pinned_header_sliver.1.dart @@ -0,0 +1,204 @@ +// 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/rendering.dart'; + +void main() { + runApp(const SettingsAppBarApp()); +} + +class SettingsAppBarApp extends StatelessWidget { + const SettingsAppBarApp({ super.key }); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: SettingsAppBarExample()); + } +} + +class SettingsAppBarExample extends StatefulWidget { + const SettingsAppBarExample({ super.key }); + + @override + State createState() => _SettingsAppBarExampleState(); +} + +class _SettingsAppBarExampleState extends State { + final GlobalKey headerSliverKey = GlobalKey(); + final GlobalKey titleSliverKey = GlobalKey(); + late final ScrollController scrollController; + double headerOpacity = 0; + + @override + void initState() { + super.initState(); + scrollController = ScrollController(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + // The key must be for a widget _below_ a RenderSliver so that + // findAncestorRenderObjectOfType can find the RenderSliver when it searches + // the key widget's renderer ancesotrs. + RenderSliver? keyToSliver(GlobalKey key) => key.currentContext?.findAncestorRenderObjectOfType(); + + // Each time the app's list scrolls: if the Title sliver has scrolled completely behind + // the (pinned) header sliver, then change the header's opacity from 0 to 1. + // + // The header RenderSliver's SliverConstraints.scrollOffset is the distance + // above the top of the viewport where the top of header sliver would appear + // if it were laid out normally. Since it's a pinned sliver, it's unconditionally + // painted at the top of the viewport, even though its scrollOffset constraint + // increases as the user scrolls upwards. The "Settings" title RenderSliver's + // scrollExtent is the vertical space it wants to occupy. It doesn't change as + // the user scrolls. + bool handleScrollNotification(ScrollNotification notification) { + final RenderSliver? headerSliver = keyToSliver(headerSliverKey); + final RenderSliver? titleSliver = keyToSliver(titleSliverKey); + if (headerSliver != null && titleSliver != null && titleSliver.geometry != null) { + final double opacity = headerSliver.constraints.scrollOffset > titleSliver.geometry!.scrollExtent ? 1 : 0; + if (opacity != headerOpacity) { + setState(() { + headerOpacity = opacity; + }); + } + } + return false; + } + + @override + Widget build(BuildContext context) { + const EdgeInsets horizontalPadding = EdgeInsets.symmetric(horizontal: 8); + final ThemeData theme = Theme.of(context); + final TextTheme textTheme = theme.textTheme; + final ColorScheme colorScheme = theme.colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surfaceContainer, + body: SafeArea( + child: NotificationListener( + onNotification: handleScrollNotification, + child: CustomScrollView( + controller: scrollController, + slivers: [ + PinnedHeaderSliver( + child: Header( + key: headerSliverKey, + opacity: headerOpacity, + child: Text('Settings', style: textTheme.titleMedium), + ), + ), + SliverPadding( + padding: horizontalPadding, + sliver: SliverToBoxAdapter( + child: TitleItem( + key: titleSliverKey, + child: Text( + 'Settings', + style: textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.bold, + fontSize: 32, + ), + ), + ), + ), + ), + const SliverPadding( + padding: horizontalPadding, + sliver: ItemList(), + ), + ], + ), + ), + ), + ); + } +} + +// The pinned item at the top of the list. This is an implicitly +// animated widget: when the opacity changes the title and divider +// fade in or out. +class Header extends StatelessWidget { + const Header({ super.key, required this.opacity, required this.child }); + + final double opacity; + final Widget child; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: ShapeDecoration( + color: opacity == 0 ? colorScheme.surfaceContainer : colorScheme.surfaceContainerLowest, + shape: LinearBorder.bottom( + side: BorderSide( + color: opacity == 0 ? colorScheme.surfaceContainer : colorScheme.surfaceContainerHighest, + ), + ), + ), + alignment: Alignment.center, + child: AnimatedOpacity( + opacity: opacity, + duration: const Duration(milliseconds: 300), + child: child, + ), + ); + } +} + +// The second item in the list. It scrolls normally. When it has scrolled +// completely out of view behind the first, pinned, Header item, the Header +// fades in. +class TitleItem extends StatelessWidget { + const TitleItem({ super.key, required this.child }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + alignment: AlignmentDirectional.bottomStart, + padding: const EdgeInsets.symmetric(vertical: 8), + child: child, + ); + } +} + +// A placeholder SliverList of 50 items. +class ItemList extends StatelessWidget { + const ItemList({ + super.key, + this.itemCount = 50, + }); + + 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.1_test.dart b/examples/api/test/widgets/sliver/pinned_header_sliver.1_test.dart new file mode 100644 index 00000000000..ad8be0296e0 --- /dev/null +++ b/examples/api/test/widgets/sliver/pinned_header_sliver.1_test.dart @@ -0,0 +1,41 @@ +// 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.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('PinnedHeaderSliver iOS Settings example', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SettingsAppBarApp(), + ); + + // Verify that the app contains a header: a SliverPersistentHeader + // with an AnimatedOpacity widget below it, and a Text('Settings') below that. + expect(find.byType(PinnedHeaderSliver), findsOneWidget); + expect(find.descendant(of: find.byType(PinnedHeaderSliver), matching: find.byType(AnimatedOpacity)), findsOneWidget); + expect(find.widgetWithText(AnimatedOpacity, 'Settings'), findsOneWidget); + + // Verify that the app contains a "Settings" title: a SliverToBoxAdapter + // with a Text('Settings') widget below it. + expect(find.widgetWithText(SliverToBoxAdapter, 'Settings'), findsOneWidget); + + final Finder headerOpacity = find.widgetWithText(AnimatedOpacity, 'Settings'); + expect(tester.widget(headerOpacity).opacity, 0); + + // Scroll up: the header's opacity goes to 1 and the title disappears. + await tester.timedDrag(find.byType(CustomScrollView), const Offset(0, -500), const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + expect(tester.widget(headerOpacity).opacity, 1); + expect(find.widgetWithText(SliverToBoxAdapter, 'Settings'), findsNothing); + + // Scroll back down and we're back to where we started. + await tester.timedDrag(find.byType(CustomScrollView), const Offset(0, 500), const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + expect(tester.widget(headerOpacity).opacity, 0); + expect(find.widgetWithText(SliverToBoxAdapter, 'Settings'), findsOneWidget); + + }); +} diff --git a/packages/flutter/lib/src/widgets/pinned_header_sliver.dart b/packages/flutter/lib/src/widgets/pinned_header_sliver.dart index 662e756647d..03cb41023e3 100644 --- a/packages/flutter/lib/src/widgets/pinned_header_sliver.dart +++ b/packages/flutter/lib/src/widgets/pinned_header_sliver.dart @@ -23,6 +23,20 @@ import 'framework.dart'; /// ** See code in examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// A more elaborate example which creates an app bar that's similar to the one +/// that appears in the iOS Settings app. In this example the pinned header +/// starts out transparent and the first item in the list serves as the app's +/// "Settings" title. When the title item has been scrolled completely behind +/// the pinned header, the header animates its opacity from 0 to 1 and its +/// (centered) "Settings" title appears. The fact that the header's opacity +/// depends on the height of the title item - which is unknown until the list +/// has been laid out - necessitates monitoring the title item's +/// [SliverGeometry.scrollExtent] and the header's [SliverConstraints.scrollOffset] +/// from a scroll [NotificationListener]. See the source code for more details. +/// +/// ** See code in examples/api/lib/widgets/sliver/pinned_header_sliver.1.dart ** +/// {@end-tool} /// /// See also: ///