Skip to content

Commit 63f0e93

Browse files
committed
feat(TabView): use UITab with iOS 18+ and allow search role usage
1 parent 76d9ed8 commit 63f0e93

File tree

4 files changed

+127
-33
lines changed

4 files changed

+127
-33
lines changed

apps/toolbox/src/pages/tabview.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
</TabViewItem>
3737

3838
<!-- Third tab -->
39-
<TabViewItem title="Third" iconSource="font://&#x43;">
39+
<!-- change role to 'search' to see different behavior on iOS -->
40+
<TabViewItem title="Third" iconSource="font://&#x43;" role="none">
4041
<Label text="Third Tab Content" textAlignment="center" verticalAlignment="center" />
4142
</TabViewItem>
4243
</TabView>

packages/core/ui/tab-view/index.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export class TabViewItem extends ViewBase {
3737
*/
3838
public iconSource: string;
3939

40+
/**
41+
* Gets or sets the role of the TabViewItem.
42+
*/
43+
public role: string;
44+
4045
/**
4146
* Gets or sets the text transform of the tab titles.
4247
*

packages/core/ui/tab-view/index.ios.ts

Lines changed: 119 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -252,22 +252,44 @@ export class TabViewItem extends TabViewItemBase {
252252
const index = parent.items.indexOf(this);
253253
const title = getTransformedText(this.title, this.style.textTransform);
254254

255-
const tabBarItem = UITabBarItem.alloc().initWithTitleImageTag(title, icon, index);
256-
updateTitleAndIconPositions(this, tabBarItem, controller);
257-
258-
// There is no need to request title styles update here in newer versions as styling is handled by bar appearance instance
259-
if (!__VISIONOS__ && SDK_VERSION < 15) {
260-
// TODO: Repeating code. Make TabViewItemBase - ViewBase and move the colorProperty on tabViewItem.
261-
// Delete the repeating code.
262-
const states = getTitleAttributesForStates(parent);
263-
applyStatesToItem(tabBarItem, states);
255+
if (SDK_VERSION >= 18) {
256+
// iOS 18+: use UITab instead of UITabBarItem.
257+
// The UITab instances are created and managed at the TabView level,
258+
// so here we just update the corresponding tab for this controller.
259+
const identifier = `${index}`;
260+
const tabController = parent.viewController as UITabBarController;
261+
try {
262+
const tab = tabController.tabForIdentifier(identifier);
263+
if (tab) {
264+
tab.title = title;
265+
tab.image = icon;
266+
}
267+
} catch (e) {
268+
// Fallback: if tabForIdentifier is not available for some reason,
269+
// do not crash – rely on existing tab configuration.
270+
}
271+
} else {
272+
// iOS < 18: keep using UITabBarItem-based configuration.
273+
const tabBarItem = UITabBarItem.alloc().initWithTitleImageTag(title, icon, index);
274+
updateTitleAndIconPositions(this, tabBarItem, controller);
275+
276+
// There is no need to request title styles update here in newer versions as styling is handled by bar appearance instance
277+
if (!__VISIONOS__ && SDK_VERSION < 15) {
278+
// TODO: Repeating code. Make TabViewItemBase - ViewBase and move the colorProperty on tabViewItem.
279+
// Delete the repeating code.
280+
const states = getTitleAttributesForStates(parent);
281+
applyStatesToItem(tabBarItem, states);
282+
}
283+
controller.tabBarItem = tabBarItem;
264284
}
265-
controller.tabBarItem = tabBarItem;
266285
}
267286
}
268287

269288
public _updateTitleAndIconPositions() {
270-
if (!this.__controller || !this.__controller.tabBarItem) {
289+
// UITab-based configuration (iOS 18+) does not expose the same per-item
290+
// title/icon positioning APIs as UITabBarItem, so we only adjust
291+
// positions when using the legacy UITabBarItem setup.
292+
if (SDK_VERSION >= 18 || !this.__controller || !this.__controller.tabBarItem) {
271293
return;
272294
}
273295
updateTitleAndIconPositions(this, this.__controller.tabBarItem, this.__controller);
@@ -494,34 +516,78 @@ export class TabView extends TabViewBase {
494516
private setViewControllers(items: TabViewItem[]) {
495517
const length = items ? items.length : 0;
496518
if (length === 0) {
497-
this._ios.viewControllers = null;
519+
if (SDK_VERSION >= 18) {
520+
// Clear tabs on iOS 18+ when there are no items.
521+
try {
522+
this._ios.tabs = NSArray.arrayWithArray([]);
523+
} catch (e) {
524+
// Fallback if tabs API is unavailable for some reason.
525+
this._ios.viewControllers = null;
526+
}
527+
} else {
528+
this._ios.viewControllers = null;
529+
}
498530
return;
499531
}
500532

501-
const controllers = NSMutableArray.alloc<UIViewController>().initWithCapacity(length);
502-
const states = getTitleAttributesForStates(this);
533+
if (SDK_VERSION >= 18) {
534+
// iOS 18+: build UITab instances and assign them to the controller.
535+
const tabs = [];
536+
const controllers = [];
537+
items.forEach((item, i) => {
538+
const controller = this.getViewController(item);
539+
controllers.push(controller);
540+
const icon = this._getIcon(item);
541+
const title = item.title || '';
542+
const identifier = `${i}`;
543+
let tab: UITab;
544+
if (item.role === 'search') {
545+
tab = UISearchTab.alloc().initWithTitleImageIdentifierViewControllerProvider(title, icon, identifier, (t) => {
546+
return controller;
547+
});
548+
} else {
549+
tab = UITab.alloc().initWithTitleImageIdentifierViewControllerProvider(title, icon, identifier, (t) => {
550+
return controller;
551+
});
552+
}
503553

504-
items.forEach((item, i) => {
505-
const controller = this.getViewController(item);
506-
const icon = this._getIcon(item);
507-
const tabBarItem = UITabBarItem.alloc().initWithTitleImageTag(item.title || '', icon, i);
508-
updateTitleAndIconPositions(item, tabBarItem, controller);
554+
tabs.push(tab);
555+
(<TabViewItemDefinition>item).canBeLoaded = true;
556+
});
509557

510-
if (!__VISIONOS__ && SDK_VERSION < 15) {
511-
applyStatesToItem(tabBarItem, states);
512-
}
558+
try {
559+
// Prefer animated setter when available.
560+
this._ios.tabs = NSArray.arrayWithArray(tabs);
561+
} catch (e) {}
562+
this._ios.viewControllers = NSArray.arrayWithArray(controllers);
563+
this._ios.customizableViewControllers = null;
564+
} else {
565+
// iOS < 18: keep using UITabBarItem-based configuration.
566+
const controllers = [];
567+
const states = getTitleAttributesForStates(this);
568+
569+
items.forEach((item, i) => {
570+
const controller = this.getViewController(item);
571+
const icon = this._getIcon(item);
572+
const tabBarItem = UITabBarItem.alloc().initWithTitleImageTag(item.title || '', icon, i);
573+
updateTitleAndIconPositions(item, tabBarItem, controller);
574+
575+
if (!__VISIONOS__ && SDK_VERSION < 15) {
576+
applyStatesToItem(tabBarItem, states);
577+
}
513578

514-
controller.tabBarItem = tabBarItem;
515-
controllers.addObject(controller);
516-
(<TabViewItemDefinition>item).canBeLoaded = true;
517-
});
579+
controller.tabBarItem = tabBarItem;
580+
controllers.push(controller);
581+
(<TabViewItemDefinition>item).canBeLoaded = true;
582+
});
518583

519-
if (SDK_VERSION >= 15) {
520-
this.updateBarItemAppearance(<UITabBar>this._ios.tabBar, states);
521-
}
584+
if (SDK_VERSION >= 15) {
585+
this.updateBarItemAppearance(<UITabBar>this._ios.tabBar, states);
586+
}
522587

523-
this._ios.viewControllers = controllers;
524-
this._ios.customizableViewControllers = null;
588+
this._ios.viewControllers = NSArray.arrayWithArray(controllers);
589+
this._ios.customizableViewControllers = null;
590+
}
525591

526592
// When we set this._ios.viewControllers, someone is clearing the moreNavigationController.delegate, so we have to reassign it each time here.
527593
this._ios.moreNavigationController.delegate = this._moreNavigationControllerDelegate;
@@ -816,6 +882,13 @@ export class TabView extends TabViewBase {
816882

817883
const accessory = UITabAccessory.alloc().initWithContentView(container);
818884
setAccessory(accessory);
885+
// Work around UIKit occasionally caching accessory sizes too aggressively
886+
// by explicitly triggering a layout pass on the tab bar.
887+
const tabBar = this._ios?.tabBar;
888+
if (tabBar) {
889+
tabBar.setNeedsLayout();
890+
tabBar.layoutIfNeeded();
891+
}
819892
// Keep references for later teardown
820893
this._bottomAccessoryNsView = nsView;
821894
}
@@ -825,11 +898,25 @@ export class TabView extends TabViewBase {
825898
class NSTabAccessoryContainer extends UIView {
826899
_owner: WeakRef<View>;
827900
static initWithOwner(owner: WeakRef<View>): NSTabAccessoryContainer {
828-
const v = NSTabAccessoryContainer.new() as NSTabAccessoryContainer;
901+
const v = NSTabAccessoryContainer.new() as unknown as NSTabAccessoryContainer;
829902
v._owner = owner;
830903
return v;
831904
}
832905

906+
override traitCollectionDidChange(previousTraitCollection: UITraitCollection) {
907+
super.traitCollectionDidChange(previousTraitCollection);
908+
if (!previousTraitCollection) {
909+
return;
910+
}
911+
// When size classes change (e.g., compact regular),
912+
// ask UIKit to recompute this accessory's intrinsic size.
913+
if (this.traitCollection?.horizontalSizeClass !== previousTraitCollection.horizontalSizeClass) {
914+
this.invalidateIntrinsicContentSize();
915+
this.setNeedsLayout();
916+
this.layoutIfNeeded();
917+
}
918+
}
919+
833920
override layoutSubviews() {
834921
super.layoutSubviews();
835922
const owner = this._owner?.deref();

packages/core/ui/tab-view/tab-view-common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const traceCategory = 'TabView';
1212

1313
@CSSType('TabViewItem')
1414
export abstract class TabViewItemBase extends ViewBase implements TabViewItemDefinition, AddChildFromBuilder {
15+
role: string;
1516
private _title = '';
1617
private _view: View;
1718
private _iconSource: string;

0 commit comments

Comments
 (0)