Animate SliverAppBar's widgets when scrolling

Have you ever created something like this?

ยท

4 min read

Animate SliverAppBar's widgets when scrolling

Full-screen:

curve-ease-out-cubit-resize-crop.gif

TOC

  1. Getting Started
  2. Inspecting design
  3. "The recipe"

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.

overview.png

Now, I'll do some calculations ๐Ÿงฎ for you.

1. Buttons Spacing

action-btns.png

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),
          ],

search-bar.png

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:

no-curve-resize-crop.gif

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:

curve-ease-out-cubit-resize-crop.gif

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!