Flutter SliverAppBar: Snap those headers!
Let’s go on an adventure today.
Our quest is to implement a header with custom snapping behavior.
Something like this:
Getting Started
You can follow the article with the completed code here.
Since ListView doesn’t support scroll effects. To CustomScrollView we go!
First, we should check if there is any prebuilt way to do this.
SliverAppBar looks promising. It even has the snap
property!
- We give the SliverAppBar an
expandedHeight
which will allow theflexibleSpace
widget to expand and collapse within the SliverAppBar. - We turn on the
pinned
andstretch
effects. - We add an infinite load SliverList.
- The status bar height is
MediaQuery.of(context).padding.top
- We have the maximum header height,
maxHeight
, which is 200 plus the status bar height. Therefore, theexpandedHeight
should be only 200 because the SliverAppBar already includes the status bar height internally. - We also have the minimum header height,
minHeight
, which is the constantkToolbarHeight
plus the status bar height.
Usually, the flexibleSpace in the SliverAppBar is occupied by a FlexibleSpaceBar, but in our case, it is not customizable enough. So we will implement our own flexibleSpace widget called Header.
Header will have 2 parameters: maxHeight
and minHeight
, which indicates the header’s maximum height after expanding and minimum height after collapsing.
This widget, by using LayoutBuilder, will have access to the current size constraints given upon it by the parents. This means constraints.maxHeight
will return the current height of the SliverAppBar.
We can then use this information to calculate the expand/collapse ratio.
Because all of the header effects are done with Tween, and Tween is designed to work with an Animation, we should then map the ratio to an AlwaysStoppedAnimation.
Then, with that animation value, we can interpolate the UI components, for example, here we animate the title from large-sized in the bottom-left to small-sized in the bottom center.
Our Header’s build
function will look like this.
SliverAppBar’s snap property
We will now try the snap
property of the SliverAppBar. One caveat is we also must enable floating
.
Hmm, the behavior is not exactly what we want. Since floating
must be enabled, the SliverAppBar can expand whenever the user scrolls down and overlay on top of the list.
Seems like we need to implement our own snap behavior.
We need to do 2 things:
- Know when the user stops scrolling
- Check to see if the header is closer to the expanded position or collapsed position, and scroll the list to that position.
To achieve the first thing, we can use a NotificationListener to listen to user scroll events.
There are multiple types of ScrollNotification:
And in our case, a ScrollEndNotification, which signifies a Scrollable has stopped scrolling, is sufficient.
We should put the NotificationListener above our CustomScrollView because the notifications will travel up the Widget tree from below.
Then, given the CustomScrollView’s ScrollController, let’s try to scroll the list to the start position, which is offset 0.
Let’s check the ScrollNotification’s documentation to see if we are doing anything wrong.
When a scroll notification is received by a [NotificationListener], the listener will have already completed build and layout, and it is therefore too late for that widget to call [State.setState]
Turns out the ScrollEndNotification was called during the current build phase, which prevents the jumpTo
command from updating the scroll position.
To solve this, we can wrap the function with Future.microTask()
to run the callback after the build ends.
The next step is to check the header size for expanding/collapsing.
We observe that the distance to scroll from the expanded position to the collapsed position is exactly the difference between the maximum header height and minimum header height.
We can then check whether the current offset is closer to which end and scroll the list to that position.
What happens when the list is short?
Here when there are only 3 items, our effect is not working because the list determined that it does not need to scroll anywhere.
All we have to do is add an AlwaysScrollablePhysics to the list. This means telling the list, hey, no matter what happens, just scroll.
Showing empty or error status
Let’s say when the list is empty, we want to display a message at the center.
We can do this by replacing the SliverList with a SliverFillRemaining, which always fills the remaining space in the list.
The collapsed position is used to allow space for the long list below. But in this case, it does not make sense because there is no long list.
So we should prevent that.
In _snapAppBar()
, we can check if the list is empty, and always scroll to the expanded position.
But we don’t have to because SliverFillRemaining has a hasScrollBody property that indicates whether it should be treated as a long scrollable list. Turning this off will do the trick.
Recap
By using NotificationListener and a few layout calculations, our SliverAppBar can now snap at the beginning of the list without needing to float all around the places.
Moreover, our snapping behavior also supports short lists, and cases where we need to display empty or error states.