Skip to content

Commit 93cd430

Browse files
author
Muhammad Usman
committed
feat(tabbar): add indexed tab mode with on-demand tab building and caching
1 parent a2c8779 commit 93cd430

2 files changed

Lines changed: 96 additions & 23 deletions

File tree

modules/ensemble/lib/layout/tab/tab_bar_controller.dart

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class TabBarController extends BoxController {
3030
TabBarAction? tabBarAction;
3131

3232
int selectedIndex = 0;
33+
bool useIndexedTab = false;
3334

3435
List<TabItem> _originalItems = [];
3536
List<TabItem> _visibleItems = [];
@@ -65,6 +66,8 @@ class TabBarController extends BoxController {
6566
var setters = super.getBaseSetters();
6667
setters.addAll({
6768
'items': (values) => items = values,
69+
'useIndexedTab': (value) =>
70+
useIndexedTab = Utils.getBool(value, fallback: false),
6871
});
6972
return setters;
7073
}
@@ -80,10 +83,10 @@ class TabBarController extends BoxController {
8083
class TabItem {
8184
TabItem(
8285
{this.icon,
83-
this.label,
84-
this.tabWidget,
85-
this.bodyWidget,
86-
this.isVisible}) {
86+
this.label,
87+
this.tabWidget,
88+
this.bodyWidget,
89+
this.isVisible}) {
8790
if (icon == null && label == null && tabWidget == null) {
8891
throw LanguageError(
8992
"Each tab requires either an icon, a label, or a custom tabWidget");

modules/ensemble/lib/layout/tab_bar.dart

Lines changed: 89 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,20 +90,29 @@ abstract class BaseTabBar extends StatefulWidget
9090
EnsembleAction.from(action, initiator: this),
9191
'onTabSelectionHaptic': (value) =>
9292
_controller.onTabSelectionHaptic = Utils.optionalString(value),
93+
'useIndexedTab': (value) =>
94+
_controller.useIndexedTab = Utils.getBool(value, fallback: false),
9395
};
9496
}
9597
}
9698

9799
class TabBarState extends BaseTabBarState {
100+
// Cache for indexed tab building mode
101+
late List<Widget?> _cache;
102+
98103
@override
99104
void initState() {
100105
super.initState();
101106
_initializeTabController();
102107
}
103108

104109
void _initializeTabController() {
110+
final safeIndex = _getValidInitialIndex();
111+
if (widget.controller.useIndexedTab) {
112+
_cache = List<Widget?>.filled(widget.controller.items.length, null);
113+
}
105114
tabController = TabController(
106-
initialIndex: _getValidInitialIndex(),
115+
initialIndex: safeIndex,
107116
length: widget.controller.items.length,
108117
vsync: this,
109118
);
@@ -200,6 +209,12 @@ class TabBarState extends BaseTabBarState {
200209
void _reinitializeTabController() {
201210
tabController.removeListener(notifyListener);
202211
tabController.dispose();
212+
// If a tab became hidden and the previously-selected index is now out of
213+
// bounds, reset it before recreating the TabController and rebuilding,
214+
// otherwise buildSelectedTab() will throw a RangeError.
215+
if (widget._controller.selectedIndex >= widget._controller.items.length) {
216+
widget._controller.selectedIndex = 0;
217+
}
203218
_initializeTabController();
204219
}
205220

@@ -216,6 +231,20 @@ class TabBarState extends BaseTabBarState {
216231
if (widget.controller.selectedIndex == index) {
217232
return;
218233
}
234+
235+
// If using indexed tab mode, build the tab on-demand before switching
236+
if (widget.controller.useIndexedTab) {
237+
final scopeManager = DataScopeWidget.getScope(context);
238+
if (scopeManager != null &&
239+
index < _cache.length &&
240+
_cache[index] == null) {
241+
final items = widget.controller.items;
242+
if (index < items.length) {
243+
_cache[index] = _buildTabAt(scopeManager, items[index]);
244+
}
245+
}
246+
}
247+
219248
setState(() {
220249
widget.controller.selectedIndex = index;
221250
});
@@ -264,13 +293,13 @@ class TabBarState extends BaseTabBarState {
264293
else {
265294
bool isExpanded = widget._controller.expanded;
266295

267-
// if Expanded is set, our content needs to stretch to the left-over height
268-
// Note we make each Builder unique, as it tends to re-use
269-
// the states (down the tree) from the previous Builder
270-
// https://stackoverflow.com/questions/55425804/using-builder-instead-of-statelesswidget
271-
Widget tabContent = Builder(
272-
key: UniqueKey(),
273-
builder: (BuildContext context) => buildSelectedTab());
296+
// Use indexed tab building if enabled, otherwise use classic single-tab rendering
297+
Widget tabContent = widget._controller.useIndexedTab
298+
? _buildTabBodies(context)
299+
: Builder(
300+
key: ValueKey(widget._controller.selectedIndex),
301+
builder: (BuildContext context) => buildSelectedTab());
302+
274303
if (isExpanded) {
275304
tabContent = Expanded(child: tabContent);
276305
}
@@ -279,16 +308,7 @@ class TabBarState extends BaseTabBarState {
279308
crossAxisAlignment: CrossAxisAlignment.stretch,
280309
children: [
281310
buildTabBar(),
282-
// builder gives us dynamic height control vs TabBarView, but
283-
// is sub-optimal since it recreates the tab content on each pass.
284-
// This means onLoad API may be called multiple times in debug mode
285-
tabContent
286-
287-
// This cause Expanded child to fail
288-
// Padding(
289-
// padding: const EdgeInsets.only(left: 0),
290-
// child: Builder(builder: (BuildContext context) => buildSelectedTab())
291-
// )
311+
tabContent,
292312
],
293313
);
294314
// if Expanded is set, stretch our column to left-over height
@@ -320,4 +340,54 @@ class TabBarState extends BaseTabBarState {
320340
}
321341
return const Text("Unknown widget for this Tab");
322342
}
323-
}
343+
344+
/// Builds tabs on-demand with caching (used when useIndexedTab is true).
345+
/// - Non-expanded: Column + Offstage for hidden tabs (zero-height)
346+
/// - Expanded: IndexedStack (bounded height)
347+
Widget _buildTabBodies(BuildContext context) {
348+
final scopeManager = DataScopeWidget.getScope(context);
349+
if (scopeManager == null) return const Text('Unknown widget for this Tab');
350+
351+
final items = widget._controller.items;
352+
final selectedIndex = widget._controller.selectedIndex;
353+
354+
// Ensure cache size matches current item count
355+
if (_cache.length != items.length) {
356+
_cache = List<Widget?>.filled(items.length, null);
357+
}
358+
359+
// Build the selected tab now if not already cached
360+
_cache[selectedIndex] ??= _buildTabAt(scopeManager, items[selectedIndex]);
361+
362+
// Non-expanded: lives in unconstrained scroll context
363+
// Use Column + Offstage to zero-out hidden tabs
364+
if (!widget._controller.expanded) {
365+
return Column(
366+
mainAxisSize: MainAxisSize.min,
367+
crossAxisAlignment: CrossAxisAlignment.stretch,
368+
children: List.generate(items.length, (i) {
369+
return Offstage(
370+
offstage: i != selectedIndex,
371+
child: _cache[i] ?? const SizedBox.shrink(),
372+
);
373+
}),
374+
);
375+
}
376+
377+
// Expanded: bounded height is provided by Expanded wrapper
378+
// IndexedStack is safe here and stacks children in bounded space
379+
return IndexedStack(
380+
index: selectedIndex,
381+
children: List.generate(
382+
items.length,
383+
(i) => _cache[i] ?? const SizedBox.shrink(),
384+
),
385+
);
386+
}
387+
388+
/// Build a single tab at given index
389+
Widget _buildTabAt(ScopeManager scopeManager, TabItem tab) {
390+
if (tab.bodyWidget == null) return const SizedBox.shrink();
391+
return scopeManager.buildWidgetFromDefinition(tab.bodyWidget);
392+
}
393+
}

0 commit comments

Comments
 (0)