mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
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:
parent
143847626d
commit
abd7cd0528
204
examples/api/lib/widgets/sliver/pinned_header_sliver.1.dart
Normal file
204
examples/api/lib/widgets/sliver/pinned_header_sliver.1.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
});
|
||||
}
|
@ -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:
|
||||
///
|
||||
|
Loading…
Reference in New Issue
Block a user