PinnedHeaderSliver example based on the iOS Settings AppBar (#151205)

A relatively elaborate PinnedSliverHeader 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 its SliverConstraints.scrollExtent from a scroll NotificationListener. 

https://github.com/flutter/flutter/assets/1377460/539e2591-d0d7-4508-8ce8-4b8f587d7648
This commit is contained in:
Hans Muller 2024-07-03 13:06:16 -07:00 committed by GitHub
parent 143847626d
commit abd7cd0528
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 259 additions and 0 deletions

View File

@ -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<SettingsAppBarExample> createState() => _SettingsAppBarExampleState();
}
class _SettingsAppBarExampleState extends State<SettingsAppBarExample> {
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<RenderSliver>();
// 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<ScrollNotification>(
onNotification: handleScrollNotification,
child: CustomScrollView(
controller: scrollController,
slivers: <Widget>[
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,
),
);
}
}

View File

@ -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<AnimatedOpacity>(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<AnimatedOpacity>(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<AnimatedOpacity>(headerOpacity).opacity, 0);
expect(find.widgetWithText(SliverToBoxAdapter, 'Settings'), findsOneWidget);
});
}

View File

@ -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:
///