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 d990dfbdaf..b0dba7ce12 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.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 3ed5240a37..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; } /** @@ -66,6 +73,13 @@ 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; + /** + * 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 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 81f265c223..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,6 +312,16 @@ export class IOSHelper { return rootView.safeAreaLayoutGuide; } + 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)); + } + } + 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..f85ac654b0 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.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 e545dd29bb..92e2e2b7fb 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.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 96d0a626f5..e82cbec3f7 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.insertNativeSubview(nativeParent, nativeChild, atIndex); return true; }