feat(ios): SplitView

This commit is contained in:
Nathan Walker
2025-10-22 20:56:45 -07:00
parent 5f14845b27
commit c5cef21431
14 changed files with 639 additions and 2 deletions

View File

@@ -1,3 +1,6 @@
import { Application } from '@nativescript/core';
import { Application, SplitView } from '@nativescript/core';
Application.run({ moduleName: 'app-root' });
// Application.run({ moduleName: 'app-root' });
SplitView.SplitStyle = 'triple';
Application.run({ moduleName: 'split-view/split-view-root' });

View File

@@ -0,0 +1,23 @@
import { Observable, EventData, Page, SplitView, ItemEventData } from '@nativescript/core';
import { getItemCallbacks } from './split-view-root';
let page: Page;
export function navigatingTo(args: EventData) {
page = <Page>args.object;
page.bindingContext = new SplitViewPrimaryModel();
}
export class SplitViewPrimaryModel extends Observable {
items: string[] = [];
constructor() {
super();
this.items = Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`);
}
onItemTap(args: ItemEventData) {
console.log('args.index', args.index);
SplitView.getInstance()?.showSecondary();
getItemCallbacks().forEach((callback) => callback(this.items[args.index]));
}
}

View File

@@ -0,0 +1,16 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
<ActionBar title="Primary View" class="action-bar" iosLargeTitle="true"></ActionBar>
<!-- Primary column (master) -->
<GridLayout rows="auto,*">
<!-- <Label text="Primary" marginBottom="12" marginLeft="8" fontSize="22" fontWeight="bold" /> -->
<ListView row="1" items="{{ items }}" itemTap="{{ onItemTap }}" backgroundColor="white">
<ListView.itemTemplate>
<GridLayout padding="12">
<Label text="{{ $value }}" fontSize="18" />
</GridLayout>
</ListView.itemTemplate>
</ListView>
</GridLayout>
</Page>

View File

@@ -0,0 +1,17 @@
import { Observable, EventData, Page } from '@nativescript/core';
let page: Page;
export function navigatingTo(args: EventData) {
page = <Page>args.object;
page.bindingContext = new SplitViewModel();
}
export class SplitViewModel extends Observable {}
let itemCallbacks: Array<(item: any) => void> = [];
export function setItemCallbacks(changeItem: Array<(item: any) => void>) {
itemCallbacks.push(...changeItem);
}
export function getItemCallbacks() {
return itemCallbacks;
}

View File

@@ -0,0 +1,14 @@
<SplitView xmlns="http://schemas.nativescript.org/tns.xsd" displayMode="twoBesideSecondary" splitBehavior="tile" preferredPrimaryColumnWidthFraction="0.25" preferredSupplementaryColumnWidthFraction="0.33">
<Frame splitRole="primary" defaultPage="split-view/split-view-primary">
</Frame>
<Frame splitRole="secondary" defaultPage="split-view/split-view-secondary">
</Frame>
<Frame splitRole="supplementary" defaultPage="split-view/split-view-supplement">
</Frame>
</SplitView>

View File

@@ -0,0 +1,24 @@
import { Observable, EventData, Page, SplitView } from '@nativescript/core';
import { setItemCallbacks } from './split-view-root';
let page: Page;
export function navigatingTo(args: EventData) {
page = <Page>args.object;
page.bindingContext = new SplitViewSecondaryModel();
}
export class SplitViewSecondaryModel extends Observable {
selectedItem = `Select an item from Primary.`;
constructor() {
super();
setItemCallbacks([this.changeItem.bind(this)]);
}
toggle() {
SplitView.getInstance()?.showPrimary();
}
changeItem(item: any) {
this.selectedItem = item;
this.notifyPropertyChange('selectedItem', item);
}
}

View File

@@ -0,0 +1,10 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
<ActionBar title="Secondary View" class="action-bar">
</ActionBar>
<!-- Secondary column (detail) -->
<StackLayout class="p-16">
<Label text="Secondary" marginBottom="12" fontSize="22" fontWeight="bold" />
<Label text="{{ selectedItem }}" textWrap="true" tap="{{toggle}}" />
</StackLayout>
</Page>

View File

@@ -0,0 +1,24 @@
import { Observable, EventData, Page, SplitView } from '@nativescript/core';
import { setItemCallbacks } from './split-view-root';
let page: Page;
export function navigatingTo(args: EventData) {
page = <Page>args.object;
page.bindingContext = new SplitViewSupplementaryModel();
}
export class SplitViewSupplementaryModel extends Observable {
selectedItem = `Supplementary - Select an item.`;
constructor() {
super();
setItemCallbacks([this.changeItem.bind(this)]);
}
toggle() {
SplitView.getInstance()?.showPrimary();
}
changeItem(item: any) {
this.selectedItem = item;
this.notifyPropertyChange('selectedItem', item);
}
}

View File

@@ -0,0 +1,10 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
<ActionBar title="Supplementary View" class="action-bar">
</ActionBar>
<!-- Supplementary column (detail) -->
<StackLayout class="p-16">
<Label text="Supplementary" marginBottom="12" fontSize="22" fontWeight="bold" />
<Label text="{{ selectedItem }}" textWrap="true" tap="{{toggle}}" />
</StackLayout>
</Page>

View File

@@ -71,6 +71,7 @@ export { SegmentedBar, SegmentedBarItem } from './segmented-bar';
export type { SelectedIndexChangedEventData } from './segmented-bar';
export { Slider } from './slider';
export type { AccessibilityDecrementEventData, AccessibilityIncrementEventData } from './slider';
export { SplitView } from './split-view';
export { addTaggedAdditionalCSS, removeTaggedAdditionalCSS, resolveFileNameFromUrl } from './styling/style-scope';
export { Background } from './styling/background';

View File

@@ -0,0 +1,7 @@
import { SplitViewBase } from './split-view-common';
export { SplitBehavior, SplitRole, SplitStyle, SplitDisplayMode } from './split-view-common';
export class SplitView extends SplitViewBase {
// Android does not have a native SplitViewController equivalent.
}

11
packages/core/ui/split-view/index.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import { SplitViewBase } from './split-view-common';
export type { SplitBehavior, SplitRole, SplitStyle, SplitDisplayMode } from './split-view-common';
/**
* iOS UISplitViewController-backed container.
* On Android, acts as a simple container.
*
* @nsView SplitView
*/
export class SplitView extends SplitViewBase {}

View File

@@ -0,0 +1,344 @@
import { SplitViewBase, SplitRole, displayModeProperty, splitBehaviorProperty, preferredPrimaryColumnWidthFractionProperty, preferredSupplementaryColumnWidthFractionProperty } from './split-view-common';
import { View } from '../core/view';
import { layout } from '../../utils';
@NativeClass
class UISplitViewControllerDelegateImpl extends NSObject implements UISplitViewControllerDelegate {
public static ObjCProtocols = [UISplitViewControllerDelegate];
private _owner: WeakRef<SplitView>;
public static initWithOwner(owner: WeakRef<SplitView>): UISplitViewControllerDelegateImpl {
const d = <UISplitViewControllerDelegateImpl>UISplitViewControllerDelegateImpl.new();
d._owner = owner;
return d;
}
splitViewControllerCollapseSecondaryViewControllerOntoPrimaryViewController(splitViewController: UISplitViewController, secondaryViewController: UIViewController, primaryViewController: UIViewController): boolean {
const owner = this._owner.deref();
if (owner) {
// Notify the owner about the collapse action
owner.onSecondaryViewCollapsed(secondaryViewController, primaryViewController);
}
return true;
}
splitViewControllerDidCollapse(svc: UISplitViewController): void {
// Can be used to notify owner if needed
}
splitViewControllerDidExpand(svc: UISplitViewController): void {
// Can be used to notify owner if needed
}
splitViewControllerDidHideColumn(svc: UISplitViewController, column: UISplitViewControllerColumn): void {
// Can be used to notify owner if needed
}
splitViewControllerDidShowColumn(svc: UISplitViewController, column: UISplitViewControllerColumn): void {
// Can be used to notify owner if needed
}
splitViewControllerDisplayModeForExpandingToProposedDisplayMode(svc: UISplitViewController, proposedDisplayMode: UISplitViewControllerDisplayMode): UISplitViewControllerDisplayMode {
return UISplitViewControllerDisplayMode.TwoBesideSecondary;
}
splitViewControllerTopColumnForCollapsingToProposedTopColumn(svc: UISplitViewController, proposedTopColumn: UISplitViewControllerColumn): UISplitViewControllerColumn {
return UISplitViewControllerColumn.Secondary;
}
toggleSidebar(sender: UIBarButtonItem): void {
const owner = this._owner.deref();
if (owner) {
owner.showPrimary();
}
}
static ObjCExposedMethods = {
'toggleSidebar:': { returns: interop.types.void, params: [UIBarButtonItem] },
};
}
export class SplitView extends SplitViewBase {
static instance: SplitView;
static getInstance(): SplitViewBase | null {
return SplitView.instance;
}
public viewController: UISplitViewController;
private _delegate: UISplitViewControllerDelegateImpl;
// Keep role -> controller
private _controllers = new Map<SplitRole, UIViewController | UINavigationController>();
private _children = new Map<SplitRole, View>();
constructor() {
super();
// Prefer modern initializer when available; otherwise default
console.log('this._getSplitStyle(): ', this._getSplitStyle());
this.viewController = UISplitViewController.alloc().initWithStyle(this._getSplitStyle());
}
createNativeView() {
SplitView.instance = this;
this._delegate = UISplitViewControllerDelegateImpl.initWithOwner(new WeakRef(this));
this.viewController.delegate = this._delegate;
this.viewController.presentsWithGesture = true;
// Apply initial preferences
this._applyPreferences();
return this.viewController.view;
}
disposeNativeView(): void {
super.disposeNativeView();
this._controllers.clear();
this._children.clear();
this.viewController = null;
this._delegate = null;
}
private _getSplitStyle() {
switch (SplitView.SplitStyle) {
case 'triple':
return UISplitViewControllerStyle.TripleColumn;
default:
// default to double always
return UISplitViewControllerStyle.DoubleColumn;
}
}
// Controller-backed container: intercept native tree operations
public _addViewToNativeVisualTree(child: SplitViewBase, atIndex: number): boolean {
const role = this._resolveRoleForChild(child, atIndex);
const controller = this._ensureControllerForChild(child);
console.log('set controllers for role: ' + role);
console.log('controller: ', controller);
this._children.set(role, child);
this._controllers.set(role, controller);
this._syncControllers();
return true;
}
public _removeViewFromNativeVisualTree(child: View): void {
const role = this._findRoleByChild(child);
if (role) {
this._children.delete(role);
this._controllers.delete(role);
this._syncControllers();
}
super._removeViewFromNativeVisualTree(child);
}
public onMeasure(widthMeasureSpec: number, heightMeasureSpec: number): void {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
const width = layout.getMeasureSpecSize(widthMeasureSpec);
const widthMode = layout.getMeasureSpecMode(widthMeasureSpec);
const height = layout.getMeasureSpecSize(heightMeasureSpec);
const heightMode = layout.getMeasureSpecMode(heightMeasureSpec);
const horizontal = this.effectivePaddingLeft + this.effectivePaddingRight + this.effectiveBorderLeftWidth + this.effectiveBorderRightWidth;
const vertical = this.effectivePaddingTop + this.effectivePaddingBottom + this.effectiveBorderTopWidth + this.effectiveBorderBottomWidth;
const measuredWidth = Math.max(widthMode === layout.UNSPECIFIED ? 0 : width, this.effectiveMinWidth) + (widthMode === layout.UNSPECIFIED ? horizontal : 0);
const measuredHeight = Math.max(heightMode === layout.UNSPECIFIED ? 0 : height, this.effectiveMinHeight) + (heightMode === layout.UNSPECIFIED ? vertical : 0);
const widthAndState = View.resolveSizeAndState(measuredWidth, width, widthMode, 0);
const heightAndState = View.resolveSizeAndState(measuredHeight, height, heightMode, 0);
this.setMeasuredDimension(widthAndState, heightAndState);
}
public onRoleChanged(view: View, oldValue: SplitRole, newValue: SplitRole) {
// Move child mapping to new role and resync
const oldRole = this._findRoleByChild(view);
if (oldRole) {
const controller = this._controllers.get(oldRole);
this._controllers.delete(oldRole);
this._children.delete(oldRole);
if (controller) {
this._controllers.set(newValue, controller);
}
this._children.set(newValue, view);
this._syncControllers();
}
}
onSecondaryViewCollapsed(secondaryViewController: UIViewController, primaryViewController: UIViewController): void {
// Default implementation: do nothing.
// Subclasses may override to customize behavior when secondary is collapsed onto primary.
}
showPrimary(): void {
if (!this.viewController) return;
this.viewController.showColumn(UISplitViewControllerColumn.Primary);
}
hidePrimary(): void {
if (!this.viewController) return;
this.viewController.hideColumn(UISplitViewControllerColumn.Primary);
}
showSecondary(): void {
if (!this.viewController) return;
this.viewController.showColumn(UISplitViewControllerColumn.Secondary);
}
hideSecondary(): void {
if (!this.viewController) return;
this.viewController.hideColumn(UISplitViewControllerColumn.Secondary);
}
showSupplementary(): void {
if (!this.viewController) return;
this.viewController.showColumn(UISplitViewControllerColumn.Supplementary);
}
private _resolveRoleForChild(child: SplitViewBase, atIndex: number): SplitRole {
const explicit = SplitViewBase.getRole(child);
if (explicit) {
return explicit;
}
// Fallback by index if no explicit role set
return this._roleByIndex(atIndex) as SplitRole;
}
private _findRoleByChild(child: View): SplitRole | null {
for (const [role, c] of this._children.entries()) {
if (c === child) {
return role;
}
}
return null;
}
private _ensureControllerForChild(child: View): UIViewController {
// If child is controller-backed (Page/Frame/etc.), reuse its controller
const vc = (child.ios instanceof UIViewController ? (child.ios as any) : (child as any).viewController) as UIViewController | null;
if (vc) {
return vc;
}
// Fallback: basic wrapper (not expected in current usage where children are Frames/Pages)
const wrapper = UIViewController.new();
if (!wrapper.view) {
wrapper.view = UIView.new();
}
if (child.nativeViewProtected) {
wrapper.view.addSubview(child.nativeViewProtected);
}
return wrapper;
}
private _syncControllers(): void {
if (!this.viewController) {
return;
}
// Prefer modern API if present; otherwise fall back to setting viewControllers array
const primary = this._controllers.get('primary');
const secondary = this._controllers.get('secondary');
const supplementary = this._controllers.get('supplementary');
if (primary) {
this.viewController.setViewControllerForColumn(primary, UISplitViewControllerColumn.Primary);
}
if (secondary) {
this.viewController.setViewControllerForColumn(secondary, UISplitViewControllerColumn.Secondary);
}
if (supplementary) {
this.viewController.setViewControllerForColumn(supplementary, UISplitViewControllerColumn.Supplementary);
}
this._applyPreferences();
}
private _applyPreferences(): void {
if (!this.viewController) {
return;
}
// displayMode
let preferredDisplayMode = UISplitViewControllerDisplayMode.Automatic;
switch (this.displayMode) {
case 'secondaryOnly':
preferredDisplayMode = UISplitViewControllerDisplayMode.SecondaryOnly;
break;
case 'oneBesideSecondary':
preferredDisplayMode = UISplitViewControllerDisplayMode.OneBesideSecondary;
break;
case 'oneOverSecondary':
preferredDisplayMode = UISplitViewControllerDisplayMode.OneOverSecondary;
break;
case 'twoBesideSecondary':
preferredDisplayMode = UISplitViewControllerDisplayMode.TwoBesideSecondary;
break;
case 'twoOverSecondary':
preferredDisplayMode = UISplitViewControllerDisplayMode.TwoOverSecondary;
break;
case 'twoDisplaceSecondary':
preferredDisplayMode = UISplitViewControllerDisplayMode.TwoDisplaceSecondary;
break;
}
this.viewController.preferredDisplayMode = preferredDisplayMode;
// splitBehavior (iOS 14+)
const sb = this.splitBehavior;
let preferredSplitBehavior = UISplitViewControllerSplitBehavior.Automatic;
switch (sb) {
case 'tile':
preferredSplitBehavior = UISplitViewControllerSplitBehavior.Tile;
break;
case 'overlay':
preferredSplitBehavior = UISplitViewControllerSplitBehavior.Overlay ?? UISplitViewControllerSplitBehavior.Automatic;
break;
case 'displace':
preferredSplitBehavior = UISplitViewControllerSplitBehavior.Displace ?? UISplitViewControllerSplitBehavior.Automatic;
break;
}
this.viewController.preferredSplitBehavior = preferredSplitBehavior;
const primary = this._controllers.get('primary');
const secondary = this._controllers.get('secondary');
const supplementary = this._controllers.get('supplementary');
if (secondary instanceof UINavigationController && secondary.navigationItem) {
// TODO: add properties to customize this
secondary.navigationItem.leftBarButtonItem = this.viewController.displayModeButtonItem;
// Optional: slightly larger symbol weight/size
// const cfg = UIImageSymbolConfiguration.configurationWithPointSizeWeightScale(18, UIImageSymbolWeight.Regular, UIImageSymbolScale.Medium);
// const image = UIImage.systemImageNamedWithConfiguration('sidebar.left', cfg);
// const item = UIBarButtonItem.alloc().initWithImageStyleTargetAction(image, UIBarButtonItemStyle.Plain, this._delegate, 'toggleSidebar:');
// secondary.navigationItem.leftBarButtonItem = item;
secondary.navigationItem.leftItemsSupplementBackButton = true;
}
if (supplementary) {
this.showSupplementary();
}
// Width fractions
if (typeof this.preferredPrimaryColumnWidthFraction === 'number' && !isNaN(this.preferredPrimaryColumnWidthFraction)) {
this.viewController.preferredPrimaryColumnWidthFraction = this.preferredPrimaryColumnWidthFraction;
}
if (SplitView.SplitStyle === 'triple') {
// supplementary only applies in triple style
if (typeof this.preferredSupplementaryColumnWidthFraction === 'number' && !isNaN(this.preferredSupplementaryColumnWidthFraction)) {
this.viewController.preferredSupplementaryColumnWidthFraction = this.preferredSupplementaryColumnWidthFraction;
}
}
}
[displayModeProperty.setNative](value: string) {
this._applyPreferences();
}
[splitBehaviorProperty.setNative](value: string) {
this._applyPreferences();
}
[preferredPrimaryColumnWidthFractionProperty.setNative](value: number) {
this._applyPreferences();
}
[preferredSupplementaryColumnWidthFractionProperty.setNative](value: number) {
this._applyPreferences();
}
}

View File

@@ -0,0 +1,133 @@
import { LayoutBase } from '../layouts/layout-base';
import { View, CSSType } from '../core/view';
import { Property, makeParser, makeValidator } from '../core/properties';
export type SplitRole = 'primary' | 'secondary' | 'supplementary';
const splitRoleConverter = makeParser<SplitRole>(makeValidator<SplitRole>('primary', 'secondary', 'supplementary'));
export type SplitStyle = 'automatic' | 'double' | 'triple';
export type SplitDisplayMode = 'automatic' | 'secondaryOnly' | 'oneBesideSecondary' | 'oneOverSecondary' | 'twoBesideSecondary' | 'twoOverSecondary' | 'twoDisplaceSecondary';
const splitDisplayModeConverter = makeParser<SplitDisplayMode>(makeValidator<SplitDisplayMode>('automatic', 'secondaryOnly', 'oneBesideSecondary', 'oneOverSecondary', 'twoBesideSecondary', 'twoOverSecondary', 'twoDisplaceSecondary'));
export type SplitBehavior = 'automatic' | 'tile' | 'overlay' | 'displace';
const splitBehaviorConverter = makeParser<SplitBehavior>(makeValidator<SplitBehavior>('automatic', 'tile', 'overlay', 'displace'));
// Default child roles (helps authoring without setting splitRole on children)
const ROLE_ORDER: SplitRole[] = ['primary', 'secondary', 'supplementary'];
@CSSType('SplitView')
export class SplitViewBase extends LayoutBase {
/**
* The master display split style display settings.
* Must be set before bootstrapping the app.
*/
static SplitStyle: SplitStyle;
static getInstance(): SplitViewBase | null {
// Platform-specific implementations may override
return null;
}
/** Child role (primary, secondary, supplementary) */
splitRole: SplitRole;
/** Preferred display mode */
displayMode: SplitDisplayMode;
/** Preferred split behavior (iOS 14+) */
splitBehavior: SplitBehavior;
/** Primary column width fraction (0..1) */
preferredPrimaryColumnWidthFraction: number;
/** Supplementary column width fraction (0..1, iOS 14+ triple) */
preferredSupplementaryColumnWidthFraction: number;
/**
* Get child role (primary, secondary, supplementary)
*/
public static getRole(element: SplitViewBase): SplitRole {
return element.splitRole;
}
/**
* Set child role (primary, secondary, supplementary)
*/
public static setRole(element: SplitViewBase, value: SplitRole): void {
element.splitRole = value;
}
// Called when a child's role changes; platform impls may override
public onRoleChanged(view: View, oldValue: SplitRole, newValue: SplitRole) {
this.requestLayout();
}
showPrimary() {
// Platform-specific implementations may override
}
hidePrimary() {
// Platform-specific implementations may override
}
showSecondary() {
// Platform-specific implementations may override
}
hideSecondary() {
// Platform-specific implementations may override
}
showSupplementary() {
// Platform-specific implementations may override
}
// Utility to infer a role by index when none specified
protected _roleByIndex(index: number): SplitRole {
return ROLE_ORDER[Math.max(0, Math.min(index, ROLE_ORDER.length - 1))];
}
}
SplitViewBase.prototype.recycleNativeView = 'auto';
export const splitRoleProperty = new Property<View, SplitRole>({
name: 'splitRole',
defaultValue: 'primary',
valueChanged: (target, oldValue, newValue) => {
const parent = target.parent;
if (parent instanceof SplitViewBase) {
parent.onRoleChanged(target, oldValue, newValue);
}
},
valueConverter: splitRoleConverter,
});
splitRoleProperty.register(View);
export const displayModeProperty = new Property<SplitViewBase, SplitDisplayMode>({
name: 'displayMode',
defaultValue: 'automatic',
affectsLayout: __APPLE__,
valueConverter: splitDisplayModeConverter,
});
displayModeProperty.register(SplitViewBase);
export const splitBehaviorProperty = new Property<SplitViewBase, SplitBehavior>({
name: 'splitBehavior',
defaultValue: 'automatic',
affectsLayout: __APPLE__,
valueConverter: splitBehaviorConverter,
});
splitBehaviorProperty.register(SplitViewBase);
export const preferredPrimaryColumnWidthFractionProperty = new Property<SplitViewBase, number>({
name: 'preferredPrimaryColumnWidthFraction',
defaultValue: 0,
affectsLayout: __APPLE__,
valueConverter: (v) => Math.max(0, Math.min(1, parseFloat(v))),
});
preferredPrimaryColumnWidthFractionProperty.register(SplitViewBase);
export const preferredSupplementaryColumnWidthFractionProperty = new Property<SplitViewBase, number>({
name: 'preferredSupplementaryColumnWidthFraction',
defaultValue: 0,
affectsLayout: __APPLE__,
valueConverter: (v) => Math.max(0, Math.min(1, parseFloat(v))),
});
preferredSupplementaryColumnWidthFractionProperty.register(SplitViewBase);