Skip to content

Commit d73f7ad

Browse files
authored
Do not crash if the controller and TabBarView are updated at different phases (build and layout) of the same frame. (#104998)
1 parent e9230ba commit d73f7ad

File tree

2 files changed

+177
-18
lines changed

2 files changed

+177
-18
lines changed

packages/flutter/lib/src/material/tabs.dart

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,7 @@ class _TabBarState extends State<TabBar> {
914914
int? _currentIndex;
915915
late double _tabStripWidth;
916916
late List<GlobalKey> _tabKeys;
917+
bool _debugHasScheduledValidTabsCountCheck = false;
917918

918919
@override
919920
void initState() {
@@ -1147,18 +1148,34 @@ class _TabBarState extends State<TabBar> {
11471148
);
11481149
}
11491150

1151+
bool _debugScheduleCheckHasValidTabsCount() {
1152+
if (_debugHasScheduledValidTabsCountCheck) {
1153+
return true;
1154+
}
1155+
WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
1156+
_debugHasScheduledValidTabsCountCheck = false;
1157+
if (!mounted) {
1158+
return;
1159+
}
1160+
assert(() {
1161+
if (_controller!.length != widget.tabs.length) {
1162+
throw FlutterError(
1163+
"Controller's length property (${_controller!.length}) does not match the "
1164+
"number of tabs (${widget.tabs.length}) present in TabBar's tabs property.",
1165+
);
1166+
}
1167+
return true;
1168+
}());
1169+
});
1170+
_debugHasScheduledValidTabsCountCheck = true;
1171+
return true;
1172+
}
1173+
11501174
@override
11511175
Widget build(BuildContext context) {
11521176
assert(debugCheckHasMaterialLocalizations(context));
1153-
assert(() {
1154-
if (_controller!.length != widget.tabs.length) {
1155-
throw FlutterError(
1156-
"Controller's length property (${_controller!.length}) does not match the "
1157-
"number of tabs (${widget.tabs.length}) present in TabBar's tabs property.",
1158-
);
1159-
}
1160-
return true;
1161-
}());
1177+
assert(_debugScheduleCheckHasValidTabsCount());
1178+
11621179
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
11631180
if (_controller!.length == 0) {
11641181
return Container(
@@ -1375,6 +1392,7 @@ class _TabBarViewState extends State<TabBarView> {
13751392
late List<Widget> _childrenWithKey;
13761393
int? _currentIndex;
13771394
int _warpUnderwayCount = 0;
1395+
bool _debugHasScheduledValidChildrenCountCheck = false;
13781396

13791397
// If the TabBarView is rebuilt with a new tab controller, the caller should
13801398
// dispose the old one. In that case the old controller's animation will be
@@ -1550,17 +1568,33 @@ class _TabBarViewState extends State<TabBarView> {
15501568
return false;
15511569
}
15521570

1571+
bool _debugScheduleCheckHasValidChildrenCount() {
1572+
if (_debugHasScheduledValidChildrenCountCheck) {
1573+
return true;
1574+
}
1575+
WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
1576+
_debugHasScheduledValidChildrenCountCheck = false;
1577+
if (!mounted) {
1578+
return;
1579+
}
1580+
assert(() {
1581+
if (_controller!.length != widget.children.length) {
1582+
throw FlutterError(
1583+
"Controller's length property (${_controller!.length}) does not match the "
1584+
"number of children (${widget.children.length}) present in TabBarView's children property.",
1585+
);
1586+
}
1587+
return true;
1588+
}());
1589+
});
1590+
_debugHasScheduledValidChildrenCountCheck = true;
1591+
return true;
1592+
}
1593+
15531594
@override
15541595
Widget build(BuildContext context) {
1555-
assert(() {
1556-
if (_controller!.length != widget.children.length) {
1557-
throw FlutterError(
1558-
"Controller's length property (${_controller!.length}) does not match the "
1559-
"number of tabs (${widget.children.length}) present in TabBar's tabs property.",
1560-
);
1561-
}
1562-
return true;
1563-
}());
1596+
assert(_debugScheduleCheckHasValidChildrenCount());
1597+
15641598
return NotificationListener<ScrollNotification>(
15651599
onNotification: _handleScrollNotification,
15661600
child: PageView(

packages/flutter/test/material/tabs_test.dart

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4617,6 +4617,131 @@ void main() {
46174617
);
46184618
gesture.removePointer();
46194619
});
4620+
4621+
testWidgets('Do not crash if the controller and TabBarView are updated at different phases(build and layout) of the same frame', (WidgetTester tester) async {
4622+
// Regression test for https://github.com/flutter/flutter/issues/104994.
4623+
List<String> tabTextContent = <String>[];
4624+
4625+
await tester.pumpWidget(
4626+
MaterialApp(
4627+
home: StatefulBuilder(
4628+
builder: (BuildContext context, StateSetter setState) {
4629+
return DefaultTabController(
4630+
length: tabTextContent.length,
4631+
child: Scaffold(
4632+
appBar: AppBar(
4633+
title: const Text('Default TabBar Preview'),
4634+
bottom: tabTextContent.isNotEmpty
4635+
? TabBar(
4636+
isScrollable: true,
4637+
tabs: tabTextContent.map((String textContent) => Tab(text: textContent)).toList(),
4638+
)
4639+
: null,
4640+
),
4641+
body: LayoutBuilder(
4642+
builder: (_, __) {
4643+
return tabTextContent.isNotEmpty
4644+
? TabBarView(
4645+
children: tabTextContent.map((String textContent) => Tab(text: "$textContent's view")).toList(),
4646+
)
4647+
: const Center(child: Text('No tabs'));
4648+
},
4649+
),
4650+
bottomNavigationBar: BottomAppBar(
4651+
child: Row(
4652+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
4653+
children: <Widget>[
4654+
IconButton(
4655+
key: const Key('Add tab'),
4656+
icon: const Icon(Icons.add),
4657+
onPressed: () {
4658+
setState(() {
4659+
tabTextContent = List<String>.from(tabTextContent)
4660+
..add('Tab ${tabTextContent.length + 1}');
4661+
});
4662+
},
4663+
),
4664+
IconButton(
4665+
key: const Key('Delete tab'),
4666+
icon: const Icon(Icons.delete),
4667+
onPressed: () {
4668+
setState(() {
4669+
tabTextContent = List<String>.from(tabTextContent)
4670+
..removeLast();
4671+
});
4672+
},
4673+
),
4674+
],
4675+
),
4676+
),
4677+
),
4678+
);
4679+
},
4680+
),
4681+
),
4682+
);
4683+
4684+
// Initializes with zero tabs properly
4685+
expect(find.text('No tabs'), findsOneWidget);
4686+
await tester.tap(find.byKey(const Key('Add tab')));
4687+
await tester.pumpAndSettle();
4688+
expect(find.text('Tab 1'), findsOneWidget);
4689+
expect(find.text("Tab 1's view"), findsOneWidget);
4690+
4691+
// Dynamically updates to zero tabs properly
4692+
await tester.tap(find.byKey(const Key('Delete tab')));
4693+
await tester.pumpAndSettle();
4694+
expect(find.text('No tabs'), findsOneWidget);
4695+
});
4696+
4697+
testWidgets("Throw if the controller's length mismatch the tabs count", (WidgetTester tester) async {
4698+
await tester.pumpWidget(
4699+
MaterialApp(
4700+
home: DefaultTabController(
4701+
length: 2,
4702+
child: Scaffold(
4703+
appBar: AppBar(
4704+
bottom: TabBar(
4705+
tabs: <Widget>[
4706+
Container(width: 100, height: 100, color: Colors.green),
4707+
],
4708+
),
4709+
),
4710+
),
4711+
),
4712+
),
4713+
);
4714+
4715+
expect(tester.takeException(), isAssertionError);
4716+
});
4717+
4718+
testWidgets("Throw if the controller's length mismatch the TabBarView‘s children count", (WidgetTester tester) async {
4719+
await tester.pumpWidget(
4720+
MaterialApp(
4721+
home: DefaultTabController(
4722+
length: 1,
4723+
child: Scaffold(
4724+
appBar: AppBar(
4725+
bottom: TabBar(
4726+
tabs: <Widget>[
4727+
Container(width: 100, height: 100, color: Colors.green),
4728+
],
4729+
),
4730+
),
4731+
body: const TabBarView(
4732+
children: <Widget>[
4733+
Icon(Icons.directions_car),
4734+
Icon(Icons.directions_transit),
4735+
Icon(Icons.directions_bike),
4736+
],
4737+
),
4738+
),
4739+
),
4740+
),
4741+
);
4742+
4743+
expect(tester.takeException(), isAssertionError);
4744+
});
46204745
}
46214746

46224747
class KeepAliveInk extends StatefulWidget {

0 commit comments

Comments
 (0)