Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TabBarView page not rebuilding correctly

I am trying to display the tab number on each page of a TabBarView, by reading the index of its TabController. For some reason though, the value does not seem to update correctly visually, even though the correct value is printed in the logs.

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: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

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

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  TabController? _tabController;

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

    _tabController = TabController(
      length: 3,
      vsync: this,
    );
  }

  _back() {
    if (_tabController!.index > 0) {
      _tabController!.animateTo(_tabController!.index - 1);

      setState(() {});
    }
  }

  _next() {
    if (_tabController!.index < _tabController!.length - 1) {
      _tabController!.animateTo(_tabController!.index + 1);

      setState(() {});
    }
  }

  Widget _tab(int index) {
    var value = "Page $index:   ${_tabController!.index + 1} / ${_tabController!.length}";
    print(value);

    return Row(
      children: [
        TextButton(
          onPressed: _back,
          child: const Text("Back"),
        ),
        Text(value,
          style: const TextStyle(
          ),
        ),
        TextButton(
          onPressed: _next,
          child: const Text("Next"),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: TabBarView(
        controller: _tabController,
        children: [
          _tab(1),
          _tab(2),
          _tab(3),
        ],
      )
    );
  }
}

When navigating from index 0 to index 1, the following is printed in the logs, as expected:

I/flutter (25730): Page 1:   2 / 3
I/flutter (25730): Page 2:   2 / 3
I/flutter (25730): Page 3:   2 / 3

However, what is actually displayed is Page 2: 1 / 3

I have tried using UniqueKey as well as calling setState on the next frame, but it doesn't make a difference. Calling setState with a hardcoded delay seems to work, but it also seems wrong.

Why is what's printed in the logs different to what's being displayed, considering that all tabs are rebuilt when setState is called? Assuming it has something to do with the PageView/Scrollable/Viewport widgets that make up the TabBarView, but what exactly is going on? Notice how even when going from page 1 to page 2 and then to page 3, none of the values on any of the pages are being updated, so even the on-screen widgets aren't rebuilding correctly.

like image 226
Ovidiu Avatar asked Sep 15 '25 21:09

Ovidiu


1 Answers

I am finally able to answer my own question. This odd behaviour is explained by the internal logic of the _TabBarViewState. The TabBarView uses a PageView internally, which it animates based on changes to the TabController index. Here is a snippet of that logic:

final int previousIndex = _controller!.previousIndex;
if ((_currentIndex! - previousIndex).abs() == 1) {
  _warpUnderwayCount += 1;
  await _pageController.animateToPage(_currentIndex!, duration: kTabScrollDuration, curve: Curves.ease);
  _warpUnderwayCount -= 1;
  return Future<void>.value();
}

Note that it keeps track of whether an animation is in progress with the _warpUnderwayCount variable, which will get a value of 1 as soon as we call animateTo() on the TabController.

Additionally, the _TabBarViewState maintains a _children list of widgets representing each page, which is first created when the TabBarView is initialized, and can later be updated only by the _TabBarViewState itself by calling its _updateChildren() function:

void _updateChildren() {
  _children = widget.children;
  _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children);
}

The _TabBarViewState also overrides the default behaviour of the didUpdateWidget function:

@override
void didUpdateWidget(TabBarView oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (widget.controller != oldWidget.controller)
    _updateTabController();
  if (widget.children != oldWidget.children && _warpUnderwayCount == 0)
    _updateChildren();
}

Note that even though we provide a new list of children from our parent stateful widget by calling setState() just after animateTo(), that list of children will be ignored by the TabBarView because _warpUnderwayCount will have a value of 1 at the point that didUpdateWidget is called, and therefore _updateChildren() will not be called as per the internal logic shown above.

I believe this is a constraint of the TabBarView widget that has to do with its complexity in terms of coordinating with its internal PageView as well as with an optional TabBar widget with which it shares a TabController.

In terms of a solution, given that rebuilding the whole TabBarView by updating its Key would cancel the animation, and that setting new children by calling setState() after calling animateTo() is ignored if done while the page change animation is still running, I can only think of calling setState() after saving all the variables required for rebuilding the children and before animateTo() is called on the next frame. If it is called within the same frame, the children will still not update because didUpdateWidget will still be called after the animation starts. Here is the code from my question, updated with the proposed solution:

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: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

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

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  TabController? _tabController;
  int _newIndex = 0;

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

    _tabController = TabController(
      length: 3,
      vsync: this,
    );
  }

  _back() {
    if (_tabController!.index > 0) {
      _newIndex = _tabController!.index - 1;

      setState(() {});

      WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
        _tabController!.animateTo(_newIndex);
      });
    }
  }

  _next() {
    if (_tabController!.index < _tabController!.length - 1) {
      _newIndex = _tabController!.index + 1;

      setState(() {});

      WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
        _tabController!.animateTo(_newIndex);
      });
    }
  }

  Widget _tab(int index) {
    var value = "Page $index:   ${_newIndex + 1} / ${_tabController!.length}";
    print(value);

    return Row(
      children: [
        TextButton(
          onPressed: _back,
          child: const Text("Back"),
        ),
        Text(value,
          style: const TextStyle(
          ),
        ),
        TextButton(
          onPressed: _next,
          child: const Text("Next"),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: TabBarView(
          controller: _tabController,
          children: [
            _tab(1),
            _tab(2),
            _tab(3),
          ],
        )
    );
  }
}
like image 79
Ovidiu Avatar answered Sep 17 '25 10:09

Ovidiu