Skip to content

fix(core): insert iOS child views relative to their sibling, not by raw index - #11406

Open
NathanWalker wants to merge 1 commit into
mainfrom
fix/ios-insert-subview-below-sibling
Open

fix(core): insert iOS child views relative to their sibling, not by raw index#11406
NathanWalker wants to merge 1 commit into
mainfrom
fix/ios-insert-subview-below-sibling

Conversation

@NathanWalker

Copy link
Copy Markdown
Contributor

On iOS, insertChild(view, index) can place the child's native view one position lower than asked, beneath the sibling it should sit above. A view inserted after a ScrollView ends up underneath it and stops receiving touches while remaining fully visible; a view inserted before an opaque sibling disappears behind it.

Root cause: every iOS _addViewToNativeVisualTree implementation used insertSubview:atIndex:. UIKit resolves that index against the layer's sublayers, not the subviews array, and core installs non-view sublayers of its own:

  • a CSS linear-gradient background is a CAGradientLayer at sublayer 0 (background.ios.ts, also RootLayout)
  • each child with box-shadow gets an outer shadow layer inserted below its own layer

With any of those ahead of the insertion point, the raw index is off by the number of such layers. Appending (addChild) is unaffected, which is why this only shows up when a child is inserted in the middle: keyed list updates, @for/v-for prepends, conditional views that mount after their siblings, framework HMR remounts.

Fix

New IOSHelper.insertSubview(parentNativeView, childNativeView, atIndex?) appends when the index is absent or past the end and otherwise inserts with insertSubview:belowSubview: against the subview currently at that index, which is independent of extra sublayers. All four raw-index sites use it:

  • View._addViewToNativeVisualTree
  • Page._addViewToNativeVisualTree
  • LiquidGlass._addViewToNativeVisualTree
  • LiquidGlassContainer._addViewToNativeVisualTree

The atIndex contract is unchanged (a subview index, as ProxyViewContainer and _childIndexToNativeChildIndex already assume), so callers are untouched.

…aw index

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.
@nx-cloud

nx-cloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 43fce35

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 3m 28s View ↗
nx run-many --target=test --configuration=ci --... ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-09-02 13:29:05 UTC

@pkg-pr-new

pkg-pr-new Bot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@nativescript/core@11406
npm i https://pkg.pr.new/@nativescript/vite@11406
npm i https://pkg.pr.new/@nativescript/webpack@11406

commit: 43fce35

@CatchABus

CatchABus commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

@NathanWalker This generated description seems really strange.
If there's a problem with box-shadow layer you are facing, we can discuss it since I'm already working on taking down this implementation in favor of a better one that doesn't nest the layer in the parent.
Is there any sample around I could try?

@NathanWalker

Copy link
Copy Markdown
Contributor Author

@CatchABus thanks for the look; this is not a box-shadow problem (the fix doesn't touch the shadow code) sorry the description buried that. The shadow layer is only one of the extra sublayers that trigger it; the case I actually hit is a CSS linear-gradient background, which core installs as a CAGradientLayer at sublayer 0.

The bug is in how children get inserted: every iOS _addViewToNativeVisualTree uses insertSubview:atIndex:, and UIKit resolves that index against the layer's sublayers, not subviews. So with any non-view sublayer ahead of the insertion point, insertChild(view, 1) lands the view at subview 0. Appends are fine, which is why it only shows for inserts in the middle. insertSubview:belowSubview: doesn't have the problem, which is all the PR changes.

Your shadow rework would remove one source of extra sublayers, but not the gradient layer (or anything a plugin adds), so I think the insert fix stands on its own.

Sample to repro on your end:

// Drop this file into any NativeScript app (plain TypeScript, no framework needed)
// and call `runInsertOrderRepro(page)` from the page's `loaded` handler:
//
//   <Page loaded="onLoaded"> ... </Page>
//   export function onLoaded(args) { runInsertOrderRepro(args.object); }
//
//   Currently:
//   [nativescript] child order:  a, x, b, c
//   [uikit]  subviews (gradient): x, a, b, c     <- wrong, x went below a
//   [uikit]  subviews (plain):    a, x, b, c     <- the same insert with no extra sublayer
//
// With the fix (insertSubview:belowSubview:), the gradient case prints a, x, b, c too.

import { GridLayout, Label, Page } from '@nativescript/core';

export function runInsertOrderRepro(page: Page): void {
	page.addCss('.repro-gradient { background: linear-gradient(to bottom, #ffffff, #f4f4f5); }');

	const grid = new GridLayout();
	grid.className = 'repro-gradient';
	grid.height = 120;
	const label = (text: string) => {
		const l = new Label();
		l.text = text;
		l.height = 20;
		return l;
	};
	grid.addChild(label('a'));
	grid.addChild(label('b'));
	grid.addChild(label('c'));
	page.content = grid;

	setTimeout(() => {
		grid.insertChild(label('x'), 1);

		const children: string[] = [];
		grid.eachChildView((child) => {
			children.push((child as Label).text);
			return true;
		});
		console.log('[nativescript] child order: ', children.join(', '));
		console.log('[uikit] subviews (gradient):  ', subviewTexts(grid.ios as UIView));

		// Pure UIKit, no NativeScript involved: an extra sublayer ahead of the
		// children shifts insertSubview:atIndex: by one.
		const plain = UIView.alloc().initWithFrame(CGRectMake(0, 0, 100, 100));
		const decorated = UIView.alloc().initWithFrame(CGRectMake(0, 0, 100, 100));
		decorated.layer.insertSublayerAtIndex(CAGradientLayer.new(), 0);
		for (const parent of [plain, decorated]) {
			for (const tag of [1, 2, 3]) parent.addSubview(taggedView(tag));
			parent.insertSubviewAtIndex(taggedView(9), 1);
		}
		console.log('[uikit] insertSubview:atIndex:1, plain parent:    ', subviewTags(plain));
		console.log('[uikit] insertSubview:atIndex:1, extra sublayer:  ', subviewTags(decorated));
	}, 100);
}

function taggedView(tag: number): UIView {
	const view = UIView.alloc().initWithFrame(CGRectMake(0, 0, 10, 10));
	view.tag = tag;
	return view;
}

function subviewTexts(view: UIView): string {
	const out: string[] = [];
	for (let i = 0; i < view.subviews.count; i++) {
		out.push((view.subviews.objectAtIndex(i) as UILabel).text);
	}
	return out.join(', ');
}

function subviewTags(view: UIView): string {
	const out: number[] = [];
	for (let i = 0; i < view.subviews.count; i++) {
		out.push(view.subviews.objectAtIndex(i).tag);
	}
	return out.join(', ');
}

* 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could rename the function to insertNativeSubview to make it distinct for users and plugin maintainers that it doesn't target {N} JS views.

Also, it would be nice to have an android counterpart which could contain the View class implementation by default.

}

/**
* Add `childNativeView` to `parentNativeView` at subview index `atIndex`, or

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment needs shrinking and correction.

@CatchABus

CatchABus commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@NathanWalker Thanks for the detailed response, it makes more sense now!
Thanks for the sample you created too, I made sure to try it.

I requested a couple of changes and suggestions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants