Joeseph Schmoe
Joeseph Schmoe

Reputation: 312

Using Dynamically-Sizing Tab View Nested in Scrollview in Flutter

I'm trying to nest a tabview in a Scrollview, and can't find a good way to accomplish the task.

A diagram is included below:

Diagram of functionality

The desired functionality is to have a normal scrollable page, where one of the slivers is a tab view with different sized (and dynamically resizing) tabs.

Unfortunately, despite looking at several resources and the flutter docs, I haven't come across any good solutions.

Here is what I have tried:

The thus-far "best" implementation's code is given below, but it is not ideal.

Does anyone know of any way(s) to accomplish this?

Thank you in advance.

// best (more "Least-bad") solution code
import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Demo',
      routes: {
        'root': (context) => const Scaffold(
              body: ExamplePage(),
            ),
      },
      initialRoute: 'root',
    );
  }
}

class ExamplePage extends StatefulWidget {
  const ExamplePage({
    Key? key,
  }) : super(key: key);

  @override
  State<ExamplePage> createState() => _ExamplePageState();
}

class _ExamplePageState extends State<ExamplePage>
    with TickerProviderStateMixin {
  late TabController tabController;

  @override
  void initState() {
    super.initState();
    tabController = TabController(length: 2, vsync: this);
    tabController.addListener(() {
      setState(() {});
    });
  }

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

  @override
  Widget build(BuildContext context) => Scaffold(
        resizeToAvoidBottomInset: true,
        backgroundColor: Colors.grey[100],
        appBar: AppBar(),
        body: NestedScrollView(
          floatHeaderSlivers: false,
          physics: const AlwaysScrollableScrollPhysics(),
          headerSliverBuilder: (BuildContext context, bool value) => [
            SliverToBoxAdapter(
              child: Padding(
                padding: const EdgeInsets.only(
                  left: 16.0,
                  right: 16.0,
                  bottom: 24.0,
                  top: 32.0,
                ),
                child: Column(
                  children: [
                    // TODO: Add scan tab thing
                    Container(
                      height: 94.0,
                      width: double.infinity,
                      color: Colors.blueGrey,
                      alignment: Alignment.center,
                      child: Text('A widget with information'),
                    ),
                    const SizedBox(height: 24.0),
                    GenaricTabBar(
                      controller: tabController,
                      tabStrings: const [
                        'Tab 1',
                        'Tab 2',
                      ],
                    ),
                  ],
                ),
              ),
            ),
          ],
          body: CustomScrollView(
            slivers: [
              SliverFillRemaining(
                child: TabBarView(
                  physics: const AlwaysScrollableScrollPhysics(),
                  controller: tabController,
                  children: [
                    // Packaging Parts
                    SingleChildScrollView(
                      child: Container(
                        height: 200,
                        color: Colors.black,
                      ),
                    ),
                    // Symbols
                    SingleChildScrollView(
                      child: Column(
                        children: [
                          Container(
                            color: Colors.red,
                            height: 200.0,
                          ),
                          Container(
                            color: Colors.orange,
                            height: 200.0,
                          ),
                          Container(
                            color: Colors.amber,
                            height: 200.0,
                          ),
                          Container(
                            color: Colors.green,
                            height: 200.0,
                          ),
                          Container(
                            color: Colors.blue,
                            height: 200.0,
                          ),
                          Container(
                            color: Colors.purple,
                            height: 200.0,
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
              SliverToBoxAdapter(
                child: ElevatedButton(
                  child: Text('Button'),
                  onPressed: () => print('pressed'),
                ),
              ),
            ],
          ),
        ),
      );
}

class GenaricTabBar extends StatelessWidget {
  final TabController? controller;
  final List<String> tabStrings;

  const GenaricTabBar({
    Key? key,
    this.controller,
    required this.tabStrings,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) => Container(
        decoration: BoxDecoration(
          color: Colors.grey,
          borderRadius: BorderRadius.circular(8.0),
        ),
        padding: const EdgeInsets.all(4.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            // if want tab-bar, uncomment
            TabBar(
              controller: controller,
              indicator: ShapeDecoration.fromBoxDecoration(
                BoxDecoration(
                  borderRadius: BorderRadius.circular(6.0),
                  color: Colors.white,
                ),
              ),
              tabs: tabStrings
                  .map((String s) => _GenaricTab(tabString: s))
                  .toList(),
            ),
          ],
        ),
      );
}

class _GenaricTab extends StatelessWidget {
  final String tabString;

  const _GenaricTab({
    Key? key,
    required this.tabString,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) => Container(
        child: Text(
          tabString,
          style: const TextStyle(
            color: Colors.black,
          ),
        ),
        height: 32.0,
        alignment: Alignment.center,
      );
}

The above works in Dartpad (dartpad.dev) and doesn't require any external libraries

Upvotes: 1

Views: 2182

Answers (1)

Joeseph Schmoe
Joeseph Schmoe

Reputation: 312

Ideally, there is a better answer out there somewhere. BUT, until it arrives, this is how I got around the issue:

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Demo',
      // darkTheme: Themes.darkTheme,
      // Language support
      // Routes will keep track of all of the possible places to go.
      routes: {
        'root': (context) => const Scaffold(
              body: ExamplePage(),
            ),
      },
      initialRoute: 'root', // See below.
    );
  }
}

class ExamplePage extends StatefulWidget {
  const ExamplePage({
    Key? key,
  }) : super(key: key);

  @override
  State<ExamplePage> createState() => _ExamplePageState();
}

class _ExamplePageState extends State<ExamplePage>
    with TickerProviderStateMixin {
  late TabController tabController;
  late PageController scrollController;
  late int _pageIndex;

  @override
  void initState() {
    super.initState();
    _pageIndex = 0;
    tabController = TabController(length: 2, vsync: this);
    scrollController = PageController();
    tabController.addListener(() {
      if (_pageIndex != tabController.index) {
        animateToPage(tabController.index);
      }
    });
  }

  void animateToPage([int? target]) {
    if (target == null || target == _pageIndex) return;
    scrollController.animateToPage(
      target,
      duration: const Duration(milliseconds: 250),
      curve: Curves.easeInOut,
    );
    setState(() {
      _pageIndex = target;
    });
  }

  void animateTabSelector([int? target]) {
    if (target == null || target == tabController.index) return;
    tabController.animateTo(
      target,
      duration: const Duration(
        milliseconds: 100,
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) => Scaffold(
        resizeToAvoidBottomInset: true,
        backgroundColor: Colors.grey[100],
        appBar: AppBar(),
        body: CustomScrollView(
          slivers: [
            SliverToBoxAdapter(
              child: Padding(
                padding: const EdgeInsets.only(
                  left: 16.0,
                  right: 16.0,
                  bottom: 24.0,
                  top: 32.0,
                ),
                child: Column(
                  children: [
                    // TODO: Add scan tab thing
                    Container(
                      height: 94.0,
                      width: double.infinity,
                      color: Colors.blueGrey,
                      alignment: Alignment.center,
                      child: Text('A widget with information'),
                    ),
                    const SizedBox(height: 24.0),
                    GenaricTabBar(
                      controller: tabController,
                      tabStrings: const [
                        'Tab 1',
                        'Tab 2',
                      ],
                    ),
                  ],
                ),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                height: 200,
                color: Colors.black,
              ),
            ),
            SliverToBoxAdapter(
              child: NotificationListener<ScrollNotification>(
                onNotification: (ScrollNotification notification) {
                  // if page more than 50% to other page, animate tab controller
                  double diff = notification.metrics.extentBefore -
                      notification.metrics.extentAfter;
                  if (diff.abs() < 50 && !tabController.indexIsChanging) {
                    animateTabSelector(diff >= 0 ? 1 : 0);
                  }
                  if (notification.metrics.atEdge) {
                    if (notification.metrics.extentBefore == 0.0) {
                      // Page 0 (1)
                      if (_pageIndex != 0) {
                        setState(() {
                          _pageIndex = 0;
                        });
                        animateTabSelector(_pageIndex);
                      }
                    } else if (notification.metrics.extentAfter == 0.0) {
                      // Page 1 (2)
                      if (_pageIndex != 1) {
                        setState(() {
                          _pageIndex = 1;
                        });
                        animateTabSelector(_pageIndex);
                      }
                    }
                  }
                  return false;
                },
                child: SingleChildScrollView(
                  controller: scrollController,
                  scrollDirection: Axis.horizontal,
                  physics: const PageScrollPhysics(),
                  child: Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      // 1. Parts
                      SizedBox(
                        width: MediaQuery.of(context).size.width,
                        child: Container(
                          color: Colors.teal,
                          height: 50,
                        ),
                      ),
                      // 2. Symbols
                      SizedBox(
                        width: MediaQuery.of(context).size.width,
                        child: Container(
                          color: Colors.orange,
                          height: 10000,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
            SliverToBoxAdapter(
              child: Column(
                children: [
                  Container(
                    color: Colors.red,
                    height: 200.0,
                  ),
                  Container(
                    color: Colors.orange,
                    height: 200.0,
                  ),
                  Container(
                    color: Colors.amber,
                    height: 200.0,
                  ),
                  Container(
                    color: Colors.green,
                    height: 200.0,
                  ),
                  Container(
                    color: Colors.blue,
                    height: 200.0,
                  ),
                  Container(
                    color: Colors.purple,
                    height: 200.0,
                  ),
                ],
              ),
            ),
          ],
        ),
      );
}

class GenaricTabBar extends StatelessWidget {
  final TabController? controller;
  final List<String> tabStrings;

  const GenaricTabBar({
    Key? key,
    this.controller,
    required this.tabStrings,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) => Container(
        decoration: BoxDecoration(
          color: Colors.grey,
          borderRadius: BorderRadius.circular(8.0),
        ),
        padding: const EdgeInsets.all(4.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            // if want tab-bar, uncomment
            TabBar(
              controller: controller,
              indicator: ShapeDecoration.fromBoxDecoration(
                BoxDecoration(
                  borderRadius: BorderRadius.circular(6.0),
                  color: Colors.white,
                ),
              ),
              tabs: tabStrings
                  .map((String s) => _GenaricTab(tabString: s))
                  .toList(),
            ),
          ],
        ),
      );
}

class _GenaricTab extends StatelessWidget {
  final String tabString;

  const _GenaricTab({
    Key? key,
    required this.tabString,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) => Container(
        child: Text(
          tabString,
          style: const TextStyle(
            color: Colors.black,
          ),
        ),
        height: 32.0,
        alignment: Alignment.center,
      );
}

(Dartpad ready)

The basic idea is to not use a Tabview at all and instead use a horizontal scroll view nested in our scrollable area.

By using page physics for the horizontal scroll and using a PageController instead of a normal ScrollController, we can achieve a a scroll effect between the two widgets in the horizontal area that snap to whichever page is correct.

By using a notification listener, we can listen for changes in the scrollview and update the tab view accordingly.

LIMITATIONS:

The above code assumes only two tabs, so would require more thought to optimize for more tabs, particularly in the NotificationListener function.

This also may not be performant for large tabs since both tabs are being built, even if one is out of view.

Finally, the vertical height of each tab is the same; so a tab that is much larger will cause the other tab to have a lot of empty vertical space.

Hope this helps anyone in a similar boat, and am open to suggestions to improve.

Upvotes: 1

Related Questions