Flutter TabBar and TabBarView with DefaultTabController

Flutter’s TabBar displays a horizontal set of tabs, while TabBarView displays the page associated with the selected tab. Both widgets must use the same tab controller so that tab taps, swipe gestures, the selected indicator, and the visible page remain synchronized.

This tutorial uses DefaultTabController, which creates and provides a TabController to its descendant widgets. This approach works well when the application has a fixed number of tabs and does not need to control the selected tab directly from code.

How TabBar, TabBarView, and TabController Work Together

A Flutter tab layout normally contains the following parts:

  • DefaultTabController creates the controller and specifies the number of tabs.
  • TabBar displays the selectable tab labels, icons, and selection indicator.
  • TabBarView displays one child page for each tab.
  • Scaffold and AppBar provide the Material page structure used in this example.

The tab count must match in three places: DefaultTabController.length, the number of widgets in TabBar.tabs, and the number of widgets in TabBarView.children. A mismatch causes a runtime assertion.

Flutter TabBar and TabBarView Example

Create a Flutter application and replace lib/main.dart with the following code. The example creates two tabs. Each tab contains an icon and text, and each corresponding page displays centered text.

main.dart

</>
Copy
import 'package:flutter/cupertino.dart';
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,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          bottom: TabBar(
            tabs: [
              Tab(icon: Icon(Icons.android), text: "Tab 1",),
              Tab(icon: Icon(Icons.phone_iphone), text: "Tab 2"),
            ],
          ),
          title: Text('TutorialKart - TabBar & TabBarView'),
        ),
        body: TabBarView(
          children: [
            Center( child: Text("Page 1")),
            Center( child: Text("Page 2")),
          ],
        ),
      ),
    );
  }
}

What the DefaultTabController Example Does

  • DefaultTabController(length: 2) creates a controller for exactly two tabs.
  • The TabBar is placed in AppBar.bottom, below the app bar title.
  • Each Tab uses both an icon and text. A tab may also use only text, only an icon, or a custom child.
  • The two children in TabBarView appear in the same order as the two tabs.
  • The user can switch pages by tapping a tab or swiping horizontally between the tab views.

Output

Flutter TabBar & TabBarView Tutorial

Null-Safe Flutter TabBar Example

The original example predates Dart null safety. A current Flutter project can use stateless widgets, const constructors, and the newer theme API while keeping the same DefaultTabController structure.

</>
Copy
import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter TabBar Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter TabBar and TabBarView'),
          bottom: const TabBar(
            tabs: [
              Tab(
                icon: Icon(Icons.android),
                text: 'Android',
              ),
              Tab(
                icon: Icon(Icons.phone_iphone),
                text: 'iOS',
              ),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            Center(child: Text('Android page')),
            Center(child: Text('iOS page')),
          ],
        ),
      ),
    );
  }
}

A StatefulWidget is not required in this version because DefaultTabController manages the selected tab internally and the screen does not store any other mutable state.

Set the Initial Tab in DefaultTabController

Use initialIndex when the screen should open with a tab other than the first one. Tab indexes start at zero, so initialIndex: 1 selects the second tab.

</>
Copy
DefaultTabController(
  length: 3,
  initialIndex: 1,
  child: Scaffold(
    appBar: AppBar(
      bottom: const TabBar(
        tabs: [
          Tab(text: 'Overview'),
          Tab(text: 'Details'),
          Tab(text: 'Reviews'),
        ],
      ),
    ),
    body: const TabBarView(
      children: [
        Center(child: Text('Overview page')),
        Center(child: Text('Details page')),
        Center(child: Text('Reviews page')),
      ],
    ),
  ),
)

Create Scrollable Tabs in Flutter

A large number of tabs may not fit across the available screen width. Set isScrollable to true so each tab keeps a natural width and the row can scroll horizontally.

</>
Copy
const TabBar(
  isScrollable: true,
  tabs: [
    Tab(text: 'News'),
    Tab(text: 'Sports'),
    Tab(text: 'Technology'),
    Tab(text: 'Business'),
    Tab(text: 'Science'),
  ],
)

Keep tab labels concise even when the tab bar is scrollable. Short labels are easier to scan and reduce unnecessary horizontal scrolling.

Customize the Flutter TabBar Indicator and Labels

TabBar provides properties for the selected label, unselected label, indicator thickness, indicator size, indicator padding, and custom indicator decoration.

</>
Copy
const TabBar(
  indicatorWeight: 3,
  indicatorSize: TabBarIndicatorSize.label,
  labelStyle: TextStyle(
    fontWeight: FontWeight.w600,
  ),
  unselectedLabelStyle: TextStyle(
    fontWeight: FontWeight.w400,
  ),
  tabs: [
    Tab(text: 'Posts'),
    Tab(text: 'Photos'),
  ],
)

For a fully custom selection indicator, pass a Decoration to the indicator property. Keep the selected and unselected states visually distinguishable.

Disable Swipe Navigation in TabBarView

Users can swipe between TabBarView pages by default. Set physics to NeverScrollableScrollPhysics when page changes should happen only through tab taps or programmatic controller changes.

</>
Copy
const TabBarView(
  physics: NeverScrollableScrollPhysics(),
  children: [
    Center(child: Text('First page')),
    Center(child: Text('Second page')),
  ],
)

Use a Custom TabController for Programmatic Tab Changes

Use an explicit TabController when the application needs to change tabs from a button, listen for selection changes, or coordinate tab selection with another animation. The State class uses SingleTickerProviderStateMixin and disposes the controller when the widget is removed.

</>
Copy
class ControlledTabs extends StatefulWidget {
  const ControlledTabs({super.key});

  @override
  State<ControlledTabs> createState() => _ControlledTabsState();
}

class _ControlledTabsState extends State<ControlledTabs>
    with SingleTickerProviderStateMixin {
  late final TabController _tabController;

  @override
  void initState() {
    super.initState();

    _tabController = TabController(
      length: 2,
      vsync: this,
    );

    _tabController.addListener(() {
      if (!_tabController.indexIsChanging) {
        debugPrint('Selected tab: ${_tabController.index}');
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Controlled tabs'),
        bottom: TabBar(
          controller: _tabController,
          tabs: const [
            Tab(text: 'First'),
            Tab(text: 'Second'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: const [
          Center(child: Text('First page')),
          Center(child: Text('Second page')),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _tabController.animateTo(1);
        },
        child: const Icon(Icons.arrow_forward),
      ),
    );
  }
}

The same controller instance must be assigned to both TabBar and TabBarView. Separate controllers will not keep the selected tab and displayed page synchronized.

Keep TabBarView Page State After Switching Tabs

A tab page may contain a scroll position, form values, or loaded data that should remain available after the user switches tabs. For page-specific state, use a StatefulWidget and mix its State class with AutomaticKeepAliveClientMixin.

</>
Copy
class SavedTabPage extends StatefulWidget {
  const SavedTabPage({super.key});

  @override
  State<SavedTabPage> createState() => _SavedTabPageState();
}

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

  @override
  Widget build(BuildContext context) {
    super.build(context);

    return ListView.builder(
      itemCount: 50,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text('Item ${index + 1}'),
        );
      },
    );
  }
}

Call super.build(context) when using AutomaticKeepAliveClientMixin. Retain only the pages whose state needs to persist, because keeping every page alive can increase memory use.

Common Flutter TabBar and TabBarView Errors

TabController Length Does Not Match the Number of Tabs

This error occurs when the controller length differs from either TabBar.tabs.length or TabBarView.children.length. Check all three values whenever tabs are added or removed.

No TabController Found for TabBar

A TabBar without its own controller searches the widget tree for a DefaultTabController. Wrap both the tab bar and tab view with DefaultTabController, or pass the same explicit controller to both widgets.

TabBarView Has Unbounded Height

A TabBarView needs a bounded height. When it is placed inside a Column, wrap it with Expanded so it receives the remaining vertical space.

</>
Copy
Column(
  children: [
    const TabBar(
      tabs: [
        Tab(text: 'One'),
        Tab(text: 'Two'),
      ],
    ),
    const Expanded(
      child: TabBarView(
        children: [
          Center(child: Text('Page one')),
          Center(child: Text('Page two')),
        ],
      ),
    ),
  ],
)

Tab Content Resets After Switching Tabs

Move persistent values into a State object or an application state-management layer. For page-local scroll and form state, consider AutomaticKeepAliveClientMixin or a PageStorageKey.

Flutter TabBar and TabBarView FAQs

What is the difference between TabBar and TabBarView?

TabBar displays and selects tabs. TabBarView displays the page associated with the selected tab. They share a TabController so their indexes and animations remain synchronized.

Do TabBar and TabBarView need the same number of children?

Yes. The number of Tab widgets, the number of TabBarView children, and the controller length must be equal.

When should I use DefaultTabController?

Use DefaultTabController for a fixed tab layout when the selected index can be managed internally. Use an explicit TabController when code must animate to a tab, listen for changes, or coordinate tab selection with another component.

How do I make Flutter tabs scroll horizontally?

Set isScrollable: true on TabBar. This allows the row of tabs to scroll when the combined tab widths exceed the available screen width.

How do I prevent users from swiping between tabs?

Set physics: NeverScrollableScrollPhysics() on TabBarView. Users can then change pages through the tab bar or an explicit controller action.

Flutter TabBar Implementation Checklist

  • The controller length matches the number of Tab widgets and TabBarView children.
  • TabBar and TabBarView use the same DefaultTabController or explicit TabController.
  • A custom TabController is disposed in the State object’s dispose() method.
  • TabBarView receives bounded height, especially when placed inside a Column.
  • Scrollable tabs are enabled when the tab labels do not fit across the screen.
  • Selected and unselected tab states remain visually distinguishable.
  • Important tab-page state is preserved when users switch between pages.
  • Tab labels and icons remain understandable with text scaling and accessibility settings.

Summary of Flutter TabBar and TabBarView

In this Flutter Tutorial, we used TabBar and TabBarView with DefaultTabController, set an initial tab, created scrollable tabs, customized the indicator, disabled swipe navigation, and used an explicit TabController for programmatic selection. The controller length, tab count, and page count must always match.