Reputation: 8714
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.
Upvotes: 4
Views: 3059
Reputation: 8714
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),
],
)
);
}
}
Upvotes: 3
Reputation: 63594
I know too little about widget tree
feel free to correct and update the answer.
All tabs are building initially, while the _tabController!.index
is 0
. _next
method does to wait for animateTo
to finish the animation, then call setState
. Using setState
rebuild the UI under build
but the TabBarView
is not rebuilding until we are telling it that it is having changes.
widget tree is smart enough while updating the UI. -🔎
While creating a widget, without providing key it generates objectRuntimeType
key, and doesn't change(same for providing key) on calling setState
.
While here, update is depending on key, and widget tree(key) is not different for TabBarView
and TabBarView
is thinking nothing happen to me, we can't see any update on UI.
Then next comes by adding listener
Register a closure to be called when the object changes.
We can add listener on TabController
to listen changes and inside setState
to update the UI. You can also remove setState
from _back
and _next
methods.
_tabController = TabController(
length: 3,
vsync: this,
)..addListener(() {
setState(() {});
});
Or just
Use
index
instead of_tabController!.index
while both responsibility is same inside Row.
Upvotes: 0
Reputation: 283
From documentation of flutter animatedTo method description: animatedTo immediately sets index and previous index and then plays the animation from its current value to index.
Once the _tab method is called, the widget that returns from it is now in the widget tree.
Whenever the build method is run again then its appearance will change.
Every time the _tab method is called, the part of the code that does not return the widget runs again, and return widget which is already in the widget tree. But it is necessary to run the build method again for the widget to change.
The build is called when the widget is built for the first time. But after that, it is necessary to re-run the build method with setState.
I convert your tab navigation buttons to Widget class. We can more easily understand the comparison with the _tab method and Widget class.
When navigating from index 0 to index 1,2,3 the following is printed in the logs: flutter: Page 0: 1 / 3 flutter: Page 0: 1 / 3
import 'package:flutter/material.dart';
void main() {
runApp(const TestMyApp());
}
class TestMyApp extends StatelessWidget {
const TestMyApp({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,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
children: [
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_tab(1),
_tab(2),
_tab(3),
],
),
),
Expanded(
child: TabNavigationWidget(tabController: _tabController!)),
],
));
}
Widget _tab(int index) {
return Text('$index');
}
}
class TabNavigationWidget extends StatelessWidget {
TabController tabController;
TabNavigationWidget({Key? key, required this.tabController})
: super(key: key);
@override
Widget build(BuildContext context) {
var value = "Page : ${tabController.index + 1} / ${tabController.length}";
print(value);
return Row(
children: [
TextButton(
onPressed: _back,
child: Text("Back ${tabController.index}"),
),
Text(
value,
style: const TextStyle(),
),
TextButton(
onPressed: _next,
child: const Text("Next"),
),
],
);
}
_back() {
if (tabController.index > 0) {
tabController.animateTo(tabController.index - 1);
}
}
_next() {
if (tabController.index < tabController.length - 1) {
tabController.animateTo(tabController.index + 1);
}
}
}
I recommend to you use widgets classes instead of _tab() methods. Your _tab methods build 3 times when the setState method is called.
Upvotes: 0
Reputation: 10463
You can use Stream to listen for tab index change when switching pages. Update the index when changing page.
final _tabPageIndicator = StreamController<int>.broadcast();
Stream<int> get getTabPage => _tabPageIndicator.stream;
...
// Update tab index on Stream
_tabPageIndicator.sink.add(_tabController!.index + 1);
Then using StreamBuilder, this gets rebuild when there's a change on the Stream it's listening to. There's no need to use setState()
to rebuild the Widgets inside StreamBuilder.
StreamBuilder<int>(
stream: getTabPage,
builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
if (snapshot.hasData && snapshot.data != null) {
tabIndex = snapshot.data!;
}
return Text(
'Page $index: [$tabIndex / ${_tabController!.length}]',
style: const TextStyle(),
);
}
),
Complete Sample
import 'dart:async';
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 tabIndex = 1;
final _tabPageIndicator = StreamController<int>.broadcast();
Stream<int> get getTabPage => _tabPageIndicator.stream;
@override
void initState() {
super.initState();
_tabController = TabController(
length: 3,
vsync: this,
);
// Update tab index on Stream
_tabPageIndicator.sink.add(_tabController!.index + 1);
}
@override
void dispose() {
super.dispose();
// Close Stream when not in use
_tabPageIndicator.close();
}
_back() {
if (_tabController!.index > 0) {
_tabController!.animateTo(_tabController!.index - 1);
// setState(() {
// });
// Update tab index on Stream
_tabPageIndicator.sink.add(_tabController!.index + 1);
}
}
_next() {
if (_tabController!.index < _tabController!.length - 1) {
_tabController!.animateTo(_tabController!.index + 1);
// setState(() {
// });
// Update tab index on Stream
_tabPageIndicator.sink.add(_tabController!.index + 1);
}
}
Widget _tab(int index) {
var value =
"Page $index: ${_tabController!.index + 1} / ${_tabController!.length}";
debugPrint(value);
return Row(
children: [
TextButton(
onPressed: _back,
child: const Text("Back"),
),
// StreamBuilder rebuilds every time there's a change on Stream
StreamBuilder<int>(
stream: getTabPage,
builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
if (snapshot.hasData && snapshot.data != null) {
tabIndex = snapshot.data!;
}
return Text(
'Page $index: [$tabIndex / ${_tabController!.length}]',
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),
],
));
}
}
Upvotes: 0