Skip to content

Commit edfa9b0

Browse files
committed
feat(ios): SplitView layout improvements
1 parent 6646758 commit edfa9b0

File tree

2 files changed

+188
-14
lines changed

2 files changed

+188
-14
lines changed

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

Lines changed: 168 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import { SplitViewBase, displayModeProperty, splitBehaviorProperty, preferredPrimaryColumnWidthFractionProperty, preferredSupplementaryColumnWidthFractionProperty, preferredInspectorColumnWidthFractionProperty } from './split-view-common';
1+
import { SplitViewBase, displayModeProperty, splitBehaviorProperty, preferredPrimaryColumnWidthFractionProperty, preferredSupplementaryColumnWidthFractionProperty, preferredInspectorColumnWidthFractionProperty, navigationBarTintColorProperty } from './split-view-common';
22
import { View } from '../core/view';
33
import { layout } from '../../utils';
44
import { SDK_VERSION } from '../../utils/constants';
55
import { FrameBase } from '../frame/frame-common';
6+
import { Color } from '../../color';
67
import type { SplitRole } from '.';
78

89
@NativeClass
910
class UISplitViewControllerDelegateImpl extends NSObject implements UISplitViewControllerDelegate {
1011
public static ObjCProtocols = [UISplitViewControllerDelegate];
1112
static ObjCExposedMethods = {
1213
toggleInspector: { returns: interop.types.void, params: [] },
14+
togglePrimary: { returns: interop.types.void, params: [] },
1315
};
1416
private _owner: WeakRef<SplitView>;
1517

@@ -29,19 +31,44 @@ class UISplitViewControllerDelegateImpl extends NSObject implements UISplitViewC
2931
}
3032

3133
splitViewControllerDidCollapse(svc: UISplitViewController): void {
32-
// Can be used to notify owner if needed
34+
const owner = this._owner.deref();
35+
if (owner) {
36+
owner._invalidateAllChildLayouts();
37+
}
3338
}
3439

3540
splitViewControllerDidExpand(svc: UISplitViewController): void {
36-
// Can be used to notify owner if needed
41+
const owner = this._owner.deref();
42+
if (owner) {
43+
owner._invalidateAllChildLayouts();
44+
}
3745
}
3846

3947
splitViewControllerDidHideColumn(svc: UISplitViewController, column: UISplitViewControllerColumn): void {
40-
// Can be used to notify owner if needed
48+
const owner = this._owner.deref();
49+
if (owner) {
50+
if (column === UISplitViewControllerColumn.Primary) {
51+
owner.primaryButtonAttached = false;
52+
} else if (column === UISplitViewControllerColumn.Inspector) {
53+
owner.inspectorButtonAttached = false;
54+
}
55+
owner._invalidateAllChildLayouts();
56+
}
4157
}
4258

4359
splitViewControllerDidShowColumn(svc: UISplitViewController, column: UISplitViewControllerColumn): void {
44-
// Can be used to notify owner if needed
60+
const owner = this._owner.deref();
61+
if (owner) {
62+
owner._invalidateAllChildLayouts();
63+
// Re-attach buttons when columns are shown (e.g., via gesture)
64+
if (column === UISplitViewControllerColumn.Primary) {
65+
owner.primaryShowing = true;
66+
setTimeout(() => owner.attachPrimaryButton(), 100);
67+
} else if (column === UISplitViewControllerColumn.Inspector) {
68+
owner.inspectorShowing = true;
69+
setTimeout(() => owner.attachInspectorButton(), 100);
70+
}
71+
}
4572
}
4673

4774
splitViewControllerDisplayModeForExpandingToProposedDisplayMode(svc: UISplitViewController, proposedDisplayMode: UISplitViewControllerDisplayMode): UISplitViewControllerDisplayMode {
@@ -62,6 +89,19 @@ class UISplitViewControllerDelegateImpl extends NSObject implements UISplitViewC
6289
}
6390
}
6491
}
92+
93+
togglePrimary(): void {
94+
const owner = this._owner.deref();
95+
if (owner) {
96+
if (owner.primaryShowing) {
97+
owner.hidePrimary();
98+
owner.primaryShowing = false;
99+
} else {
100+
owner.showPrimary();
101+
owner.primaryShowing = true;
102+
}
103+
}
104+
}
65105
}
66106

67107
export class SplitView extends SplitViewBase {
@@ -75,7 +115,10 @@ export class SplitView extends SplitViewBase {
75115
// Keep role -> controller
76116
private _controllers = new Map<SplitRole, UIViewController | UINavigationController>();
77117
private _children = new Map<SplitRole, View>();
118+
primaryButtonAttached = false;
119+
inspectorButtonAttached = false;
78120
inspectorShowing = false;
121+
primaryShowing = true;
79122

80123
constructor() {
81124
super();
@@ -88,6 +131,11 @@ export class SplitView extends SplitViewBase {
88131
this.viewController.delegate = this._delegate;
89132
this.viewController.presentsWithGesture = true;
90133

134+
// Disable automatic display mode button - we manage our own buttons
135+
if (this.viewController.displayModeButtonVisibility !== undefined) {
136+
this.viewController.displayModeButtonVisibility = UISplitViewControllerDisplayModeButtonVisibility.Never;
137+
}
138+
91139
// Apply initial preferences
92140
this._applyPreferences();
93141

@@ -205,26 +253,34 @@ export class SplitView extends SplitViewBase {
205253
showPrimary(): void {
206254
if (!this.viewController) return;
207255
this.viewController.showColumn(UISplitViewControllerColumn.Primary);
256+
this._invalidateAllChildLayouts();
257+
// Attach button after a short delay to ensure the column is visible
258+
setTimeout(() => this.attachPrimaryButton(), 100);
208259
}
209260

210261
hidePrimary(): void {
211262
if (!this.viewController) return;
212263
this.viewController.hideColumn(UISplitViewControllerColumn.Primary);
264+
this.primaryButtonAttached = false;
265+
this._invalidateAllChildLayouts();
213266
}
214267

215268
showSecondary(): void {
216269
if (!this.viewController) return;
217270
this.viewController.showColumn(UISplitViewControllerColumn.Secondary);
271+
this._invalidateAllChildLayouts();
218272
}
219273

220274
hideSecondary(): void {
221275
if (!this.viewController) return;
222276
this.viewController.hideColumn(UISplitViewControllerColumn.Secondary);
277+
this._invalidateAllChildLayouts();
223278
}
224279

225280
showSupplementary(): void {
226281
if (!this.viewController) return;
227282
this.viewController.showColumn(UISplitViewControllerColumn.Supplementary);
283+
this._invalidateAllChildLayouts();
228284
}
229285

230286
showInspector(): void {
@@ -233,14 +289,19 @@ export class SplitView extends SplitViewBase {
233289
if (this.viewController.preferredInspectorColumnWidthFraction !== undefined) {
234290
this.viewController.showColumn(UISplitViewControllerColumn.Inspector);
235291
this.notifyInspectorChange(true);
292+
this._invalidateAllChildLayouts();
293+
// Attach button after a short delay to ensure the column is visible
294+
setTimeout(() => this.attachInspectorButton(), 100);
236295
}
237296
}
238297

239298
hideInspector(): void {
240299
if (!this.viewController) return;
241300
if (this.viewController.preferredInspectorColumnWidthFraction !== undefined) {
242301
this.viewController.hideColumn(UISplitViewControllerColumn.Inspector);
302+
this.inspectorButtonAttached = false;
243303
this.notifyInspectorChange(false);
304+
this._invalidateAllChildLayouts();
244305
}
245306
}
246307

@@ -253,6 +314,46 @@ export class SplitView extends SplitViewBase {
253314
});
254315
}
255316

317+
invalidateChildLayouts(delay: number = 0): void {
318+
const refreshLayouts = () => {
319+
for (const [role, child] of this._children.entries()) {
320+
if (child && child.requestLayout) {
321+
child.requestLayout();
322+
}
323+
// Also trigger layout on the native view
324+
if (child && child.nativeViewProtected) {
325+
child.nativeViewProtected.setNeedsLayout();
326+
child.nativeViewProtected.layoutIfNeeded();
327+
}
328+
// If it's a Frame, also request layout on its current page
329+
if ((child as FrameBase)?.currentPage?.requestLayout) {
330+
(child as FrameBase).currentPage.requestLayout();
331+
if ((child as FrameBase).currentPage.nativeViewProtected) {
332+
(child as FrameBase).currentPage.nativeViewProtected.setNeedsLayout();
333+
(child as FrameBase).currentPage.nativeViewProtected.layoutIfNeeded();
334+
}
335+
}
336+
}
337+
// Also request layout on the SplitView itself
338+
this.requestLayout();
339+
if (this.nativeViewProtected) {
340+
this.nativeViewProtected.setNeedsLayout();
341+
this.nativeViewProtected.layoutIfNeeded();
342+
}
343+
};
344+
345+
if (delay > 0) {
346+
setTimeout(refreshLayouts, delay);
347+
} else {
348+
refreshLayouts();
349+
}
350+
}
351+
352+
_invalidateAllChildLayouts(): void {
353+
// Call after animation typically completes to ensure native layout pass has finished
354+
this.invalidateChildLayouts(350);
355+
}
356+
256357
private _resolveRoleForChild(child: SplitViewBase, atIndex: number): SplitRole {
257358
const explicit = SplitViewBase.getRole(child);
258359
if (explicit) {
@@ -318,7 +419,52 @@ export class SplitView extends SplitViewBase {
318419
targetVC.navigationItem.leftItemsSupplementBackButton = true;
319420
}
320421

321-
private _attachInspectorButton(): void {
422+
attachPrimaryButton(): void {
423+
if (this.primaryButtonAttached) {
424+
return;
425+
}
426+
427+
const primary = this._controllers.get('primary');
428+
if (!(primary instanceof UINavigationController)) {
429+
return;
430+
}
431+
432+
const targetVC = primary.topViewController;
433+
if (!targetVC) {
434+
// Subscribe to Frame's navigatedTo event to know when the first page is shown
435+
const frameChild = this._children.get('primary') as any;
436+
if (frameChild && frameChild.on && !frameChild._splitViewPrimaryNavigatedHandler) {
437+
frameChild._splitViewPrimaryNavigatedHandler = () => {
438+
// Use setTimeout to ensure the navigation controller's topViewController is updated
439+
setTimeout(() => this.attachPrimaryButton(), 0);
440+
};
441+
frameChild.on(FrameBase.navigatedToEvent, frameChild._splitViewPrimaryNavigatedHandler);
442+
}
443+
return;
444+
}
445+
446+
// Set the navigation bar tint color for the primary column
447+
if (primary.navigationBar && this.navigationBarTintColor) {
448+
primary.navigationBar.tintColor = this.navigationBarTintColor.ios;
449+
}
450+
451+
// Add a sidebar button to primary column matching the inspector style
452+
const cfg = UIImageSymbolConfiguration.configurationWithPointSizeWeightScale(18, UIImageSymbolWeight.Regular, UIImageSymbolScale.Medium);
453+
const image = UIImage.systemImageNamedWithConfiguration('sidebar.leading', cfg);
454+
const item = UIBarButtonItem.alloc().initWithImageStyleTargetAction(image, UIBarButtonItemStyle.Plain, this._delegate, 'togglePrimary');
455+
if (this.navigationBarTintColor) {
456+
item.tintColor = this.navigationBarTintColor.ios;
457+
}
458+
// Use rightBarButtonItems array to ensure we control exactly what's shown
459+
targetVC.navigationItem.rightBarButtonItems = NSArray.arrayWithObject(item);
460+
this.primaryButtonAttached = true;
461+
}
462+
463+
attachInspectorButton(): void {
464+
if (this.inspectorButtonAttached) {
465+
return;
466+
}
467+
322468
const inspector = this._controllers.get('inspector');
323469
if (!(inspector instanceof UINavigationController)) {
324470
return;
@@ -331,23 +477,27 @@ export class SplitView extends SplitViewBase {
331477
if (frameChild && frameChild.on && !frameChild._splitViewNavigatedHandler) {
332478
frameChild._splitViewNavigatedHandler = () => {
333479
// Use setTimeout to ensure the navigation controller's topViewController is updated
334-
setTimeout(() => this._attachInspectorButton(), 0);
480+
setTimeout(() => this.attachInspectorButton(), 100);
335481
};
336482
frameChild.on(FrameBase.navigatedToEvent, frameChild._splitViewNavigatedHandler);
337483
}
338484
return;
339485
}
340486

341-
// Avoid duplicates
342-
if (targetVC.navigationItem.rightBarButtonItem) {
343-
return;
487+
// Set the navigation bar tint color for the inspector column
488+
if (inspector.navigationBar && this.navigationBarTintColor) {
489+
inspector.navigationBar.tintColor = this.navigationBarTintColor.ios;
344490
}
345491

346-
// TODO: can provide properties to customize this
492+
// Note: could provide properties to customize this
347493
const cfg = UIImageSymbolConfiguration.configurationWithPointSizeWeightScale(18, UIImageSymbolWeight.Regular, UIImageSymbolScale.Medium);
348494
const image = UIImage.systemImageNamedWithConfiguration('sidebar.trailing', cfg);
349495
const item = UIBarButtonItem.alloc().initWithImageStyleTargetAction(image, UIBarButtonItemStyle.Plain, this._delegate, 'toggleInspector');
496+
if (this.navigationBarTintColor) {
497+
item.tintColor = this.navigationBarTintColor.ios;
498+
}
350499
targetVC.navigationItem.rightBarButtonItem = item;
500+
this.inspectorButtonAttached = true;
351501
}
352502

353503
private _syncControllers(): void {
@@ -428,17 +578,17 @@ export class SplitView extends SplitViewBase {
428578
const supplementary = this._controllers.get('supplementary');
429579
const inspector = this._controllers.get('inspector');
430580

431-
// Attach displayModeButtonItem to the secondary column's first page
432581
if (secondary instanceof UINavigationController) {
433582
this._attachSecondaryDisplayModeButton();
434583
}
584+
if (primary instanceof UINavigationController) {
585+
this.attachPrimaryButton();
586+
}
435587
if (supplementary) {
436588
this.showSupplementary();
437589
}
438590
if (inspector) {
439591
this.showInspector();
440-
// Ensure the inspector column gets its toggle button as soon as the first page is shown
441-
this._attachInspectorButton();
442592
}
443593

444594
// Width fractions
@@ -480,4 +630,8 @@ export class SplitView extends SplitViewBase {
480630
[preferredInspectorColumnWidthFractionProperty.setNative](value: number) {
481631
this._applyPreferences();
482632
}
633+
634+
[navigationBarTintColorProperty.setNative](value: Color) {
635+
this._applyPreferences();
636+
}
483637
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { LayoutBase } from '../layouts/layout-base';
22
import { View, CSSType } from '../core/view';
33
import { Property, makeParser, makeValidator } from '../core/properties';
4+
import { Color } from '../../color';
45
import type { SplitBehavior, SplitDisplayMode, SplitRole, SplitStyle } from '.';
56

67
const splitRoleConverter = makeParser<SplitRole>(makeValidator<SplitRole>('primary', 'secondary', 'supplementary', 'inspector'));
@@ -35,6 +36,8 @@ export class SplitViewBase extends LayoutBase {
3536
preferredSupplementaryColumnWidthFraction: number;
3637
/** Inspector column width fraction (0..1, iOS 17+/18+ when Inspector column available) */
3738
preferredInspectorColumnWidthFraction: number;
39+
/** Navigation bar tint color for buttons */
40+
navigationBarTintColor: Color;
3841

3942
/**
4043
* Get child role (primary, secondary, supplementary, inspector)
@@ -87,6 +90,16 @@ export class SplitViewBase extends LayoutBase {
8790
// Platform-specific implementations may override
8891
}
8992

93+
/**
94+
* Invalidate layouts for all child views in the SplitView.
95+
* Useful when columns change, orientation changes, or any scenario
96+
* requiring a full layout refresh of all split view children.
97+
* @param delay Optional delay in milliseconds (default 350ms to wait for animations)
98+
*/
99+
invalidateChildLayouts(delay: number = 0): void {
100+
// Platform-specific implementations may override
101+
}
102+
90103
// Utility to infer a role by index when none specified
91104
protected _roleByIndex(index: number): SplitRole {
92105
return ROLE_ORDER[Math.max(0, Math.min(index, ROLE_ORDER.length - 1))];
@@ -147,3 +160,10 @@ export const preferredInspectorColumnWidthFractionProperty = new Property<SplitV
147160
valueConverter: (v) => Math.max(0, Math.min(1, parseFloat(v))),
148161
});
149162
preferredInspectorColumnWidthFractionProperty.register(SplitViewBase);
163+
164+
export const navigationBarTintColorProperty = new Property<SplitViewBase, Color>({
165+
name: 'navigationBarTintColor',
166+
equalityComparer: Color.equals,
167+
valueConverter: (v) => new Color(v),
168+
});
169+
navigationBarTintColorProperty.register(SplitViewBase);

0 commit comments

Comments
 (0)