Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions apps/toolbox/src/pages/tabview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EventData, Observable, Page, Color, TabView, TabViewItem } from '@nativescript/core';
import { EventData, Observable, Page, Color, TabView, TabViewItem, StackLayout, Label, Utils, GridLayout, ItemSpec } from '@nativescript/core';

class TabViewDemoModel extends Observable {
private page: Page;
Expand Down Expand Up @@ -35,14 +35,22 @@ class TabViewDemoModel extends Observable {
clearIcons = () => {
if (!this.tabView || !this.tabView.items) return;
this.tabView.items.forEach((item) => {
(item as TabViewItem).iconSource = undefined;
item.iconSource = undefined;
});
};

private setIcons(iconSources: string[]) {
const items = this.tabView.items as TabViewItem[];
let isSystemIcons: boolean;
for (let i = 0; i < items.length; i++) {
items[i].iconSource = iconSources[i % iconSources.length];
const iconSource = iconSources[i % iconSources.length];
items[i].iconSource = iconSource;
if (iconSource.startsWith(Utils.SYSTEM_PREFIX)) {
isSystemIcons = true;
}
}
if (__APPLE__) {
this.tabView.tabTextFontSize = isSystemIcons ? 11 : null;
}
}

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

private applyItemColor(color: Color) {
(this.tabView.items as TabViewItem[]).forEach((item) => {
(item as TabViewItem).style.color = color;
item.style.color = color;
});
}

// iOS bottom accessory demo (iOS 26+). Safe to call on other platforms; will be ignored.
attachBottomAccessory = () => {
if (!this.tabView) return;
const root = new GridLayout();

// root.backgroundColor = new Color('green');
root.iosOverflowSafeArea = false;
root.padding = 12;
root.width = { unit: '%', value: 100 };
root.height = 56; // ensure visible height
const label = new Label();
// label.backgroundColor = new Color('red');
label.text = 'Bottom Accessory (iOS 26+)';
label.color = new Color('#000');
label.textAlignment = 'center';
GridLayout.setColumn(label, 0);
GridLayout.setRow(label, 0);
root.addChild(label);
this.tabView.iosBottomAccessory = root;
};

clearBottomAccessory = () => {
if (!this.tabView) return;
this.tabView.iosBottomAccessory = null;
};

setMinimizeScrollDown = () => {
if (!this.tabView) return;
this.tabView.iosTabBarMinimizeBehavior = 'onScrollDown';
};

setMinimizeNever = () => {
if (!this.tabView) return;
this.tabView.iosTabBarMinimizeBehavior = 'never';
};
}

const vm = new TabViewDemoModel();
Expand Down
14 changes: 12 additions & 2 deletions apps/toolbox/src/pages/tabview.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@
<StackLayout padding="20">
<Label class="description" textWrap="true" text="Test TabViewItem iconSource with sys:// (iOS SF Symbols) and font:// (custom/font glyph)." />

<GridLayout columns="*, *" rows="auto, auto" class="m-y-10">
<GridLayout columns="*, *" rows="auto, auto, auto, auto" class="m-y-10">
<Button text="Use sys:// icons" col="0" row="0" tap="{{ useSysIcons }}" class="btn btn-primary" />
<Button text="Use font:// icons" col="1" row="0" tap="{{ useFontIcons }}" class="btn btn-primary" />
<Button text="Clear icons" colSpan="2" row="1" tap="{{ clearIcons }}" class="btn btn-primary" />

<!-- iOS 26+ bottom accessory demo -->
<Button text="Add accessory" col="0" row="2" tap="{{ attachBottomAccessory }}" class="btn btn-primary" />
<Button text="Clear accessory" col="1" row="2" tap="{{ clearBottomAccessory }}" class="btn btn-secondary" />

<!-- iOS 26+ minimize behavior demo -->
<Button text="Minimize: Down" col="0" row="3" tap="{{ setMinimizeScrollDown }}" class="btn btn-primary" />
<Button text="Minimize: Never" col="1" row="3" tap="{{ setMinimizeNever }}" class="btn btn-secondary" />
</GridLayout>

<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." />
Expand All @@ -22,7 +30,9 @@

<!-- Second tab -->
<TabViewItem title="Second" iconSource="font://&#x42;">
<Label text="Second Tab Content" textAlignment="center" verticalAlignment="center" />
<Frame defaultPage="pages/list-page-sticky">

</Frame>
</TabViewItem>

<!-- Third tab -->
Expand Down
5 changes: 5 additions & 0 deletions packages/core/platform/screen/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface ScreenMetrics {
*/
scale: number;

/**
* (iOS only) Gets the native UIScreen instance.
*/
screen: UIScreen;

_updateMetrics(): void;
}

Expand Down
18 changes: 17 additions & 1 deletion packages/core/ui/tab-view/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
* Contains the TabView class, which represents a standard content component with tabs.
*/

import { View } from '../core/view';
import type { View } from '../core/view';
import { ViewBase } from '../core/view-base';
import { Property, CssProperty } from '../core/properties';
import { EventData } from '../../data/observable';
import { Color } from '../../color';
import { CoreTypes } from '../../core-types';
import { Style } from '../styling/style';
import type { TabBarMinimizeType } from './tab-view-common';
/**
* Represents a tab view entry.
*
Expand Down Expand Up @@ -119,6 +120,21 @@ export class TabView extends View {
*/
selectedTabTextColor: Color;

/**
* Gets or sets the iOS tab bar minimize behavior (iOS 26+).
*
* @nsProperty
*/
iosTabBarMinimizeBehavior: TabBarMinimizeType;

/**
* iOS 26+: Optional bottom accessory view that appears above the tab bar.
* Provide a NativeScript View instance. On platforms < iOS 26 this is ignored.
*
* @nsProperty
*/
iosBottomAccessory: View;

/**
* Gets or sets the color of the horizontal line drawn below the currently selected tab on Android.
*
Expand Down
172 changes: 169 additions & 3 deletions packages/core/ui/tab-view/index.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Font } from '../styling/font';

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

@NativeClass
Expand Down Expand Up @@ -285,6 +285,7 @@ export class TabView extends TabViewBase {
private _delegate: UITabBarControllerDelegateImpl;
private _moreNavigationControllerDelegate: UINavigationControllerDelegateImpl;
private _iconsCache = {};
private _bottomAccessoryNsView: View;
private _ios: UITabBarControllerImpl;
private _actionBarHiddenByTabView: boolean;

Expand Down Expand Up @@ -328,6 +329,11 @@ export class TabView extends TabViewBase {
if (this._ios) {
this._ios.delegate = this._delegate;
}

// Re-apply bottom accessory if set
if (this.iosBottomAccessory) {
this._applyBottomAccessory(this.iosBottomAccessory, false);
}
}

public onUnloaded() {
Expand All @@ -338,6 +344,8 @@ export class TabView extends TabViewBase {
this._ios.moreNavigationController.delegate = null;
}
}
// Avoid retaining custom view when unloading
this._applyBottomAccessory(null, false);
super.onUnloaded();
}

Expand Down Expand Up @@ -682,11 +690,169 @@ export class TabView extends TabViewBase {
for (let i = 0, length = items.length; i < length; i++) {
const item = items[i];
if (item.iconSource) {
(<TabViewItem>item)._update();
(item as TabViewItem)._update();
}
}
}
}

// iOS 26+: bottom accessory support
[iosBottomAccessoryProperty.getDefault](): View {
return null;
}
[iosBottomAccessoryProperty.setNative](value: View) {
this._applyBottomAccessory(value, false);
}

// iOS 26+: tab bar minimize behavior
[iosTabBarMinimizeBehaviorProperty.getDefault](): 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp' {
return 'automatic';
}
[iosTabBarMinimizeBehaviorProperty.setNative](value: 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp') {
if (SDK_VERSION < 26) {
return;
}
let mapped: UITabBarMinimizeBehavior;
switch (value) {
case 'never':
mapped = UITabBarMinimizeBehavior.Never;
break;
case 'onScrollDown':
mapped = UITabBarMinimizeBehavior.OnScrollDown;
break;
case 'onScrollUp':
mapped = UITabBarMinimizeBehavior.OnScrollUp;
break;
case 'automatic':
default:
mapped = UITabBarMinimizeBehavior.Automatic;
}
this._ios.tabBarMinimizeBehavior = mapped;
}

private _applyBottomAccessory(value: View | null, animated: boolean) {
// Guard for platform availability
if (SDK_VERSION < 26) {
return;
}

const setAccessory = (accessory: UITabAccessory | null) => {
try {
this._ios.setBottomAccessoryAnimated(accessory, animated);
} catch (err) {
// Fallback to property if needed
this._ios.bottomAccessory = accessory;
}
};

// Clear previous
if (!value) {
// Clear on controller
setAccessory(null);
// Tear down previously managed NS view
if (this._bottomAccessoryNsView) {
// Do not remove from a parent; we didn't add it to the NS view tree.
try {
this._bottomAccessoryNsView._tearDownUI(true);
} catch (_) {}
this._bottomAccessoryNsView = null;
}
return;
}

// Ensure the NativeScript view has a native view
const nsView = value;
if (!nsView.nativeViewProtected) {
// mirror dialogs approach to setup UI for a detached view
nsView._setupUI({} as any);
}
// Just mark it loaded, if not already, so measurement & styling are applied.
if (!nsView.isLoaded) {
// In detached scenarios we simply callLoaded after setup.
nsView.callLoaded();
}
const contentView = nsView.nativeViewProtected as UIView;
if (!contentView) {
return;
}

// Use frame-based sizing; keep autoresizing mask-based behavior enabled (no Auto Layout constraints added here).
contentView.translatesAutoresizingMaskIntoConstraints = true;
// Measure desired height with the tab bar width
let tabBarWidth = this._ios?.tabBar?.frame?.size?.width || Screen.mainScreen.screen.bounds.size.width;
// Account for safe area insets so accessory doesn't extend visually past rounded corners
if (this._ios?.tabBar?.safeAreaInsets) {
const insets = this._ios.tabBar.safeAreaInsets;
// Reduce usable width by left+right safe area (typically 0, but defensive)
const horizontalInsets = insets.left + insets.right;
if (horizontalInsets > 0 && horizontalInsets < tabBarWidth) {
tabBarWidth -= horizontalInsets;
}
}
const tabBarWidthPx = layout.toDevicePixels(tabBarWidth);
// Prefer flooring to avoid overshooting container by +1px due to FP rounding
const tabBarWidthPxRounded = Math.floor(tabBarWidthPx);
let measuredHeight = 0;
// Measure using device-pixel width; flooring prevents +1px expansion
const widthSpec = layout.makeMeasureSpec(tabBarWidthPxRounded, layout.EXACTLY);
const heightSpec = layout.makeMeasureSpec(0, layout.UNSPECIFIED);
nsView.measure(widthSpec, heightSpec);
measuredHeight = layout.toDeviceIndependentPixels(nsView.getMeasuredHeight());

// Use a sensible minimum height (44pt button row) if measurement is tiny
const minHeight = 44;
const finalHeight = Math.max(minHeight, measuredHeight || 0);
// Rely on container height constraint (below) and frame-based layout inside container.
const container = NSTabAccessoryContainer.initWithOwner(new WeakRef(nsView));
container.translatesAutoresizingMaskIntoConstraints = true;
container.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
// Mask any subpixel spill just in case
container.clipsToBounds = true;
container.addSubview(contentView);
// Constrain the container height (not the content) so UIKit has a concrete size.
const containerHeight = container.heightAnchor.constraintEqualToConstant(finalHeight);
containerHeight.priority = 999;
NSLayoutConstraint.activateConstraints([containerHeight]);

const accessory = UITabAccessory.alloc().initWithContentView(container);
setAccessory(accessory);
// Keep references for later teardown
this._bottomAccessoryNsView = nsView;
}
}

@NativeClass
class NSTabAccessoryContainer extends UIView {
_owner: WeakRef<View>;
static initWithOwner(owner: WeakRef<View>): NSTabAccessoryContainer {
const v = NSTabAccessoryContainer.new() as NSTabAccessoryContainer;
v._owner = owner;
return v;
}

override layoutSubviews() {
super.layoutSubviews();
const owner = this._owner?.deref();
if (!owner?.nativeViewProtected) return;
owner.nativeViewProtected.frame = this.bounds;
const w = this.bounds.size.width;
const h = this.bounds.size.height;
try {
// Convert to device pixels and floor to avoid +1px overshoot from rounding
let wp = Math.floor(layout.toDevicePixels(w));
const hp = Math.floor(layout.toDevicePixels(h));
// Clamp width to <= container pixel width (defensive)
const containerPxWidth = Math.floor(layout.toDevicePixels(this.bounds.size.width));
if (wp > containerPxWidth) {
wp = containerPxWidth;
}
// Ensure NS view and its children are measured with the final container width
const widthSpec = layout.makeMeasureSpec(wp, layout.EXACTLY);
const heightSpec = layout.makeMeasureSpec(hp, layout.EXACTLY);
owner.measure(widthSpec, heightSpec);
owner.layout(0, 0, wp, hp);
} catch (_) {}
}
}

interface TabStates {
Expand Down
Loading