Development Challenge: Building a Collapsible AppBar in Flutter

Development Challenge: Building a Collapsible AppBar in Flutter

How I built a collapsible app bar with a blurred background effect on scrolling while designing the user profile page of Waki

As we kickstarted the UI development of Waki in late January, Anderson and I decided to split the development work, with Anderson working on the home of the app while I work on the user profile and account settings of the app.

So I naturally had to come up with a design and implementation of the user profile page in Flutter. The idea is to create a collapsible app bar header with a background picture (configurable by the user) that would be blurred when it is collapsed and not blurred when completely expanded (on scrolling). After a bit of research, I came up with the solution that I summarise below.

My solution is to use a NestedScrollView() widget. This widget has a headerSliverBuilder parameter that accepts a List<Widget>. To achieve the app bar with a background picture, I passed a list containing a SliverPersistentHeader class to the headerSliverBuilder parameter of the of the NestedScrollView(). The SliverPersistentHeader class then returns a Stack widget, first containing the blurred background image, and then the background image (without the blurred effect). To make the "unblurred" background image disappear when the page is scrolled up and the app bar is fully collapsed, I wrapped it in an AnimatedOpacity widget which makes the background image gradually disappear as the user scrolls up to reveal the blurred background image below it in the Stack widget.

I provide a concrete example below. First we create the boilerplate code for an app page and put some stuff in the body.

import 'dart:ui';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyAppBarApp());
}

class MyAppBarApp extends StatelessWidget {
  const MyAppBarApp({super.key});
  // This widget is the root of the application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: SliverHeaderExample(),
    );
  }
}

class SliverHeaderExample extends StatelessWidget {
  const SliverHeaderExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        physics: const BouncingScrollPhysics(),
        headerSliverBuilder: (context, innerBoxIsScrolled) {
          return [
            // will build Sliver header here        
          ];
        },
        // put some stuff in the body section of NestedScrollView()
        body: ListView.builder(
          itemCount: 100,
          itemBuilder: (BuildContext context, int index) {
            return Card(
              color: index % 2 == 0 ? const Color(0xFFBC004F) : const Color(0xFF75565B),
              child: Container(
                alignment: Alignment.center,
                width: double.infinity,
                height: 100.0,
                child: const Text(
                  'Flutter is awesome',
                  style: TextStyle(fontSize: 18.0),
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}

The headerSliverBuilder argument of the NestedScrollView accepts a list of widgets, which typically contains an AppBar or TabBar. In our case we will return a SliverPersistentHeader with a delegate which inherits from SliverPersistentHeaderDelegate class, in the block below:

// fixed code above
      body: NestedScrollView(
        physics: const BouncingScrollPhysics(),
        headerSliverBuilder: (context, innerBoxIsScrolled) {
          return [
            // return SliverPersistentHeader
            SliverPersistentHeader(
              pinned: true,
              delegate: MyPersistentSliverAppBar(
                expandedHeight: 250,
                collapsedHeight: 120,
              ),
            ),
          ];
        },
//fixed code below

The MyPersistentSliverAppBar will extend the SliverPersistentHeaderDelegate class, whose build function gives us access to a shrinkOffset variable which tells us by how much the sliver has been shrunk. In addition, we can override the maxEntent and minExtent variables of the parent SliverPersistentHeaderDelegate class by using getter methods to return our own desired expandedHeight and collapsedHeight.

The implementation of MyPersistentSliverAppBar then returns a Stack Widget, containing first the blurred background image and then the original background image contained in an animated opacity as shown below:

class MyPersistentSliverAppBar extends SliverPersistentHeaderDelegate {
  final double expandedHeight;
  final double collapsedHeight;

  MyPersistentSliverAppBar({
    required this.collapsedHeight,
    required this.expandedHeight,
  });

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    // create variable to track scrolling progress
    final progress = shrinkOffset / maxExtent;
    const Duration appBarAnimationDuration = Duration(milliseconds: 150);

    return Stack(
      clipBehavior: Clip.none, 
      fit: StackFit.expand,
      children: [

        // blurred background image
        Container(
          height: maxExtent,
          clipBehavior:
              Clip.hardEdge, //prevent blurr from affecting whole screen
          decoration: const BoxDecoration(
            image: DecorationImage(
              image: NetworkImage('https://picsum.photos/seed/picsum/200/300'),
              fit: BoxFit.cover,
            ),
          ),
          child: BackdropFilter(
            filter: ImageFilter.blur(
              sigmaX: 90,
              sigmaY: 90,
            ),
            child: Container(
              color: Colors.black.withOpacity(0.3),
            ),
          ),
        ),

        // background without blurr
        AnimatedOpacity(
          duration: appBarAnimationDuration,
          opacity: 1 - progress, // fully transparent on scrolling progress = 1
          child: Image.network(
            'https://picsum.photos/seed/picsum/200/300',
            width: MediaQuery.of(context).size.width,
            fit: BoxFit.cover,
          ),
        ),

      ],
    );
  }
  @override
  double get maxExtent => expandedHeight;
  @override
  double get minExtent => collapsedHeight;
  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false;
}

With the changes above, we can already test the collapsible app as show below

Collapsible App Bar

By adding more widgets to the Stack widget inside the MyPersistentSliverAppBar, we can further customize the app bar, e.g., by adding naviagation buttons etc. I will probably write another post along this line, looking into into how to further customize the app bar.

What do you think of the solution I came up with? Have an even better idea for implementating this? Why not comment below and let me know!