Skip to content

Commit 3015f4c

Browse files
authored
feat(ios): TabView iOS 26+ bottomAccessory + minimizeBehavior (#10945)
1 parent 799f18a commit 3015f4c

File tree

6 files changed

+275
-10
lines changed

6 files changed

+275
-10
lines changed

apps/toolbox/src/pages/tabview.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EventData, Observable, Page, Color, TabView, TabViewItem } from '@nativescript/core';
1+
import { EventData, Observable, Page, Color, TabView, TabViewItem, StackLayout, Label, Utils, GridLayout, ItemSpec } from '@nativescript/core';
22

33
class TabViewDemoModel extends Observable {
44
private page: Page;
@@ -35,14 +35,22 @@ class TabViewDemoModel extends Observable {
3535
clearIcons = () => {
3636
if (!this.tabView || !this.tabView.items) return;
3737
this.tabView.items.forEach((item) => {
38-
(item as TabViewItem).iconSource = undefined;
38+
item.iconSource = undefined;
3939
});
4040
};
4141

4242
private setIcons(iconSources: string[]) {
4343
const items = this.tabView.items as TabViewItem[];
44+
let isSystemIcons: boolean;
4445
for (let i = 0; i < items.length; i++) {
45-
items[i].iconSource = iconSources[i % iconSources.length];
46+
const iconSource = iconSources[i % iconSources.length];
47+
items[i].iconSource = iconSource;
48+
if (iconSource.startsWith(Utils.SYSTEM_PREFIX)) {
49+
isSystemIcons = true;
50+
}
51+
}
52+
if (__APPLE__) {
53+
this.tabView.tabTextFontSize = isSystemIcons ? 11 : null;
4654
}
4755
}
4856

@@ -56,9 +64,45 @@ class TabViewDemoModel extends Observable {
5664

5765
private applyItemColor(color: Color) {
5866
(this.tabView.items as TabViewItem[]).forEach((item) => {
59-
(item as TabViewItem).style.color = color;
67+
item.style.color = color;
6068
});
6169
}
70+
71+
// iOS bottom accessory demo (iOS 26+). Safe to call on other platforms; will be ignored.
72+
attachBottomAccessory = () => {
73+
if (!this.tabView) return;
74+
const root = new GridLayout();
75+
76+
// root.backgroundColor = new Color('green');
77+
root.iosOverflowSafeArea = false;
78+
root.padding = 12;
79+
root.width = { unit: '%', value: 100 };
80+
root.height = 56; // ensure visible height
81+
const label = new Label();
82+
// label.backgroundColor = new Color('red');
83+
label.text = 'Bottom Accessory (iOS 26+)';
84+
label.color = new Color('#000');
85+
label.textAlignment = 'center';
86+
GridLayout.setColumn(label, 0);
87+
GridLayout.setRow(label, 0);
88+
root.addChild(label);
89+
this.tabView.iosBottomAccessory = root;
90+
};
91+
92+
clearBottomAccessory = () => {
93+
if (!this.tabView) return;
94+
this.tabView.iosBottomAccessory = null;
95+
};
96+
97+
setMinimizeScrollDown = () => {
98+
if (!this.tabView) return;
99+
this.tabView.iosTabBarMinimizeBehavior = 'onScrollDown';
100+
};
101+
102+
setMinimizeNever = () => {
103+
if (!this.tabView) return;
104+
this.tabView.iosTabBarMinimizeBehavior = 'never';
105+
};
62106
}
63107

64108
const vm = new TabViewDemoModel();

apps/toolbox/src/pages/tabview.xml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,18 @@
1010
<StackLayout padding="20">
1111
<Label class="description" textWrap="true" text="Test TabViewItem iconSource with sys:// (iOS SF Symbols) and font:// (custom/font glyph)." />
1212

13-
<GridLayout columns="*, *" rows="auto, auto" class="m-y-10">
13+
<GridLayout columns="*, *" rows="auto, auto, auto, auto" class="m-y-10">
1414
<Button text="Use sys:// icons" col="0" row="0" tap="{{ useSysIcons }}" class="btn btn-primary" />
1515
<Button text="Use font:// icons" col="1" row="0" tap="{{ useFontIcons }}" class="btn btn-primary" />
1616
<Button text="Clear icons" colSpan="2" row="1" tap="{{ clearIcons }}" class="btn btn-primary" />
17+
18+
<!-- iOS 26+ bottom accessory demo -->
19+
<Button text="Add accessory" col="0" row="2" tap="{{ attachBottomAccessory }}" class="btn btn-primary" />
20+
<Button text="Clear accessory" col="1" row="2" tap="{{ clearBottomAccessory }}" class="btn btn-secondary" />
21+
22+
<!-- iOS 26+ minimize behavior demo -->
23+
<Button text="Minimize: Down" col="0" row="3" tap="{{ setMinimizeScrollDown }}" class="btn btn-primary" />
24+
<Button text="Minimize: Never" col="1" row="3" tap="{{ setMinimizeNever }}" class="btn btn-secondary" />
1725
</GridLayout>
1826

1927
<Label textWrap="true" class="t-12" text="Note: sys:// icons are iOS-only and map to SF Symbols via UIImage.systemImageNamed. On Android, sys:// will not render and is expected to show no icon." />
@@ -22,7 +30,9 @@
2230

2331
<!-- Second tab -->
2432
<TabViewItem title="Second" iconSource="font://&#x42;">
25-
<Label text="Second Tab Content" textAlignment="center" verticalAlignment="center" />
33+
<Frame defaultPage="pages/list-page-sticky">
34+
35+
</Frame>
2636
</TabViewItem>
2737

2838
<!-- Third tab -->

packages/core/platform/screen/index.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export interface ScreenMetrics {
2727
*/
2828
scale: number;
2929

30+
/**
31+
* (iOS only) Gets the native UIScreen instance.
32+
*/
33+
screen: UIScreen;
34+
3035
_updateMetrics(): void;
3136
}
3237

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
* Contains the TabView class, which represents a standard content component with tabs.
33
*/
44

5-
import { View } from '../core/view';
5+
import type { View } from '../core/view';
66
import { ViewBase } from '../core/view-base';
77
import { Property, CssProperty } from '../core/properties';
88
import { EventData } from '../../data/observable';
99
import { Color } from '../../color';
1010
import { CoreTypes } from '../../core-types';
1111
import { Style } from '../styling/style';
12+
import type { TabBarMinimizeType } from './tab-view-common';
1213
/**
1314
* Represents a tab view entry.
1415
*
@@ -119,6 +120,21 @@ export class TabView extends View {
119120
*/
120121
selectedTabTextColor: Color;
121122

123+
/**
124+
* Gets or sets the iOS tab bar minimize behavior (iOS 26+).
125+
*
126+
* @nsProperty
127+
*/
128+
iosTabBarMinimizeBehavior: TabBarMinimizeType;
129+
130+
/**
131+
* iOS 26+: Optional bottom accessory view that appears above the tab bar.
132+
* Provide a NativeScript View instance. On platforms < iOS 26 this is ignored.
133+
*
134+
* @nsProperty
135+
*/
136+
iosBottomAccessory: View;
137+
122138
/**
123139
* Gets or sets the color of the horizontal line drawn below the currently selected tab on Android.
124140
*

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

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Font } from '../styling/font';
33

44
import { IOSHelper, View } from '../core/view';
55
import { ViewBase } from '../core/view-base';
6-
import { TabViewBase, TabViewItemBase, itemsProperty, selectedIndexProperty, tabTextColorProperty, tabTextFontSizeProperty, tabBackgroundColorProperty, selectedTabTextColorProperty, iosIconRenderingModeProperty, traceMissingIcon } from './tab-view-common';
6+
import { TabViewBase, TabViewItemBase, itemsProperty, selectedIndexProperty, tabTextColorProperty, tabTextFontSizeProperty, tabBackgroundColorProperty, selectedTabTextColorProperty, iosIconRenderingModeProperty, traceMissingIcon, iosBottomAccessoryProperty, iosTabBarMinimizeBehaviorProperty } from './tab-view-common';
77
import { Color } from '../../color';
88
import { Trace } from '../../trace';
99
import { fontInternalProperty } from '../styling/style-properties';
@@ -15,7 +15,7 @@ import { Frame } from '../frame';
1515
import { layout } from '../../utils/layout-helper';
1616
import { FONT_PREFIX, isFontIconURI, isSystemURI, SYSTEM_PREFIX } from '../../utils/common';
1717
import { SDK_VERSION } from '../../utils/constants';
18-
import { Device } from '../../platform';
18+
import { Device, Screen } from '../../platform';
1919
export * from './tab-view-common';
2020

2121
@NativeClass
@@ -285,6 +285,7 @@ export class TabView extends TabViewBase {
285285
private _delegate: UITabBarControllerDelegateImpl;
286286
private _moreNavigationControllerDelegate: UINavigationControllerDelegateImpl;
287287
private _iconsCache = {};
288+
private _bottomAccessoryNsView: View;
288289
private _ios: UITabBarControllerImpl;
289290
private _actionBarHiddenByTabView: boolean;
290291

@@ -328,6 +329,11 @@ export class TabView extends TabViewBase {
328329
if (this._ios) {
329330
this._ios.delegate = this._delegate;
330331
}
332+
333+
// Re-apply bottom accessory if set
334+
if (this.iosBottomAccessory) {
335+
this._applyBottomAccessory(this.iosBottomAccessory, false);
336+
}
331337
}
332338

333339
public onUnloaded() {
@@ -338,6 +344,8 @@ export class TabView extends TabViewBase {
338344
this._ios.moreNavigationController.delegate = null;
339345
}
340346
}
347+
// Avoid retaining custom view when unloading
348+
this._applyBottomAccessory(null, false);
341349
super.onUnloaded();
342350
}
343351

@@ -682,11 +690,169 @@ export class TabView extends TabViewBase {
682690
for (let i = 0, length = items.length; i < length; i++) {
683691
const item = items[i];
684692
if (item.iconSource) {
685-
(<TabViewItem>item)._update();
693+
(item as TabViewItem)._update();
686694
}
687695
}
688696
}
689697
}
698+
699+
// iOS 26+: bottom accessory support
700+
[iosBottomAccessoryProperty.getDefault](): View {
701+
return null;
702+
}
703+
[iosBottomAccessoryProperty.setNative](value: View) {
704+
this._applyBottomAccessory(value, false);
705+
}
706+
707+
// iOS 26+: tab bar minimize behavior
708+
[iosTabBarMinimizeBehaviorProperty.getDefault](): 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp' {
709+
return 'automatic';
710+
}
711+
[iosTabBarMinimizeBehaviorProperty.setNative](value: 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp') {
712+
if (SDK_VERSION < 26) {
713+
return;
714+
}
715+
let mapped: UITabBarMinimizeBehavior;
716+
switch (value) {
717+
case 'never':
718+
mapped = UITabBarMinimizeBehavior.Never;
719+
break;
720+
case 'onScrollDown':
721+
mapped = UITabBarMinimizeBehavior.OnScrollDown;
722+
break;
723+
case 'onScrollUp':
724+
mapped = UITabBarMinimizeBehavior.OnScrollUp;
725+
break;
726+
case 'automatic':
727+
default:
728+
mapped = UITabBarMinimizeBehavior.Automatic;
729+
}
730+
this._ios.tabBarMinimizeBehavior = mapped;
731+
}
732+
733+
private _applyBottomAccessory(value: View | null, animated: boolean) {
734+
// Guard for platform availability
735+
if (SDK_VERSION < 26) {
736+
return;
737+
}
738+
739+
const setAccessory = (accessory: UITabAccessory | null) => {
740+
try {
741+
this._ios.setBottomAccessoryAnimated(accessory, animated);
742+
} catch (err) {
743+
// Fallback to property if needed
744+
this._ios.bottomAccessory = accessory;
745+
}
746+
};
747+
748+
// Clear previous
749+
if (!value) {
750+
// Clear on controller
751+
setAccessory(null);
752+
// Tear down previously managed NS view
753+
if (this._bottomAccessoryNsView) {
754+
// Do not remove from a parent; we didn't add it to the NS view tree.
755+
try {
756+
this._bottomAccessoryNsView._tearDownUI(true);
757+
} catch (_) {}
758+
this._bottomAccessoryNsView = null;
759+
}
760+
return;
761+
}
762+
763+
// Ensure the NativeScript view has a native view
764+
const nsView = value;
765+
if (!nsView.nativeViewProtected) {
766+
// mirror dialogs approach to setup UI for a detached view
767+
nsView._setupUI({} as any);
768+
}
769+
// Just mark it loaded, if not already, so measurement & styling are applied.
770+
if (!nsView.isLoaded) {
771+
// In detached scenarios we simply callLoaded after setup.
772+
nsView.callLoaded();
773+
}
774+
const contentView = nsView.nativeViewProtected as UIView;
775+
if (!contentView) {
776+
return;
777+
}
778+
779+
// Use frame-based sizing; keep autoresizing mask-based behavior enabled (no Auto Layout constraints added here).
780+
contentView.translatesAutoresizingMaskIntoConstraints = true;
781+
// Measure desired height with the tab bar width
782+
let tabBarWidth = this._ios?.tabBar?.frame?.size?.width || Screen.mainScreen.screen.bounds.size.width;
783+
// Account for safe area insets so accessory doesn't extend visually past rounded corners
784+
if (this._ios?.tabBar?.safeAreaInsets) {
785+
const insets = this._ios.tabBar.safeAreaInsets;
786+
// Reduce usable width by left+right safe area (typically 0, but defensive)
787+
const horizontalInsets = insets.left + insets.right;
788+
if (horizontalInsets > 0 && horizontalInsets < tabBarWidth) {
789+
tabBarWidth -= horizontalInsets;
790+
}
791+
}
792+
const tabBarWidthPx = layout.toDevicePixels(tabBarWidth);
793+
// Prefer flooring to avoid overshooting container by +1px due to FP rounding
794+
const tabBarWidthPxRounded = Math.floor(tabBarWidthPx);
795+
let measuredHeight = 0;
796+
// Measure using device-pixel width; flooring prevents +1px expansion
797+
const widthSpec = layout.makeMeasureSpec(tabBarWidthPxRounded, layout.EXACTLY);
798+
const heightSpec = layout.makeMeasureSpec(0, layout.UNSPECIFIED);
799+
nsView.measure(widthSpec, heightSpec);
800+
measuredHeight = layout.toDeviceIndependentPixels(nsView.getMeasuredHeight());
801+
802+
// Use a sensible minimum height (44pt button row) if measurement is tiny
803+
const minHeight = 44;
804+
const finalHeight = Math.max(minHeight, measuredHeight || 0);
805+
// Rely on container height constraint (below) and frame-based layout inside container.
806+
const container = NSTabAccessoryContainer.initWithOwner(new WeakRef(nsView));
807+
container.translatesAutoresizingMaskIntoConstraints = true;
808+
container.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
809+
// Mask any subpixel spill just in case
810+
container.clipsToBounds = true;
811+
container.addSubview(contentView);
812+
// Constrain the container height (not the content) so UIKit has a concrete size.
813+
const containerHeight = container.heightAnchor.constraintEqualToConstant(finalHeight);
814+
containerHeight.priority = 999;
815+
NSLayoutConstraint.activateConstraints([containerHeight]);
816+
817+
const accessory = UITabAccessory.alloc().initWithContentView(container);
818+
setAccessory(accessory);
819+
// Keep references for later teardown
820+
this._bottomAccessoryNsView = nsView;
821+
}
822+
}
823+
824+
@NativeClass
825+
class NSTabAccessoryContainer extends UIView {
826+
_owner: WeakRef<View>;
827+
static initWithOwner(owner: WeakRef<View>): NSTabAccessoryContainer {
828+
const v = NSTabAccessoryContainer.new() as NSTabAccessoryContainer;
829+
v._owner = owner;
830+
return v;
831+
}
832+
833+
override layoutSubviews() {
834+
super.layoutSubviews();
835+
const owner = this._owner?.deref();
836+
if (!owner?.nativeViewProtected) return;
837+
owner.nativeViewProtected.frame = this.bounds;
838+
const w = this.bounds.size.width;
839+
const h = this.bounds.size.height;
840+
try {
841+
// Convert to device pixels and floor to avoid +1px overshoot from rounding
842+
let wp = Math.floor(layout.toDevicePixels(w));
843+
const hp = Math.floor(layout.toDevicePixels(h));
844+
// Clamp width to <= container pixel width (defensive)
845+
const containerPxWidth = Math.floor(layout.toDevicePixels(this.bounds.size.width));
846+
if (wp > containerPxWidth) {
847+
wp = containerPxWidth;
848+
}
849+
// Ensure NS view and its children are measured with the final container width
850+
const widthSpec = layout.makeMeasureSpec(wp, layout.EXACTLY);
851+
const heightSpec = layout.makeMeasureSpec(hp, layout.EXACTLY);
852+
owner.measure(widthSpec, heightSpec);
853+
owner.layout(0, 0, wp, hp);
854+
} catch (_) {}
855+
}
690856
}
691857

692858
interface TabStates {

0 commit comments

Comments
 (0)