'Page level scrolling per TabBarView with widgets on top

I would like to implement the following behavior in Flutter for web where scrollbars allow to scroll all the way up and down of the page on any of the tabs. Scrolling on the ListView should take us to the top of the page and not only to the top of the list.

enter image description here

Code

class TestPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return [
              SliverToBoxAdapter(
                child: Container(
                  height: 200,
                  color: Colors.black12,
                  child: const Center(child: Text('Sliver 1')),
                ),
              ),
              const SliverToBoxAdapter(
                  child: Center(
                child: TabBar(
                  labelColor: Colors.black,
                  tabs: [
                    Tab(text: 'Sliver Tab 1'),
                    Tab(text: 'Sliver Tab 2'),
                  ],
                ),
              )),
            ];
          },
          body: TabBarView(
            children: [
              ListView.builder(
                  // controller: ScrollController(),
                  itemCount: 1000,
                  itemBuilder: (context, index) => Container(
                        height: 50,
                        color: index.isEven ? Colors.red : Colors.white,
                        child: AliveKeeper(index: index),
                      )),
              ListView.builder(
                // controller: ScrollController(),
                itemCount: 1000,
                itemBuilder: (context, index) => Container(
                  height: 50,
                  color: index.isEven ? Colors.blue : Colors.white,
                  child: AliveKeeper(index: index),
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

class AliveKeeper extends StatefulWidget {
  final int index;

  const AliveKeeper({
    required this.index,
    GlobalKey? key,
  }) : super(key: key);

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

class AliveKeeperState extends State<AliveKeeper>
    with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Text('Item ${widget.index}');
  }
}

As you can see here, I've tried different implementations using NestedScrollView but I haven't got the expected behavior.

Keep in mind that each TarBarView must be configured with keep alive.

Thanks.



Solution 1:[1]

Try this code I think helps this

and mouse hovers example :

https://protocoderspoint.com/hover-effect-with-animation-flutter-web/

this code only GridView example

class TestPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return [
              SliverToBoxAdapter(
                child: Container(
                  height: 200,
                  color: Colors.black12,
                  child: const Center(child: Text('Sliver 1')),
                ),
              ),
              const SliverToBoxAdapter(
                  child: Center(
                child: TabBar(
                  labelColor: Colors.black,
                  tabs: [
                    Tab(text: 'Sliver Tab 1'),
                    Tab(text: 'Sliver Tab 2'),
                  ],
                ),
              )),
            ];
          },
          body: TabBarView(
            children: [
              GridView.builder(
 gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
                crossAxisCount: 3,  
                  crossAxisSpacing: 4.0,  
                  mainAxisSpacing: 4.0  

),
                  
                  itemCount: 1000,
                  itemBuilder: (context, index) => Container(
                        height: 50,
                        color: index.isEven ? Colors.red : Colors.white,
                        child: AliveKeeper(index: index),
                      )),
              GridView.builder(
 gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
               crossAxisCount: 3,  
                  crossAxisSpacing: 4.0,  
                  mainAxisSpacing: 4.0  

),
                  
                  itemCount: 1000,
                  itemBuilder: (context, index) => Container(
                        height: 50,
                        color: index.isEven ? Colors.red : Colors.white,
                        child: AliveKeeper(index: index),
                      )),
            ],
          ),
        ),
      ),
    );
  }
}

class AliveKeeper extends StatefulWidget {
  final int index;

  const AliveKeeper({
    required this.index,
    GlobalKey? key,
  }) : super(key: key);

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

class AliveKeeperState extends State<AliveKeeper>
    with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Text('Item ${widget.index}');
  }
}

Solution 2:[2]

ListView.builder(
  primary = false;
  ......
  ......

),

Might help you with your issue.

Solution 3:[3]

here I have tried to solve using CustomScrollController.

import 'dart:developer';
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';

import 'demo/navigation.dart';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Custom Clipper',
      theme: ThemeData(
        brightness: Brightness.light,
        // add tabBarTheme
        tabBarTheme: TabBarTheme(
            labelColor: Colors.pink[800],
            labelStyle: TextStyle(color: Colors.pink[800]), // color for text
            indicator: const UnderlineTabIndicator(
                // color for indicator (underline)
                borderSide: BorderSide(color: Colors.black12))),
        primaryColor: Colors.pink[800], // outdated and has no effect to Tabbar
        // deprecated,
      ),
      // initialRoute: NavigationUtils.routeHome,
      // onGenerateRoute: NavigationUtils.generateRoute,
      home: const UsersList(),
    );
  }
}

class UsersList extends StatefulWidget {
  final String? searchString;

  const UsersList({Key? key, this.searchString}) : super(key: key);

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

class _UsersListState extends State<UsersList>
    with SingleTickerProviderStateMixin {
  //for display search bar
  bool isShowSearchBar = false;

  //creating dynamic a list for side popup menu
  List<PopupMenuItem> popUpMenuItem = [];

  //Scroll controller
  final ScrollController _scrollController = ScrollController();

  //TabBar controller
  late TabController tbController;

  @override
  void initState() {
    //initializing with 4 tab items
    tbController = TabController(length: 4, vsync: this);

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: CustomScrollView(
      controller: _scrollController,

      scrollBehavior: MyCustomScrollBehavior(),
      physics: const ClampingScrollPhysics(),
      slivers: [
        SliverAppBar(
          title: searchBar(),
          backgroundColor: isShowSearchBar ? Colors.white : null,
          elevation: 0,
          pinned: true,
          floating: true,
        ),
        SliverAppBar(
          toolbarHeight: 200,
          title: Padding(
            padding: const EdgeInsets.only(top: 20.0),
            child: Row(children: [
              const Padding(
                padding: EdgeInsets.all(10.0),
                child: CircleAvatar(
                  radius: 90,
                ),
              ),
              Expanded(
                  child: Container(
                height: 200,
                margin: const EdgeInsets.only(left: 40),
                child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: const [
                      Text(
                        "Name: Rohit Chaurasiya",
                        style: TextStyle(color: Colors.black),
                      ),
                      Text("posts: 13", style: TextStyle(color: Colors.black)),
                    ]),
              ))
            ]),
          ),
          backgroundColor: Colors.white,
          elevation: 0,
          bottom: PreferredSize(
            preferredSize:
                Size(MediaQuery.of(context).size.width, kToolbarHeight),
            child: Stack(
              alignment: Alignment.topCenter,
              children: [
                const Divider(
                  thickness: 1,
                  color: Colors.black12,
                  height: 15,
                ),
                TabBar(
                    controller: tbController,
                    isScrollable: true,
                    indicatorColor: Colors.black,
                    indicatorWeight: 5,
                    indicatorSize: TabBarIndicatorSize.label,
                    padding: const EdgeInsets.symmetric(horizontal: 10),
                    indicator: const UnderlineTabIndicator(
                        borderSide: BorderSide(color: Colors.black, width: 3.0),
                        insets: EdgeInsets.only(bottom: 45)),
                    tabs: const [
                      Icon(Icons.camera_alt_outlined),
                      Padding(
                        padding: EdgeInsets.symmetric(horizontal: 10),
                        child: Tab(
                          text: "CHATS",
                        ),
                      ),
                      Padding(
                        padding: EdgeInsets.symmetric(horizontal: 10),
                        child: Tab(text: "STATUS"),
                      ),
                      Padding(
                        padding: EdgeInsets.symmetric(horizontal: 10),
                        child: Tab(text: "CALLS"),
                      )
                    ]),
              ],
            ),
          ),
        ),
        SliverFillRemaining(
          child: TabBarView(
            controller: tbController,
            children: [
              ListView.builder(
                physics: const AlwaysScrollableScrollPhysics(),
                itemCount: 100,
                itemBuilder: (BuildContext context, int index) {
                  return Text("Camera $index");
                },
              ),
              ListView.builder(
                itemCount: 100,
                itemBuilder: (BuildContext context, int index) {
                  return Text("Chat $index");
                },
              ),
              const Center(
                child: Text("STATUS"),
              ),
              const Center(
                child: Text("CALLS"),
              ),
            ],
          ),
        )
      ],
      shrinkWrap: true,
    ));
  }

  Widget searchBar() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        (isShowSearchBar)
            ? Expanded(
                child: TextField(
                  decoration: const InputDecoration(
                      hintText: "Search...",
                      prefixIcon: Icon(
                        Icons.search,
                        color: Colors.grey,
                      )),
                  onChanged: (searchQuery) {
                    setState(() {
                      searchQuery = searchQuery.toLowerCase();
                    });
                  },
                ),
              )
            : const Text("MyChat"),
        Row(
          children: [
            InkWell(
                onTap: () {
                  setState(() {
                    isShowSearchBar = !isShowSearchBar;
                  });
                },
                child: Icon(
                  isShowSearchBar ? Icons.close : Icons.search,
                  color: isShowSearchBar ? Colors.grey : Colors.white,
                )),
            PopupMenuButton(
                icon: const Icon(FontAwesomeIcons.ellipsisV),
                onSelected: _selectedMenu,
                itemBuilder: (context) => popUpMenuItem)
          ],
        )
      ],
    );
  }

  void _selectedMenu(value) {
    log("Selected menu => " + value.toString());
  }
}

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () async {
        try {} on PlatformException catch (e) {
          log("sdfcsfg");
        }
      },
      child: Container(
        padding: const EdgeInsets.symmetric(
          horizontal: 20,
          vertical: 15,
        ),
        child: Row(
          children: <Widget>[
            Container(
              padding: const EdgeInsets.all(2),
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                boxShadow: [
                  BoxShadow(
                    color: Colors.grey.withOpacity(0.5),
                    spreadRadius: 2,
                    blurRadius: 5,
                  ),
                ],
              ),
              child: const CircleAvatar(
                radius: 25,
                backgroundImage: AssetImage("assets/images/contact.jpeg"),
              ),
            ),
            Container(
              width: MediaQuery.of(context).size.width * 0.65,
              padding: const EdgeInsets.only(
                left: 20,
              ),
              child: Column(
                children: <Widget>[
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: <Widget>[
                      Row(
                        children: const <Widget>[
                          Text(
                            "Rohit",
                            style: TextStyle(
                              fontSize: 20,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                          /*Container(
                            margin: const EdgeInsets.only(left: 5),
                            width: 7,
                            height: 7,
                            decoration: BoxDecoration(
                              shape: BoxShape.circle,
                              color: Colors.orange,
                            ),
                          )*/
                        ],
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class MyCustomScrollBehavior extends MaterialScrollBehavior {
  // Override behavior methods and getters like dragDevices
  @override
  Set<PointerDeviceKind> get dragDevices => {
        PointerDeviceKind.touch,
        PointerDeviceKind.mouse,
        // etc.
      };
}

enter image description here

Solution 4:[4]

TabBarView uses PageView and it uses children max height for TabBarView's children. While your goal is to keep alive, you can use SingleChildScrollView, this answer main goal is to control the scroll-bar as per tab.

Run on dartPad.

class _TestPageState extends State<TestPage>
    with SingleTickerProviderStateMixin {
  final int tabLength = 3;
  late final controller = TabController(length: tabLength, vsync: this)
    ..addListener(() {
      setState(() {});
    });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: LayoutBuilder(
        builder: (context, constraints) => SingleChildScrollView(
          child: Column(
            children: [
              Container(
                height: 200,
                color: Colors.black12,
                child: const Center(child: Text('Sliver 1')),
              ),
              TabBar(
                controller: controller,
                labelColor: Colors.black,
                tabs: List.generate(
                  tabLength,
                  (i) => Tab(text: 'Sliver Tab ${i + 1}'),
                ),
              ),
              GestureDetector(
                onTap: () {},
                onHorizontalDragEnd: (details) {
                  if (details.primaryVelocity == null) return;
                  debugPrint(details.primaryVelocity.toString());

                  if (details.primaryVelocity! > 0) {
                    debugPrint("move Left");
                    controller.animateTo(
                        controller.index < 0 ? 0 : controller.index - 1);
                  } else if (details.primaryVelocity! < 0) {
                    debugPrint("move right");
                    controller.animateTo(controller.index >= tabLength
                        ? tabLength - 1
                        : controller.index + 1);
                  }
                },
                child:

                    /// or any animation builder you prefer
                    AnimatedSwitcher(
                  duration: const Duration(milliseconds: 200),
                  transitionBuilder: (child, animation) {
                    return ScaleTransition(
                      scale: animation,
                      child: child,
                    );
                  },
                  child: [
                    tab1(),
                    tab2(),
                    tab3(),
                  ].elementAt(controller.index),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Column tab2() {
    return Column(
      children: List.generate(
        22,
        (index) => Container(
          height: 50,
          width: double.infinity,
          color: index.isEven ? Colors.blue : Colors.white,
          child: AliveKeeper(index: index),
        ),
      ),
    );
  }

  Column tab3() {
    return Column(
      children: List.generate(
        22,
        (index) => Container(
          height: 50,
          width: double.infinity,
          color: index.isEven ? Colors.pink : Colors.white,
          child: AliveKeeper(index: index),
        ),
      ),
    );
  }

  Column tab1() {
    return Column(
      children: List.generate(
          32,
          (index) => Container(
                width: double.infinity,
                height: 50,
                color: index.isEven ? Colors.red : Colors.white,
                child: AliveKeeper(index: index),
              )),
    );
  }
}

enter image description here

Solution 5:[5]

You can use multiple ScrollControllers for various scrollable Widgets that you have, and coordinate/synchronize their ScrollPositions by listening to changes in their ScrollControllers.

Checkout this DartPad. I have added a third tab in DartPad which changes the size dynamically.

static void controllerListener(
    ScrollController parent,
    ScrollController child,
    ScrollController sc,
    bool movedScrollbar,
  ) {
    final childPX = child.position.pixels;
    final parentPX = parent.position.pixels;
    final parentPXMax = parent.position.maxScrollExtent;

    if (childPX >= 0 && parentPX < parentPXMax) {
      parent.position.moveTo(childPX + parentPX);
    } else {
      final currenParentPos = childPX + parentPX;
      parent.position.moveTo(currenParentPos);
    }

    if (!movedScrollbar) {
      sc.position.moveTo(parentPX + childPX);
    }
  }

  @override

this function scrolls header widgets according to changes in ListView widget.

Also you wanted a page level Scrollbar, therefore I made a SingleChildScrollView spanning the whole page and according Scrollbar position changes in SingleChildScrollView widget I changed the main Widget. If you think the calculation for scroll position is off you can change the scrollController.addListener code starting from line 199 in DartPad.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Anmol Mishra
Solution 2 aedemirsen
Solution 3
Solution 4
Solution 5