Animate SliverAppBar's widgets when scrolling
Have you ever created something like this?
Full-screen:
TOC
Feeling too long to dig in, soย
"Jump to recipe"
!
Getting Started
Let's start with the above screen first and ignore unimportant things!
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SafeArea(
child: ColoredBox(
color: primaryColor,
child: SliverScaffold(
body: ListView.separated(...),
),
),
);
}
}
There is nothing new here ๐
๐ but the SliverScaffold
widget. This widget is responsible for building SliverAppBar and takes a widget as the body. Here is the widget:
class SliverScaffold extends StatefulWidget {
const SliverScaffold({Key? key, required this.body}) : super(key: key);
final Widget body;
@override
_SliverScaffoldState createState() => _SliverScaffoldState();
}
class _SliverScaffoldState extends State<SliverScaffold> {
final ScrollController _scrollController = ScrollController();
// 0.0 -> Expanded
double currentExtent = 0.0;
double get minExtent => 0.0;
double get maxExtent => _scrollController.position.maxScrollExtent;
double get deltaExtent => maxExtent - minExtent;
@override
Widget build(BuildContext context) {
return NestedScrollView(
controller: _scrollController,
headerSliverBuilder: (_, __) => [
SliverAppBar(
... // I'll show this in the next section
),
],
body: widget.body,
);
}
}
Inspecting design ๐
Look at the picture below:
Left image: Initial state, SliverAppBar is expanded.
Right image: SliverAppBar is collapsed.
Now, I'll do some calculations ๐งฎ for you.
1. Buttons Spacing
As you can see, the spacing of each button to the screen's edge changes from 24 to 0 as SliverAppBar's state is collapsed.
Now, we have to describe it in our code. Tween is the best choice.
final Tween<double> actionSpacingTween = Tween(begin: 24, end: 0);
And we need a variable to save the current state too (just like currentExtent
). Its initial value is 24 (expanded state).
double actionSpacing = 24;
2. Icons Stroke Width
Did you notice the stroke width of the two icons changed in each state? If you don't, have a look at the picture above again.
Here is the tween:
double iconStrokeWidth = 1.8;
final Tween<double> iconStrokeWidthTween = Tween(begin: 1.8, end: 1.2);
But, how can we animate these icons? ๐ค
The solution is... using CustomPainter
.
So, I used this website to convert SVG
images to CustomPainter
code. Then, editing a bit of the code...
...
const NotificationIconPainter(this.strokeWidth);
// Add a variable to control
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
...
Paint paint_0_stroke = Paint() // generated code
..strokeWidth = strokeWidth
// ^^^^^^^^^^^ --> replaced with the variable above
...
}
And here is our SliverAppBar
's buttons:
leading: Row(
children: [
SizedBox(width: actionSpacing),
IconButton(
onPressed: () {},
splashRadius: 24,
icon: CategoryIconPainter.getCustomPaint(iconStrokeWidth),
),
],
),
actions: [
IconButton(
onPressed: () {},
splashRadius: 24,
icon: NotificationIconPainter.getCustomPaint(iconStrokeWidth),
),
SizedBox(width: actionSpacing),
],
3. The search bar
I'll make this short. Again, here is the tween:
double titlePaddingHorizontal = 16;
final Tween<double> titlePaddingHorizontalTween = Tween(begin: 16, end: 48);
double titlePaddingTop = 74;
final Tween<double> titlePaddingTopTween = Tween(begin: 74, end: 12);
I named the search bar as title because of its position when SliverAppBar collapsed.
And here is SliverAppBar's flexibleSpace
:
flexibleSpace: FlexibleSpaceBar.createSettings(
...
child: FlexibleSpaceBar(
titlePadding: EdgeInsets.only(
top: titlePaddingTop,
left: titlePaddingHorizontal,
right: titlePaddingHorizontal,
),
centerTitle: true,
title: Column(
children: [
SizedBox(...), // the search bar
const Spacer(),
],
),
),
),
"The recipe"
So, we transformed all states to Tween<double>
.
Before we start, let's think about how we transform the current scroll position to each state.
Or, we just need an answer from StackOverflow
So, this is what I have:
double _remapCurrentExtent(Tween<double> target) {
final double deltaTarget = target.end! - target.begin!;
double currentTarget =
(((currentExtent - minExtent) * deltaTarget) / deltaExtent) +
target.begin!;
return currentTarget;
}
Now, create a listener to listen to scroll changes and update all widget's states.
_scrollListener() {
setState(() {
currentExtent = _scrollController.offset;
actionSpacing = _remapCurrentExtent(actionSpacingTween);
iconStrokeWidth = _remapCurrentExtent(iconStrokeWidthTween);
titlePaddingHorizontal = _remapCurrentExtent(titlePaddingHorizontalTween);
titlePaddingTop = _remapCurrentExtent(titlePaddingTopTween);
titlePaddingBottom = _remapCurrentExtent(titlePaddingBottomTween);
});
}
Then, add a listener to scrollController
...
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
}
Output:
That seems to be working! ๐
๐ค The search bar should be going faster to avoid overflow.
Maybe adding a curve will solve the problem??! ๐ง
Let's try it!
Add a curve to our _SliverScaffoldState
:
Curve get curve => Curves.easeOutCubic;
And edit _remapCurrentExtent()
function:
double _remapCurrentExtent(Tween<double> target) {
final double deltaTarget = target.end! - target.begin!;
double currentTarget =
(((currentExtent - minExtent) * deltaTarget) / deltaExtent) +
target.begin!;
double t = (currentTarget - target.begin!) / deltaTarget;
double curveT = curve.transform(t);
return lerpDouble(target.begin!, target.end!, curveT)!;
}
Final result:
I hope you like this article. Comment down below if you have any questions or suggestions.
You can check out the full code in this repository.
Thank you for reading!