Flutter TabBar and SliverAppBar that hides when you scroll down

36,561

Solution 1

Update - Sliver App Bar Expanded

If you want to see Sliver App Bar expanded as soon as someone scrolls up i.e. not scrolling all the way to top but just little bit, Then just change snap: false to snap: true in code :)


Solution [Fixing All Points]

After surfing google, stackoverflow, github issues, reddit for hours. I could finally come up with a solution that addresses following:

  1. Sliver App bar with title getting hidden and only tab bar visible after scrolling down. You would see title again when you reach top.

  2. MAJOR : When you scroll in Tab 1 & then Navigate to Tab 2, you would not see any overlap. The content of Tab 2 will not get obstructed by Sliver App bar.

  3. Sliver Padding for top most element in List is 0.

  4. Preserves the Scroll Position in individual Tabs

below is the code, I would attempt to explain in a bit (dartpad preview) :

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  static const String _title = 'Flutter Code Sample';

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: _title,
      home: MyStatelessWidget(),
    );
  }
}

class MyStatelessWidget extends StatelessWidget {
  const MyStatelessWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final List<String> _tabs = <String>['Tab 1', 'Tab 2'];
    return DefaultTabController(
      length: _tabs.length,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return <Widget>[
              SliverOverlapAbsorber(
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                sliver: SliverAppBar(
                  title: const Text('Books'),
                  floating: true,
                  pinned: true,
                  snap: false,
                  forceElevated: innerBoxIsScrolled,
                  bottom: TabBar(
                    tabs: _tabs.map((String name) => Tab(text: name)).toList(),
                  ),
                ),
              ),
            ];
          },
          body: TabBarView(
            children: _tabs.map((String name) {
              return SafeArea(
                top: false,
                bottom: false,
                child: Builder(
                  builder: (BuildContext context) {
                    return CustomScrollView(
                      key: PageStorageKey<String>(name),
                      slivers: <Widget>[
                        SliverOverlapInjector(
                          handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                        ),
                        SliverPadding(
                          padding: const EdgeInsets.all(8.0),
                          sliver: SliverList(
                            delegate: SliverChildBuilderDelegate(
                              (BuildContext context, int index) {
                                return ListTile(
                                  title: Text('Item $index'),
                                );
                              },
                              childCount: 30,
                            ),
                          ),
                        ),
                      ],
                    );
                  },
                ),
              );
            }).toList(),
          ),
        ),
      ),
    );
  }
}

Test it out all you want in dartpad, once you are fine then lets try to understand what is happening here.

Most of the code is from flutter documentation of NestedScrollView

They have mentioned very nicely in comments. I am no expert so I would just highlight what I think solved most of the issues.

I believe Two things are critical here:

  1. SliverOverlapAbsorber & SliverOverlapInjector
  2. Use of SliverList instead of ListView

Whatever extra space we were seeing or the space which sliver app bar consumed and first list item was overlapped was mainly resolved with the use of above two points.

To remember the scroll position of tabs, they added PageStorageKey inside CustomScrollView:

key: PageStorageKey<String>(name),

name is just a string -> 'Tab 1'

They also mentioned in docs that we can use SliverFixedExtentList, SliverGrid, basically Sliver widgets. Now use of Sliver widgets should be done when needed. In one of the Flutter Youtube videos (official channel) they mentioned that ListView, GridView, are all high level implementation of Slivers. So Slivers is low level stuff if you looking to super customize scrolling or appearance behaviour.

Please let me know in comments if I missed something or said wrong.

Solution 2

You need to use SliverOverlapAbsorber/SliverOverlapInjector, the following code works for me (Full Code):

@override
  Widget build(BuildContext context) {
    return Material(
      child: Scaffold(
        body: DefaultTabController(
          length: _tabs.length, // This is the number of tabs.
          child: NestedScrollView(
            headerSliverBuilder:
                (BuildContext context, bool innerBoxIsScrolled) {
              // These are the slivers that show up in the "outer" scroll view.
              return <Widget>[
                SliverOverlapAbsorber(
                  // This widget takes the overlapping behavior of the SliverAppBar,
                  // and redirects it to the SliverOverlapInjector below. If it is
                  // missing, then it is possible for the nested "inner" scroll view
                  // below to end up under the SliverAppBar even when the inner
                  // scroll view thinks it has not been scrolled.
                  // This is not necessary if the "headerSliverBuilder" only builds
                  // widgets that do not overlap the next sliver.
                  handle:
                      NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                  sliver: SliverSafeArea(
                    top: false,
                    sliver: SliverAppBar(
                      title: const Text('Books'),
                      floating: true,
                      pinned: true,
                      snap: false,
                      primary: true,
                      forceElevated: innerBoxIsScrolled,
                      bottom: TabBar(
                        // These are the widgets to put in each tab in the tab bar.
                        tabs: _tabs.map((String name) => Tab(text: name)).toList(),
                      ),
                    ),
                  ),
                ),
              ];
            },
            body: TabBarView(
              // These are the contents of the tab views, below the tabs.
              children: _tabs.map((String name) {
                return SafeArea(
                  top: false,
                  bottom: false,
                  child: Builder(
                    // This Builder is needed to provide a BuildContext that is "inside"
                    // the NestedScrollView, so that sliverOverlapAbsorberHandleFor() can
                    // find the NestedScrollView.
                    builder: (BuildContext context) {
                      return CustomScrollView(
                        // The "controller" and "primary" members should be left
                        // unset, so that the NestedScrollView can control this
                        // inner scroll view.
                        // If the "controller" property is set, then this scroll
                        // view will not be associated with the NestedScrollView.
                        // The PageStorageKey should be unique to this ScrollView;
                        // it allows the list to remember its scroll position when
                        // the tab view is not on the screen.
                        key: PageStorageKey<String>(name),
                        slivers: <Widget>[
                          SliverOverlapInjector(
                            // This is the flip side of the SliverOverlapAbsorber above.
                            handle:
                                NestedScrollView.sliverOverlapAbsorberHandleFor(
                                    context),
                          ),
                          SliverPadding(
                            padding: const EdgeInsets.all(8.0),
                            // In this example, the inner scroll view has
                            // fixed-height list items, hence the use of
                            // SliverFixedExtentList. However, one could use any
                            // sliver widget here, e.g. SliverList or SliverGrid.
                            sliver: SliverFixedExtentList(
                              // The items in this example are fixed to 48 pixels
                              // high. This matches the Material Design spec for
                              // ListTile widgets.
                              itemExtent: 60.0,
                              delegate: SliverChildBuilderDelegate(
                                (BuildContext context, int index) {
                                  // This builder is called for each child.
                                  // In this example, we just number each list item.
                                  return Container(
                                      color: Color((math.Random().nextDouble() *
                                                      0xFFFFFF)
                                                  .toInt() <<
                                              0)
                                          .withOpacity(1.0));
                                },
                                // The childCount of the SliverChildBuilderDelegate
                                // specifies how many children this inner list
                                // has. In this example, each tab has a list of
                                // exactly 30 items, but this is arbitrary.
                                childCount: 30,
                              ),
                            ),
                          ),
                        ],
                      );
                    },
                  ),
                );
              }).toList(),
            ),
          ),
        ),
      ),
    );
  }

Solution 3

I was able to make the floating Appbar with Tabbar similar to that of WhatsApp by using SliverAppbar with NestedScrollView.

Do add floatHeaderSlivers: true, in NestedScrollView and

pinned: true, floating: true, in SliverAppBar

Link to working code sample

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: CustomSliverAppbar(),
    );
  }
}

class CustomSliverAppbar extends StatefulWidget {
  @override
  _CustomSliverAppbarState createState() => _CustomSliverAppbarState();
}

class _CustomSliverAppbarState extends State<CustomSliverAppbar>
    with SingleTickerProviderStateMixin {
  TabController _tabController;

  @override
  void initState() {
    _tabController = TabController(
      initialIndex: 0,
      length: 2,
      vsync: this,
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        floatHeaderSlivers: true,
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              title: Text(
                "WhatsApp type sliver appbar",
              ),
              centerTitle: true,
              pinned: true,
              floating: true,
              bottom: TabBar(
                  indicatorColor: Colors.black,
                  labelPadding: const EdgeInsets.only(
                    bottom: 16,
                  ),
                  controller: _tabController,
                  tabs: [
                    Text("TAB A"),
                    Text("TAB B"),
                  ]),
            ),
          ];
        },
        body: TabBarView(
          controller: _tabController,
          children: [
            TabA(),
            const Center(
              child: Text('Display Tab 2',
                  style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }
}

class TabA extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scrollbar(
      child: ListView.separated(
        separatorBuilder: (context, child) => Divider(
          height: 1,
        ),
        padding: EdgeInsets.all(0.0),
        itemCount: 30,
        itemBuilder: (context, i) {
          return Container(
            height: 100,
            width: double.infinity,
            color: Colors.primaries[Random().nextInt(Colors.primaries.length)],
          );
        },
      ),
    );
  }
}

enter image description here

Solution 4

--- EDIT 1 --

Alright so I threw together something quick for you. I followed this article (written by Emily Fortuna who is one of the main Flutter devs) to better understand Slivers.

Medium: Slivers, Demystified

But then found this Youtube video that basically used your code so I opted for this one, rather than try to figure out every small detail about Slivers.

Youtube: Using Tab and Scroll Controllers and the NestedScrollView in Dart's Flutter Framework

Turns out you were on the right track with your code. You can use SliverAppBar within NestedScrollView (this wasn't the case last time I tried) but I made a few changes. That I will explain after my code:

import 'package:flutter/material.dart';

import 'dart:math';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>  with SingleTickerProviderStateMixin /*<-- This is for the controllers*/ {
  TabController _tabController; // To control switching tabs
  ScrollController _scrollViewController; // To control scrolling

  List<String> items = [];
  List<Color> colors = [Colors.red, Colors.green, Colors.yellow, Colors.purple, Colors.blue, Colors.amber, Colors.cyan, Colors.pink];
  Random random = new Random();

  Color getRandomColor() {
    return colors.elementAt(random.nextInt(colors.length));
  }

  @override
  void initState() {
    super.initState();
    _tabController =TabController(vsync: this, length: 2);
    _scrollViewController =ScrollController();
  }

  @override
  void dispose() {
    super.dispose();
    _tabController.dispose();
    _scrollViewController.dispose();
  }

  @override
  Widget build(BuildContext context) {

 // Init the items
    for (var i = 0; i < 100; i++) {
      items.add('Item $i');
    }

    return SafeArea(
      child: NestedScrollView(
        controller: _scrollViewController,
        headerSliverBuilder: (BuildContext context, bool boxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              title: Text("WhatsApp using Flutter"),
              floating: true,
              pinned: false,
              snap: true,
              bottom: TabBar(
                tabs: <Widget>[
                  Tab(
                    child: Text("Colors"),
                  ),
                  Tab(
                    child: Text("Chats"),
                  ),
                ],
                controller: _tabController,
              ),
            ),
          ];
        },
        body: TabBarView(
              controller: _tabController,
              children: <Widget>[
                ListView.builder(
                  itemBuilder: (BuildContext context, int index) {
                      Color color = getRandomColor();
                      return Container(
                        height: 150.0,
                        color: color,
                        child: Text(
                          "Row $index",
                          style: TextStyle(
                            color: Colors.white,
                          ),
                        ),
                      );
                    },
                    //physics: NeverScrollableScrollPhysics(), //This may come in handy if you have issues with scrolling in the future
                  ),

                  ListView.builder(
                    itemBuilder: (BuildContext context, int index) {
                      return Material(
                        child: ListTile(
                          leading: CircleAvatar(
                            backgroundColor: Colors.blueGrey,
                          ),
                          title: Text(
                            items.elementAt(index)
                            ),
                        ),
                      );
                    },
                    //physics: NeverScrollableScrollPhysics(),
                  ),
              ],
            ),
      ),
    );

  }
}

Alright so on to the explanation.

  1. Use a StatefulWidget

    Most widgets in Flutter are going to be stateful but it depends on the situation. I think in this case it is better because you are using a ListView which could change as users add or erase conversations/chats.

  2. SafeArea because this widget is great.

    Go read about it on Flutter Docs: SafeArea

  3. The Controllers

    I think this was the big issue at first but maybe it was something else. But you should usually make your own controllers if you are dealing with custom behavior in Flutter. So I made the _tabController and the _scrollViewController (I don't think I got every bit of functionality out of them, i.e. keeping track of scroll positions between tabs, but they work for the basics). The tab controller that you use for the TabBar and the TabView should be the same.

  4. The Material Widget before the ListTile

    You probably would have found this out sooner or later but the ListTile widget is a Material widget and therefore requires a "Material ancestor widget" according to the output I got while trying to render it at first. So I saved you a tiny headache with that. I think it is because I didn't use a Scaffold. (Just keep this in mind when you use Material widgets without Material ancestor widgets)

Hope this helps you get started, if you need any assistance with it just message me or add me to your Github repo and I'll see what I can do.


--- ORIGINAL ---

I answered you on Reddit as well, hopefully you see one of these two soon.

SliverAppBar Info

The key properties you want to have with the SliverAppBar are:

floating: Whether the app bar should become visible as soon as the user scrolls towards the app bar.
pinned: Whether the app bar should remain visible at the start of the scroll view. (This is the one you are asking about)
snap: If snap and floating are true then the floating app bar will "snap" into view.

All this came from the Flutter SliverAppBar Docs. They have lots of animated examples with different combos of floating, pinned and snap.

So for yours the following should work:

SliverAppBar(
            title: Text("Application"),
            floating: true, // <--- this is required if you want the appbar to come back into view when you scroll up
            pinned: false, // <--- this will make the appbar disappear on scrolling down
            snap: true,    // <--- this is required if you want the application bar to 'snap' when you scroll up (floating MUST be true as well)
            bottom: new TabBar(
              tabs: [ ... ],    // <-- total of 2 tabs
            ),
          ),

ScrollView with SliverAppBar

To answer the underlying question of the NestedScrollView. According to the docs (same as above) a SliverAppBar is:

A material design app bar that integrates with a CustomScrollView.

Therefore you cannot use a NestedScrollView you need to use a CustomScrollView. This is the intended use of Sliver classes but they can be used in the NestedScrollView Check out the docs.

Share:
36,561

Related videos on Youtube

themthem
Author by

themthem

I program in mainly Python and JavaScript, working with web technologies.

Updated on November 18, 2021

Comments

  • themthem
    themthem over 2 years

    I am trying to create an app with a top application bar and a tab bar below. When you scroll down, the bar should hide by moving off the screen (but tabs should stay), and when you scroll back up, the application bar should show again. This behaviour can be seen in WhatsApp. Please see this video for a demonstration. (Taken from Material.io). This is a similar behaviour, although the app bar and tab bar are hidden on scroll, so it is not exactly the behaviour I am looking for.

    I have been able to achieve the autohiding, however, there are a few issues:

    1. I have to set the snap of the SliverAppBar to true. Without this, the application bar will not show when I scroll back up.

      Although this is works, it is not the behaviour I am looking for. I want the application bar to show smoothly (similar to WhatsApp) rather than coming into view even if you scroll very little.

      To clarify, when I scroll all the way down, even if I scroll up very little, the app bar should come into view. I do not want to have to scroll all the way up to see the app bar.

    2. When I scroll down and change tabs, a little bit of the content is cut out of view.

      Below is a GIF showing the behaviour:

      GIF demonstrating output

      (See the part when I scroll down on the listView (tab1), then move back to tab2)

    Here is the code for the DefaultTabController:

    DefaultTabController(
      length: 2,
      child: new Scaffold(
        body: new NestedScrollView(
          headerSliverBuilder:
              (BuildContext context, bool innerBoxIsScrolled) {
            return <Widget>[
              new SliverAppBar(
                title: Text("Application"),
                floating: true,
                pinned: true,
                snap: true,    // <--- this is required if I want the application bar to show when I scroll up
                bottom: new TabBar(
                  tabs: [ ... ],    // <-- total of 2 tabs
                ),
              ),
            ];
          },
          body: new TabBarView(
            children: [ ... ]    // <--- the array item is a ListView
          ),
        ),
      ),
    ),
    

    In case it is needed, the full code is in this GitHub repository. main.dart is here.

    I also found this related question: Hide Appbar on Scroll Flutter?. However, it did not provide the solution. The same problems persist, and when you scroll up, the SliverAppBar will not show. (So snap: true is required)

    I also found this issue on Flutter's GitHub. (Edit: someone commented that they are waiting for the Flutter team to fix this. Is there a possibility that there is no solution?)

    This is the output of flutter doctor -v: Pastebin. Certain issues are found, but from what I have learned, they should not have an impact.

    Edit: There are two issues for this:

    • willy wijaya
      willy wijaya about 5 years
      I have this problem as well. Using SliverOverlapAbsorber is work, but then you can't use sliverappbar float. Any fix for this?
  • themthem
    themthem about 5 years
    So I used a CustomScrollView, and I am trying to put the TabBarView as a child as a SliverFillRemaining. Is this correct? I am getting more errors/warnings such as Another exception was thrown: NoSuchMethodError: The getter 'visible' was called on null.. Is it possible for you to share a working example?
  • themthem
    themthem about 5 years
    Your code works, but the problem (in the GIF) seems to still persist. When I change tabs, the top of the content is hidden. Do you know how I can solve this? I think we might be able to solve this problem by scrolling to the top on each tab change. Also, the snap: true is still required, unfortunately
  • Tanner Davis
    Tanner Davis about 5 years
    Hmmmm you're right. I've been trying to follow Flutter's example of using NestedScrollView but it ruins the ability to 'snap' the appBar back. I have a feeling you might have to wait for Flutter to give this specific functionality without the need to dig deep into the source code for it.
  • themthem
    themthem about 5 years
    Thank you for all your help. I have created an issue: #29561
  • themthem
    themthem about 5 years
    This seems to be the closest the solve the problem, but the problem is still that snap: true is required for the app bar to show up when you scroll from any position on the screen, unfortunately. Could this be a bug with Flutter?
  • Ismail RBOUH
    Ismail RBOUH about 5 years
    I don't understand the problem with snap could you please elaborate or record an example?
  • themthem
    themthem about 5 years
    The problem is that when you scroll up, you have to stop scrolling for the app bar to show. I want it that when you start scrolling up, the app bar should already start to show. I'm sorry if this is a poor explanation, but I want to achieve the app bar behaviour of Android WhatsApp. Currently, the behaviour is like the Android Google Docs app bar. Apart from this, your solution has fixed the issue of some content being hidden when you change tabs
  • Tapas Pal
    Tapas Pal almost 4 years
    With snap:false, it works for me. Thanks @IsmailRBOUH
  • Sanjay Kumar
    Sanjay Kumar over 3 years
    How to handle if using SilverAppbar, SliverPersistentHeader in NestedScrollView.
  • themthem
    themthem almost 3 years
    Thank you for the answer! This solution does fix the overlap issue, but a problem is that I have to scroll all the way up to view the app bar again. I am looking for something like Android WhatsApp's app bar behavior
  • krupesh Anadkat
    krupesh Anadkat almost 3 years
    snap: true will solve your problem :) I just tested it in dartpad. Scroll down and then scroll little up and leave mouse touchpad, you will see Sliver App bar expanding smoothly :)
  • krupesh Anadkat
    krupesh Anadkat almost 3 years
    If it solves the issue then please accept this as answer, so it would be closed in stackoverflow open questions.. If not let me know what is missing, I will try to help :)
  • themthem
    themthem almost 3 years
    Thank you, that works! Do you think you can add the part about setting snap to true to the answer? I think it may be helpful for people in the future if they're searching for the behavior like WhatsApp
  • krupesh Anadkat
    krupesh Anadkat almost 3 years
    Definitely, I have added it at the top. Thanks for the suggestion.
  • Astik Anand
    Astik Anand over 2 years
    Nice and Clean. Awesome +1 :)