New In Flutter 3 Sliver Axis Groups
We now have axis groups in slivers to group dissimilar slivers together
Before Flutter 3, we did not have a good way to group dissimilar slivers into a group. New in Flutter 3 is sliver axis groups to allow us to group dissimilar slivers together. Let me show you how to use these new sliver widgets.
What Is A Sliver?
A sliver is a portion of a scrollable area that you can define with specific behavior. Some of this define behavior can be custom scrolling effects, for example elastic scrolling.
It's a lower-level interface to offer control on scrollable areas. And, because all slivers are lazy built only when scrolled into view, it makes it easy to do huge lists of items and still have 60 FPS in rendering.
ListView and GridView use slivers.
With that brief introduction to slivers let's see how to use the new sliver axis group widgets.
Sliver Axis Group Widgets
First, what we will build is this:
So let me show some code to use both widgets and then a full code example.
SliverMainAxisGroup is a sliver that places multiple sliver children in a linear array along the main axis. A typical use is:
CustomScrollView( | |
slivers: [ | |
SliverMainAxisGroup(slivers: [ | |
SliverPersistentHeader( | |
delegate: HeaderDelegate(title: 'SliverList'), | |
pinned: true, | |
), | |
SliverPadding( | |
padding: EdgeInsets.all(16), | |
sliver: decoratedSliverList(), | |
), | |
]), | |
SliverMainAxisGroup(slivers: [ | |
SliverPersistentHeader( | |
delegate: HeaderDelegate(title: 'SliverGrid'), | |
pinned: true, | |
), | |
sliverGrid(10) | |
]), | |
], | |
), | |
Widget decoratedSliverList() { | |
return DecoratedSliver( | |
position: DecorationPosition.background, | |
decoration: BoxDecoration( | |
color: Colors.yellow, | |
), | |
sliver: SliverList.separated( | |
itemBuilder: (_, int index) => | |
Container(height: 50, child: Center(child: Text('Item $index'))), | |
separatorBuilder: (_, __) => Divider(), | |
itemCount: 15, | |
), | |
); | |
} | |
} |
Simple, right? Each SliverMainAxxisGroup contains a SliverPersistentHeader with a delegate that creates as sticky header.
Then a SliverCrossAxisGroup code example is:
SliverCrossAxisGroup(slivers: [ | |
SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
]), |
But, what if we want to divide up the cross axis constraint differently? Flutter 3 introduces tow new slivers, SliverCrossAxisExpanded and SliverConstrainedCrossAxis.
SliverCrossAxisExpanded
SliverCrossAxisGroup(slivers: [ | |
SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
SliverCrossAxisExpanded( | |
flex: 2, | |
sliver: SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
), | |
SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
]), |
This sliver fills along the cross axis.
SliverConstrainedCrossAxis
SliverCrossAxisGroup(slivers: [ | |
SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
SliverCrossAxisExpanded( | |
flex: 2, | |
sliver: SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
), | |
SliverConstrainedCrossAxis( | |
maxExtent: 200, | |
sliver: SliverPadding( | |
padding: const EdgeInsets.all(16), | |
sliver: decoratedSliverList()), | |
), | |
]), |
The SliverConstrainedCrossAxis acts like a ConstrainedBox allowing the constraining of the cross axis.
Now for a full app demo.
Full App Demo
The home screen is the ChocolatePage class:
import 'package:flutter/material.dart'; | |
import 'package:new_slivers/sliver_appbar_delegate.dart'; | |
class ChocolatePage extends StatefulWidget { | |
const ChocolatePage({super.key}); | |
@override | |
State<ChocolatePage> createState() => _ChocolatePageState(); | |
} | |
class _ChocolatePageState extends State<ChocolatePage> { | |
late final ScrollController scrollController; | |
double scrollPercentage=0.0; | |
final double maxHeight = 600; | |
final double minHeight = 250; | |
final double initialRadius=130; | |
final double finalRadius= 60; | |
@override | |
void initState() { | |
super.initState(); | |
scrollController= ScrollController(); | |
scrollController.addListener(onScroll); | |
} | |
void onScroll() { | |
double offset= scrollController.offset; | |
double maxOffset=350; | |
setState(() { | |
scrollPercentage=(offset/maxOffset).clamp(0.0, 1.0); | |
}); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
body: Stack( | |
children: [ | |
CustomScrollView( | |
controller: scrollController, | |
slivers: [ | |
SliverPersistentHeader( | |
pinned: true, | |
floating: true, | |
delegate: SliverAppbarDelegate( | |
maxHeight: maxHeight / 2, | |
minHeight: 100, | |
child: Container( | |
decoration: const BoxDecoration( | |
gradient: LinearGradient( | |
colors: [ | |
Color(0xff4b5de0), | |
Color(0xff1e0c98), | |
] | |
) | |
), | |
) | |
), | |
), | |
SliverMainAxisGroup( | |
slivers: [ | |
SliverPersistentHeader( | |
pinned: true, | |
delegate: SliverAppbarDelegate( | |
maxHeight: maxHeight / 2, | |
minHeight: 250, | |
child: Container( | |
color: Colors.white, | |
child: const Padding( | |
padding: EdgeInsets.only(left: 16.0,bottom: 16), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Spacer(), | |
Text('Chips',style: TextStyle(fontSize: 40,fontWeight: FontWeight.w700),), | |
SizedBox(height: 20,), | |
Text('\$12.99 ',style: TextStyle(color:Color(0xff1e0c98),fontWeight: FontWeight.w600,fontSize: 30),) | |
], | |
), | |
), | |
) | |
), | |
), | |
recipeDetail() | |
], | |
), | |
], | |
), | |
Positioned( | |
top: (((maxHeight)/2)-initialRadius)*(1-scrollPercentage), | |
right: (MediaQuery.of(context).size.width/2-initialRadius)*(1-scrollPercentage), | |
child: Transform.scale( | |
scale: (initialRadius-(initialRadius-finalRadius)*scrollPercentage)/100, | |
child: CircleAvatar( | |
backgroundImage: const AssetImage('assets/chips.jpg'), | |
radius: initialRadius, | |
), | |
)) | |
], | |
), | |
); | |
} | |
} |
A typical stacked way of animating the SliverAppbar header. The SliverAppbarDelegate than will be:
import 'package:flutter/material.dart'; | |
class SliverAppbarDelegate extends SliverPersistentHeaderDelegate { | |
final double maxHeight; | |
final double minHeight; | |
final Widget child; | |
SliverAppbarDelegate( | |
{Key? key, required this.maxHeight, required this.minHeight, required this.child}); | |
@override | |
Widget build(BuildContext context, double shrinkOffset, | |
bool overlapsContent) => SizedBox.expand(child: child,); | |
@override | |
double get maxExtent => maxHeight; | |
@override | |
double get minExtent => minHeight; | |
@override | |
bool shouldRebuild(SliverAppbarDelegate oldDelegate) { | |
return maxHeight != oldDelegate.maxHeight || | |
minHeight != oldDelegate.minHeight || | |
child != oldDelegate.child; | |
} | |
} | |
Widget recipeDetail() { | |
return SliverToBoxAdapter( | |
child: Padding( | |
padding: const EdgeInsets.all(16.0), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
const SizedBox( | |
height: 16, | |
), | |
const Divider( | |
height: 5, | |
color: Colors.black45, | |
), | |
const SizedBox( | |
height: 16, | |
), | |
const Text( | |
"Details", | |
style: TextStyle(fontSize: 20, color: Colors.grey), | |
), | |
const SizedBox( | |
height: 16, | |
), | |
const Text(loreamIpsumText, style: TextStyle(fontSize: 15),), | |
const SizedBox( | |
height: 16, | |
), | |
const Divider( | |
height: 2, | |
), | |
const SizedBox( | |
height: 16, | |
), | |
const Text( | |
"Ingredients", | |
style: TextStyle(fontSize: 20, color: Colors.grey), | |
), | |
const SizedBox( | |
height: 16, | |
), | |
...List.generate( | |
ingredients.length, | |
(index) => | |
Padding( | |
padding: const EdgeInsets.all(16.0), | |
child: Text(ingredients[index].toString()), | |
)), | |
], | |
), | |
), | |
); | |
} | |
const String loreamIpsumText = | |
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; | |
const List<String> ingredients = [ | |
"Vanilla", | |
"Almond Flour", | |
"Butter", | |
"Cream", | |
"Eggs", | |
"Chocolates", | |
"Peanut", | |
"Chocolates", | |
"Chocolates", | |
"Chocolates", | |
"Chocolates", | |
"Chocolates", | |
"Chocolates", | |
"Chocolates", | |
"Chocolates", | |
"Chocolates", | |
"Chocolates", | |
]; |
Thoughts
So that is the new sliver widgets that came out in Flutter 3. There might be some internal Google work on sidesheets as in the modal, standard, and bottom sheets called for in the Material Design 3 spec. When I find out more, I will share with you and how to use them.
Fred Grott's Newsletter
Code: Flutter Bytes Hub
Demos-at-YouTube: My YouTube Channel
An ADHD'ers super focus on Flutter app design and the side hustle of designing flutter apps. Delivered, several days every weekday to your inbox.