From 43fce35ce0609c2db3bea7ef4a8ca5f94415cb86 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 1 Sep 2026 13:08:20 -0700 Subject: [PATCH 1/2] fix(core): insert iOS child views relative to their sibling, not by raw index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit insertSubview:atIndex: resolves the index against the layer's sublayers, which also hold non-view layers: a CSS gradient background sits at sublayer 0 and each shadowed child adds an outer shadow layer. With any of those ahead of the insertion point, insertChild(view, index) landed the child one position below the sibling it should precede — a view inserted after a ScrollView ended up beneath it and lost its touches, while appending was unaffected. Reproduced with a bare UIView probe on iOS 18.5 and 26.5. IOSHelper.insertSubview() appends past the end and otherwise uses insertSubview:belowSubview: against the subview at the index, which is independent of extra sublayers. View, Page, LiquidGlass and LiquidGlassContainer all go through it. --- packages/core/ui/core/view/index.ios.ts | 6 +----- .../core/ui/core/view/view-helper/index.d.ts | 5 +++++ .../core/ui/core/view/view-helper/index.ios.ts | 17 +++++++++++++++++ .../layouts/liquid-glass-container/index.ios.ts | 8 ++------ .../core/ui/layouts/liquid-glass/index.ios.ts | 8 ++------ packages/core/ui/page/index.ios.ts | 6 +----- 6 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/core/ui/core/view/index.ios.ts b/packages/core/ui/core/view/index.ios.ts index d990dfbdaf..254285ee00 100644 --- a/packages/core/ui/core/view/index.ios.ts +++ b/packages/core/ui/core/view/index.ios.ts @@ -1270,11 +1270,7 @@ export class CustomLayoutView extends ContainerView { const childNativeView: NativeScriptUIView = child.nativeViewProtected; if (parentNativeView && childNativeView) { - if (typeof atIndex !== 'number' || atIndex >= parentNativeView.subviews.count) { - parentNativeView.addSubview(childNativeView); - } else { - parentNativeView.insertSubviewAtIndex(childNativeView, atIndex); - } + IOSHelper.insertSubview(parentNativeView, childNativeView, atIndex); // Add outer shadow layer manually as it belongs to parent layer tree (this is needed for reusable views) if (childNativeView.outerShadowContainerLayer && !childNativeView.outerShadowContainerLayer.superlayer) { diff --git a/packages/core/ui/core/view/view-helper/index.d.ts b/packages/core/ui/core/view/view-helper/index.d.ts index 3ed5240a37..1365466542 100644 --- a/packages/core/ui/core/view/view-helper/index.d.ts +++ b/packages/core/ui/core/view/view-helper/index.d.ts @@ -66,6 +66,11 @@ export namespace IOSHelper { export function invalidateStatusBarAppearance(controller?: any /* UIViewController */, reason?: string): void; export function updateAutoAdjustScrollInsets(controller: any /* UIViewController */, owner: View): void; export function updateConstraints(controller: any /* UIViewController */, owner: View): void; + /** + * Add `childNativeView` to `parentNativeView` at subview index `atIndex`, or append it when the index is absent or past the end. + * Uses `insertSubview:belowSubview:` — `insertSubview:atIndex:` resolves the index against the layer's sublayers, which also hold non-view layers (gradient backgrounds, shadow layers). + */ + export function insertSubview(parentNativeView: any /* UIView */, childNativeView: any /* UIView */, atIndex?: number): void; export function layoutView(controller: any /* UIViewController */, owner: View): void; export function getPositionFromFrame(frame: any /* CGRect */): Position; export function getFrameFromPosition(position: Position, insets?: Position): any; /* CGRect */ diff --git a/packages/core/ui/core/view/view-helper/index.ios.ts b/packages/core/ui/core/view/view-helper/index.ios.ts index 81f265c223..f4f1eb2f2f 100644 --- a/packages/core/ui/core/view/view-helper/index.ios.ts +++ b/packages/core/ui/core/view/view-helper/index.ios.ts @@ -312,6 +312,23 @@ export class IOSHelper { return rootView.safeAreaLayoutGuide; } + /** + * Add `childNativeView` to `parentNativeView` at subview index `atIndex`, or + * append it when the index is absent or past the end. Never done with + * `insertSubview:atIndex:`: UIKit resolves that index against the layer's + * sublayers, which also hold the non-view layers core installs (a gradient + * background at sublayer 0, an outer shadow layer per shadowed child), so the + * view lands below the sibling it should precede. + */ + static insertSubview(parentNativeView: UIView, childNativeView: UIView, atIndex?: number): void { + const subviews = parentNativeView.subviews; + if (typeof atIndex !== 'number' || atIndex >= subviews.count) { + parentNativeView.addSubview(childNativeView); + } else { + parentNativeView.insertSubviewBelowSubview(childNativeView, subviews.objectAtIndex(atIndex)); + } + } + static layoutView(controller: UIViewController, owner: View): void { let layoutGuide = controller.view.safeAreaLayoutGuide; if (!layoutGuide) { diff --git a/packages/core/ui/layouts/liquid-glass-container/index.ios.ts b/packages/core/ui/layouts/liquid-glass-container/index.ios.ts index 323edf9fe0..4ebcad8bed 100644 --- a/packages/core/ui/layouts/liquid-glass-container/index.ios.ts +++ b/packages/core/ui/layouts/liquid-glass-container/index.ios.ts @@ -1,6 +1,6 @@ import type { NativeScriptUIView } from '../../utils'; import { supportsGlass } from '../../../utils/constants'; -import { GlassEffectType, iosGlassEffectProperty, View } from '../../core/view'; +import { GlassEffectType, iosGlassEffectProperty, IOSHelper, View } from '../../core/view'; import { LiquidGlassContainerCommon } from './liquid-glass-container-common'; import { toUIGlassStyle } from '../liquid-glass'; @@ -39,11 +39,7 @@ export class LiquidGlassContainer extends LiquidGlassContainerCommon { const childNativeView: NativeScriptUIView = child.nativeViewProtected; if (parentNativeView && childNativeView) { - if (typeof atIndex !== 'number' || atIndex >= parentNativeView.subviews.count) { - parentNativeView.addSubview(childNativeView); - } else { - parentNativeView.insertSubviewAtIndex(childNativeView, atIndex); - } + IOSHelper.insertSubview(parentNativeView, childNativeView, atIndex); // Add outer shadow layer manually as it belongs to parent layer tree (this is needed for reusable views) if (childNativeView.outerShadowContainerLayer && !childNativeView.outerShadowContainerLayer.superlayer) { diff --git a/packages/core/ui/layouts/liquid-glass/index.ios.ts b/packages/core/ui/layouts/liquid-glass/index.ios.ts index e545dd29bb..5de77aa4e6 100644 --- a/packages/core/ui/layouts/liquid-glass/index.ios.ts +++ b/packages/core/ui/layouts/liquid-glass/index.ios.ts @@ -1,6 +1,6 @@ import type { NativeScriptUIView } from '../../utils'; import { supportsGlass } from '../../../utils/constants'; -import { type GlassEffectType, type GlassEffectVariant, iosGlassEffectProperty, View } from '../../core/view'; +import { type GlassEffectType, type GlassEffectVariant, iosGlassEffectProperty, IOSHelper, View } from '../../core/view'; import { LiquidGlassCommon } from './liquid-glass-common'; export class LiquidGlass extends LiquidGlassCommon { @@ -35,11 +35,7 @@ export class LiquidGlass extends LiquidGlassCommon { const childNativeView: NativeScriptUIView = child.nativeViewProtected; if (parentNativeView && childNativeView) { - if (typeof atIndex !== 'number' || atIndex >= parentNativeView.subviews.count) { - parentNativeView.addSubview(childNativeView); - } else { - parentNativeView.insertSubviewAtIndex(childNativeView, atIndex); - } + IOSHelper.insertSubview(parentNativeView, childNativeView, atIndex); // If the child has an outer shadow layer, ensure it is attached under the child's layer if (childNativeView.outerShadowContainerLayer && !childNativeView.outerShadowContainerLayer.superlayer) { diff --git a/packages/core/ui/page/index.ios.ts b/packages/core/ui/page/index.ios.ts index 96d0a626f5..8844c106e5 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -548,11 +548,7 @@ export class Page extends PageBase { } if (nativeParent && nativeChild) { - if (typeof atIndex !== 'number' || atIndex >= nativeParent.subviews.count) { - nativeParent.addSubview(nativeChild); - } else { - nativeParent.insertSubviewAtIndex(nativeChild, atIndex); - } + IOSHelper.insertSubview(nativeParent, nativeChild, atIndex); return true; } From c8f80583151c92d72c1c62a51a6891fdb768b5b3 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 30 Sep 2026 12:39:37 -0700 Subject: [PATCH 2/2] refactor(core): rename to insertNativeSubview and add Android counterpart Renames IOSHelper.insertSubview to insertNativeSubview so it reads as a native-view helper, adds AndroidHelper.insertNativeSubview (used by View and ActionBar), and trims the docs. --- packages/core/ui/action-bar/index.android.ts | 6 +- packages/core/ui/core/view/index.android.ts | 2 +- packages/core/ui/core/view/index.ios.ts | 2 +- .../view/view-helper/index.android.spec.ts | 49 +++++++++++++++ .../ui/core/view/view-helper/index.android.ts | 8 +++ .../core/ui/core/view/view-helper/index.d.ts | 15 ++++- .../core/view/view-helper/index.ios.spec.ts | 59 +++++++++++++++++++ .../ui/core/view/view-helper/index.ios.ts | 11 +--- .../liquid-glass-container/index.ios.ts | 2 +- .../core/ui/layouts/liquid-glass/index.ios.ts | 2 +- packages/core/ui/page/index.ios.ts | 2 +- 11 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 packages/core/ui/core/view/view-helper/index.android.spec.ts create mode 100644 packages/core/ui/core/view/view-helper/index.ios.spec.ts diff --git a/packages/core/ui/action-bar/index.android.ts b/packages/core/ui/action-bar/index.android.ts index 126e443572..a790d7cbe8 100644 --- a/packages/core/ui/action-bar/index.android.ts +++ b/packages/core/ui/action-bar/index.android.ts @@ -385,11 +385,7 @@ export class ActionBar extends ActionBarBase { super._addViewToNativeVisualTree(child); if (this.nativeViewProtected && child.nativeViewProtected) { - if (atIndex >= this.nativeViewProtected.getChildCount()) { - this.nativeViewProtected.addView(child.nativeViewProtected); - } else { - this.nativeViewProtected.addView(child.nativeViewProtected, atIndex); - } + AndroidHelper.insertNativeSubview(this.nativeViewProtected, child.nativeViewProtected, atIndex); return true; } diff --git a/packages/core/ui/core/view/index.android.ts b/packages/core/ui/core/view/index.android.ts index 79cadf94ae..c8ee57fd88 100644 --- a/packages/core/ui/core/view/index.android.ts +++ b/packages/core/ui/core/view/index.android.ts @@ -1826,7 +1826,7 @@ export class CustomLayoutView extends ContainerView { if (Trace.isEnabled()) { Trace.write(`${this}.nativeView.addView(${child}.nativeView, ${atIndex})`, Trace.categories.VisualTreeEvents); } - this.nativeViewProtected.addView(child.nativeViewProtected, atIndex); + AndroidHelper.insertNativeSubview(this.nativeViewProtected, child.nativeViewProtected, atIndex); if (child instanceof View) { this._updateNativeLayoutParams(child); } diff --git a/packages/core/ui/core/view/index.ios.ts b/packages/core/ui/core/view/index.ios.ts index 254285ee00..b0dba7ce12 100644 --- a/packages/core/ui/core/view/index.ios.ts +++ b/packages/core/ui/core/view/index.ios.ts @@ -1270,7 +1270,7 @@ export class CustomLayoutView extends ContainerView { const childNativeView: NativeScriptUIView = child.nativeViewProtected; if (parentNativeView && childNativeView) { - IOSHelper.insertSubview(parentNativeView, childNativeView, atIndex); + IOSHelper.insertNativeSubview(parentNativeView, childNativeView, atIndex); // Add outer shadow layer manually as it belongs to parent layer tree (this is needed for reusable views) if (childNativeView.outerShadowContainerLayer && !childNativeView.outerShadowContainerLayer.superlayer) { diff --git a/packages/core/ui/core/view/view-helper/index.android.spec.ts b/packages/core/ui/core/view/view-helper/index.android.spec.ts new file mode 100644 index 0000000000..c6a1772454 --- /dev/null +++ b/packages/core/ui/core/view/view-helper/index.android.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.hoisted(() => { + (globalThis as any).androidx = { core: { graphics: {} } }; +}); + +import { AndroidHelper } from './index.android'; + +/** Models android.view.ViewGroup, including its IndexOutOfBoundsException for out-of-range indexes. */ +class FakeViewGroup { + readonly children: string[] = []; + + getChildCount(): number { + return this.children.length; + } + + addView(view: string, index?: number): void { + if (index === undefined || index === -1) { + this.children.push(view); + return; + } + if (index < 0 || index > this.children.length) { + throw new Error(`IndexOutOfBoundsException: index=${index} count=${this.children.length}`); + } + this.children.splice(index, 0, view); + } +} + +function parentWith(...views: string[]): FakeViewGroup { + const parent = new FakeViewGroup(); + parent.children.push(...views); + return parent; +} + +describe('AndroidHelper.insertNativeSubview', () => { + it('inserts at the child index', () => { + const parent = parentWith('a', 'b', 'c'); + AndroidHelper.insertNativeSubview(parent as any, 'x' as any, 1); + expect(parent.children).toEqual(['a', 'x', 'b', 'c']); + }); + + it('appends when the index is absent, negative, or out of range', () => { + const parent = parentWith('a', 'b'); + AndroidHelper.insertNativeSubview(parent as any, 'x' as any); + AndroidHelper.insertNativeSubview(parent as any, 'y' as any, -1); + AndroidHelper.insertNativeSubview(parent as any, 'z' as any, Number.MAX_SAFE_INTEGER); + expect(parent.children).toEqual(['a', 'b', 'x', 'y', 'z']); + }); +}); diff --git a/packages/core/ui/core/view/view-helper/index.android.ts b/packages/core/ui/core/view/view-helper/index.android.ts index efe07ecc24..4463d818a2 100644 --- a/packages/core/ui/core/view/view-helper/index.android.ts +++ b/packages/core/ui/core/view/view-helper/index.android.ts @@ -51,6 +51,14 @@ export class AndroidHelper { } } + static insertNativeSubview(parentNativeView: android.view.ViewGroup, childNativeView: android.view.View, atIndex?: number): void { + if (typeof atIndex !== 'number' || atIndex < 0 || atIndex >= parentNativeView.getChildCount()) { + parentNativeView.addView(childNativeView); + } else { + parentNativeView.addView(childNativeView, atIndex); + } + } + static getCopyOrDrawable(drawable: android.graphics.drawable.Drawable, resources?: android.content.res.Resources): android.graphics.drawable.Drawable { if (drawable) { const constantState = drawable.getConstantState(); diff --git a/packages/core/ui/core/view/view-helper/index.d.ts b/packages/core/ui/core/view/view-helper/index.d.ts index 1365466542..f1b6eaf6e5 100644 --- a/packages/core/ui/core/view/view-helper/index.d.ts +++ b/packages/core/ui/core/view/view-helper/index.d.ts @@ -41,6 +41,13 @@ export namespace AndroidHelper { export function setDrawableColor(color: number, drawable: any /* android.graphics.drawable.Drawable */, blendMode?: any /* androidx.core.graphics.BlendModeCompat */): void; export function clearDrawableColor(drawable: any /* android.graphics.drawable.Drawable */): void; export function getCopyOrDrawable(drawable: any /* android.graphics.drawable.Drawable */, resources?: any /* android.content.res.Resources */): any; /* android.graphics.drawable.Drawable */ + /** + * Inserts a native child view at a child index, appending when the index is absent or out of range. + * @param parentNativeView Parent ViewGroup. + * @param childNativeView Android view to insert. + * @param atIndex Child index to insert at. + */ + export function insertNativeSubview(parentNativeView: any /* android.view.ViewGroup */, childNativeView: any /* android.view.View */, atIndex?: number): void; } /** @@ -67,10 +74,12 @@ export namespace IOSHelper { export function updateAutoAdjustScrollInsets(controller: any /* UIViewController */, owner: View): void; export function updateConstraints(controller: any /* UIViewController */, owner: View): void; /** - * Add `childNativeView` to `parentNativeView` at subview index `atIndex`, or append it when the index is absent or past the end. - * Uses `insertSubview:belowSubview:` — `insertSubview:atIndex:` resolves the index against the layer's sublayers, which also hold non-view layers (gradient backgrounds, shadow layers). + * Inserts a native subview at a subview index, appending when the index is absent or out of range. + * @param parentNativeView Parent UIView. + * @param childNativeView UIView to insert. + * @param atIndex Subview index to insert at. */ - export function insertSubview(parentNativeView: any /* UIView */, childNativeView: any /* UIView */, atIndex?: number): void; + export function insertNativeSubview(parentNativeView: any /* UIView */, childNativeView: any /* UIView */, atIndex?: number): void; export function layoutView(controller: any /* UIViewController */, owner: View): void; export function getPositionFromFrame(frame: any /* CGRect */): Position; export function getFrameFromPosition(position: Position, insets?: Position): any; /* CGRect */ diff --git a/packages/core/ui/core/view/view-helper/index.ios.spec.ts b/packages/core/ui/core/view/view-helper/index.ios.spec.ts new file mode 100644 index 0000000000..8921f6b98f --- /dev/null +++ b/packages/core/ui/core/view/view-helper/index.ios.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { IOSHelper } from './index.ios'; + +/** Models UIView's subview ordering; `insertSubviewAtIndex` is absent so any use of it throws. */ +class FakeUIView { + readonly children: FakeUIView[] = []; + + constructor(readonly tag: string) {} + + get subviews() { + const children = this.children; + return { + count: children.length, + objectAtIndex: (index: number) => children[index], + }; + } + + addSubview(view: FakeUIView): void { + this.children.push(view); + } + + insertSubviewBelowSubview(view: FakeUIView, sibling: FakeUIView): void { + this.children.splice(this.children.indexOf(sibling), 0, view); + } +} + +function parentWith(...tags: string[]): FakeUIView { + const parent = new FakeUIView('parent'); + for (const tag of tags) { + parent.addSubview(new FakeUIView(tag)); + } + return parent; +} + +function order(parent: FakeUIView): string[] { + return parent.children.map((child) => child.tag); +} + +describe('IOSHelper.insertNativeSubview', () => { + it('inserts below the subview currently at the index', () => { + const parent = parentWith('a', 'b', 'c'); + IOSHelper.insertNativeSubview(parent as any, new FakeUIView('x') as any, 1); + expect(order(parent)).toEqual(['a', 'x', 'b', 'c']); + }); + + it('inserts at the front for index 0', () => { + const parent = parentWith('a', 'b'); + IOSHelper.insertNativeSubview(parent as any, new FakeUIView('x') as any, 0); + expect(order(parent)).toEqual(['x', 'a', 'b']); + }); + + it('appends when the index is absent or out of range', () => { + const parent = parentWith('a', 'b'); + IOSHelper.insertNativeSubview(parent as any, new FakeUIView('x') as any); + IOSHelper.insertNativeSubview(parent as any, new FakeUIView('y') as any, 3); + IOSHelper.insertNativeSubview(parent as any, new FakeUIView('z') as any, Number.MAX_SAFE_INTEGER); + expect(order(parent)).toEqual(['a', 'b', 'x', 'y', 'z']); + }); +}); diff --git a/packages/core/ui/core/view/view-helper/index.ios.ts b/packages/core/ui/core/view/view-helper/index.ios.ts index f4f1eb2f2f..91b4289ee6 100644 --- a/packages/core/ui/core/view/view-helper/index.ios.ts +++ b/packages/core/ui/core/view/view-helper/index.ios.ts @@ -312,19 +312,12 @@ export class IOSHelper { return rootView.safeAreaLayoutGuide; } - /** - * Add `childNativeView` to `parentNativeView` at subview index `atIndex`, or - * append it when the index is absent or past the end. Never done with - * `insertSubview:atIndex:`: UIKit resolves that index against the layer's - * sublayers, which also hold the non-view layers core installs (a gradient - * background at sublayer 0, an outer shadow layer per shadowed child), so the - * view lands below the sibling it should precede. - */ - static insertSubview(parentNativeView: UIView, childNativeView: UIView, atIndex?: number): void { + static insertNativeSubview(parentNativeView: UIView, childNativeView: UIView, atIndex?: number): void { const subviews = parentNativeView.subviews; if (typeof atIndex !== 'number' || atIndex >= subviews.count) { parentNativeView.addSubview(childNativeView); } else { + // insertSubview:atIndex: also counts non-view sublayers, e.g. gradient backgrounds parentNativeView.insertSubviewBelowSubview(childNativeView, subviews.objectAtIndex(atIndex)); } } diff --git a/packages/core/ui/layouts/liquid-glass-container/index.ios.ts b/packages/core/ui/layouts/liquid-glass-container/index.ios.ts index 4ebcad8bed..f85ac654b0 100644 --- a/packages/core/ui/layouts/liquid-glass-container/index.ios.ts +++ b/packages/core/ui/layouts/liquid-glass-container/index.ios.ts @@ -39,7 +39,7 @@ export class LiquidGlassContainer extends LiquidGlassContainerCommon { const childNativeView: NativeScriptUIView = child.nativeViewProtected; if (parentNativeView && childNativeView) { - IOSHelper.insertSubview(parentNativeView, childNativeView, atIndex); + IOSHelper.insertNativeSubview(parentNativeView, childNativeView, atIndex); // Add outer shadow layer manually as it belongs to parent layer tree (this is needed for reusable views) if (childNativeView.outerShadowContainerLayer && !childNativeView.outerShadowContainerLayer.superlayer) { diff --git a/packages/core/ui/layouts/liquid-glass/index.ios.ts b/packages/core/ui/layouts/liquid-glass/index.ios.ts index 5de77aa4e6..92e2e2b7fb 100644 --- a/packages/core/ui/layouts/liquid-glass/index.ios.ts +++ b/packages/core/ui/layouts/liquid-glass/index.ios.ts @@ -35,7 +35,7 @@ export class LiquidGlass extends LiquidGlassCommon { const childNativeView: NativeScriptUIView = child.nativeViewProtected; if (parentNativeView && childNativeView) { - IOSHelper.insertSubview(parentNativeView, childNativeView, atIndex); + IOSHelper.insertNativeSubview(parentNativeView, childNativeView, atIndex); // If the child has an outer shadow layer, ensure it is attached under the child's layer if (childNativeView.outerShadowContainerLayer && !childNativeView.outerShadowContainerLayer.superlayer) { diff --git a/packages/core/ui/page/index.ios.ts b/packages/core/ui/page/index.ios.ts index 8844c106e5..e82cbec3f7 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -548,7 +548,7 @@ export class Page extends PageBase { } if (nativeParent && nativeChild) { - IOSHelper.insertSubview(nativeParent, nativeChild, atIndex); + IOSHelper.insertNativeSubview(nativeParent, nativeChild, atIndex); return true; }