How to create a custom persistent TabBar for your Flutter App

Learn how to create a persistent TabBar for your Flutter app with zero dependencies.

Intro

Flutter offers many beautiful components out-of-the-box. Even though you can customize a fair amount of settings, you may sometimes reach a point where you just have to build your own.

A "Persistent Bottom TabBar" is a common approach for a layout seen in many apps. In this guide we will explore how to create your own and customize it to your needs. It's easier than you might think!

Setup

To create a Persistent Bottom TabBar you need to include it in a top-level screen so that every other page would be a child of your Layout. We will create a main_navigation.dart to create this component.

Let's start by creating a global Scaffold widget with a custom bottomNavigationBar that includes a child DefaultTabController.

Main Scaffold setup

class MainNavigation extends StatelessWidget {
  final Widget child;

  const MainNavigation({required this.child, super.key});

  @override
  Widget build(BuildContext context) {
    final List<Widget> tabs = [
      const Tab(icon: Icon(CustomIcons.search)),
      const Tab(icon: Icon(CustomIcons.star)),
      const Tab(icon: Icon(CustomIcons.calendar)),
      const Tab(icon: Icon(CustomIcons.user)),
    ];

    return Scaffold(
      primary: true,
      body: child,
      bottomNavigationBar: Material(
        color: Colors.white,
        shape: Border(
          top: BorderSide(color: Colors.grey.withOpacity(0.4), width: 1),
        ),
        borderOnForeground: false,
        child: SafeArea(
          bottom: true,
          child: DefaultTabController(
            length: tabs.length,
            child: TabBar(
              splashBorderRadius: BorderRadius.zero,
              dividerHeight: 0,
              dividerColor: Colors.white,
              indicatorPadding: const EdgeInsets.symmetric(horizontal: 5),
              indicatorSize: TabBarIndicatorSize.tab,
              tabs: tabs,
            ),
          ),
        ),
      ),
    );
  }
}

The Scaffold's bottomNavigationBar is a wrapper in a Material class which enables Material features like Clipping, Elevation and Tap Effects. Here we can customize the background color and shape of our TabBar container. Following, we have the DefaultTabController wrapped an a SafeArea widget to safely place our TabBar above any system UI elements.

Our MainNavigation expects a child widget to render as the main Scaffold body. This child widget should be dynamic base on your routing or any other mechanism. For example if you are using GoRouter's ShellRoute you can do the following.

  return GoRouter(
    initialLocation: "/",
    routes: [
      ShellRoute(
        builder: (context, state, child) =>
            MainNavigationTabs(child: child),
        ...
      )
    ]
  );

Adding an Indicator for active tab

Now that we have a basic layout working we can customize further our TabBar. Let's add a line as an indicator of the currently active screen.

For that we can extend the BoxPainter component and customize it.

class TopIndicator extends Decoration {
  @override
  BoxPainter createBoxPainter([VoidCallback? onChanged]) {
    return _TopIndicatorBox();
  }
}

class _TopIndicatorBox extends BoxPainter {
  @override
  void paint(Canvas canvas, Offset offset, ImageConfiguration cfg) {
    Paint paint = Paint()
      ..color = Colors.red
      ..strokeWidth = 3
      ..isAntiAlias = true;

    canvas.drawLine(offset, Offset(cfg.size!.width + offset.dx, 0), paint);
  }
}

The example above will draw a red line with 3px stroke width above the active TabBar item. We need to add it to our TabBar component we created earlier.

Scaffold(
    child: SafeArea(
        child: TabBar(
            ...
+           indicator: TopIndicator(),
        ),
    ),
)

Final Result

With that we have now created a custom Tabs layout which we can customize easily. Here is the final result.

class MainNavigation extends StatelessWidget {
  final Widget child;

  const MainNavigation({required this.child, super.key});

  @override
  Widget build(BuildContext context) {
    final List<Widget> tabs = [
      const Tab(icon: Icon(CustomIcons.search)),
      const Tab(icon: Icon(CustomIcons.star)),
      const Tab(icon: Icon(CustomIcons.calendar)),
      const Tab(icon: Icon(CustomIcons.user)),
    ];

    return Scaffold(
      primary: true,
      body: child,
      bottomNavigationBar: Material(
        color: Colors.white,
        shape: Border(
          top: BorderSide(color: Colors.grey.withOpacity(0.4), width: 1),
        ),
        borderOnForeground: false,
        child: SafeArea(
          bottom: true,
          child: DefaultTabController(
            length: tabs.length,
            child: TabBar(
              splashBorderRadius: BorderRadius.zero,
              dividerHeight: 0,
              dividerColor: Colors.white,
              indicatorPadding: const EdgeInsets.symmetric(horizontal: 5),
              indicatorSize: TabBarIndicatorSize.tab,
              tabs: tabs,
              indicator: TopIndicator(),
            ),
          ),
        ),
      ),
    );
  }
}

class TopIndicator extends Decoration {
  @override
  BoxPainter createBoxPainter([VoidCallback? onChanged]) {
    return _TopIndicatorBox();
  }
}

class _TopIndicatorBox extends BoxPainter {
  @override
  void paint(Canvas canvas, Offset offset, ImageConfiguration cfg) {
    Paint paint = Paint()
      ..color = Colors.red
      ..strokeWidth = 3
      ..isAntiAlias = true;

    canvas.drawLine(offset, Offset(cfg.size!.width + offset.dx, 0), paint);
  }
}
More posts in Flutter
Flutter

Learn how to install and configure Firebase with Flutter in 2024. Follow our step-by-step guide to seamlessly integrate Firebase into your Flutter projects for enhanced app functionality and performance.