// 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, ), ); } }