← 输入框|github.com|刷新
Skip to content

Support state-preserving same-parent reordering in StackLayout #11545

Open

Description

@MathiasWP

Is your feature request related to a problem? Please describe.

A keyed renderer needs to move a focused input, or a layout containing that input, within the same StackLayout without ending the editing session. The current removeChild/insertChild pair cannot provide that behavior.

On @nativescript/core 9.1.3 (latest release when checked), removing and reinserting recreates the native input and loses focus on both iOS and Android. Temporarily setting reusable = true preserves native identity and selection, but still emits unloaded/loaded and loses focus. This reproduces in plain NativeScript with no Svelte or renderer code.

I am reporting a missing state-preserving reorder capability, rather than claiming that removeChild violates its removal contract or that this is a regression.

Describe the solution you would like

An explicit same-parent operation, provisionally StackLayout.moveChild(child, finalIndex), which preserves native attachment, native identity, focus and editing state. It should update logical child order, native child order, rendered layout and sibling selectors together. True removal should retain its existing cleanup behavior.

The candidate below is deliberately limited to StackLayout and direct native children. It rejects foreign children, invalid indices and proxy children before mutation, and returns false for a no-op. Broadening this to other layouts or cross-parent transfers needs a separate design review.

Describe alternatives you have considered

  • removeChild + insertChild: replaces the input and loses focus.
  • Temporary reusable + removal/insertion: retains the native object but still interrupts editing.
  • Restoring focus and selection after removal: does not guarantee continuity of the input connection or active composition.
  • Native z-order changes alone: do not keep NativeScript's logical child collection, layout and sibling CSS state in sync.

Reproduction

Copy this file into the entry of a plain NativeScript TypeScript app. The default reusable case prints nativeIdentityRetained: true and focusRetained: false on the tested platforms. Changing the argument to remove-insert also loses native identity. The move-child mode requires the candidate patch and preserves both.

// Copy into the entry of a plain NativeScript TypeScript app (core 9.1.3).
// Change the argument at the bottom to compare removal, reuse, and the patch.
import { Application, Label, Page, StackLayout, TextField, View, isIOS } from '@nativescript/core';

type Operation = 'remove-insert' | 'reusable' | 'move-child';
const asView = (view: unknown) => view as View;

function runRepro(operation: Operation) {
  Application.run({ create() {
    const page = new Page(); page.actionBarHidden = true;
    const stack = new StackLayout(); stack.padding = 24;
    const sibling = new Label(); sibling.text = 'Sibling';
    const input = new TextField(); input.text = 'Keep editing';
    stack.addChild(asView(sibling)); stack.addChild(asView(input));
    page.content = asView(stack);
    page.once(Page.loadedEvent, () => setTimeout(() => {
      input.focus();
      setTimeout(() => {
        const nativeInput = input.nativeViewProtected;
        const focused = () => isIOS ? !!input.ios.isFirstResponder : input.android.hasFocus();
        if (!focused()) throw new Error('Reproduction requires focus before moving');
        const lifecycle = { loaded: 0, unloaded: 0 };
        input.on(View.loadedEvent, () => lifecycle.loaded++);
        input.on(View.unloadedEvent, () => lifecycle.unloaded++);
        if (operation === 'move-child') {
          const move = (stack as StackLayout & { moveChild?: (view: View, index: number) => boolean }).moveChild;
          if (!move) throw new Error('Apply the proposed patch to test move-child');
          move.call(stack, asView(input), 0);
        } else {
          input.reusable = operation === 'reusable';
          try { stack.removeChild(asView(input)); stack.insertChild(asView(input), 0); }
          finally { input.reusable = false; }
        }
        setTimeout(() => console.log(JSON.stringify({
          operation, nativeIdentityRetained: nativeInput === input.nativeViewProtected,
          focusRetained: focused(), logicalOrderCorrect: stack.getChildAt(0) === asView(input), lifecycle
        })), 100);
      }, 100);
    }, 250));
    return page;
  } });
}

runRepro('reusable');

Candidate implementation

The source patch below applies to the core 9.1.3 source revision 45b3b99 and also applies cleanly to main at aeac327.

LayoutBase owns the child-array update and sibling CSS invalidation through an internal helper, with inspector remove/insert notifications that retain the same node identity. StackLayout reorders existing platform children using UIView.bringSubviewToFront / ViewGroup.bringChildToFront, without remove/add or focus-restoration calls. Host-owned native subviews keep their slots, and iOS outer shadow layers follow their views. This initial implementation uses O(n) native calls per move; it is not a performance claim or a virtualized-list design.

Proposed TypeScript patch
--- a/packages/core/ui/layouts/layout-base-common.ts
+++ b/packages/core/ui/layouts/layout-base-common.ts
@@ -74,6 +74,33 @@
 		}
 	}
 
+	// Internal bookkeeping for layouts with a state-preserving native reorder.
+	// Run native validation before mutating the logical collection.
+	public _moveChild(child: View, atIndex: number, moveNative: (children: ReadonlyArray<View>) => void): boolean {
+		const fromIndex = this._subViews.indexOf(child);
+		if (fromIndex < 0 || child.parent !== this) {
+			throw new Error('Cannot move a view that is not a child of this layout.');
+		}
+		if (!Number.isInteger(atIndex) || atIndex < 0 || atIndex >= this._subViews.length) {
+			throw new RangeError('The destination must be a valid final child index.');
+		}
+		if (fromIndex === atIndex) {
+			return false;
+		}
+		const children = this._subViews.slice();
+		children.splice(fromIndex, 1);
+		children.splice(atIndex, 0, child);
+		moveNative(children);
+		// Inspector notifications only: keep the same DOM node/native view alive.
+		this.domNode?.onChildRemoved(child);
+		this._subViews.splice(fromIndex, 1);
+		this._subViews.splice(atIndex, 0, child);
+		this.domNode?.onChildAdded(child);
+		this._invalidateSiblingCssState(Math.min(fromIndex, atIndex), Math.max(fromIndex, atIndex) + 2);
+		this.requestLayout();
+		return true;
+	}
+
 	// Sibling combinator matches are cached and never re-queried on tree changes.
 	// Appending changes no predecessor, so addChild skips invalidation.
 	private _invalidateSiblingCssState(fromIndex: number, adjacentEndIndex: number): void {
--- a/packages/core/ui/layouts/layout-base.d.ts
+++ b/packages/core/ui/layouts/layout-base.d.ts
@@ -41,6 +41,9 @@
 	 * @param view The view to remove from the children array.
 	 */
 	removeChild(view: View): void;
+
+	/** INTERNAL. Commit child order after the layout validates and reorders its native views. */
+	_moveChild(child: View, atIndex: number, moveNative: (children: ReadonlyArray<View>) => void): boolean;
 
 	/**
 	 * Removes all views in this layout.
--- a/packages/core/ui/layouts/stack-layout/index.d.ts
+++ b/packages/core/ui/layouts/stack-layout/index.d.ts
@@ -1,6 +1,7 @@
 import { LayoutBase } from '../layout-base';
 import { Property } from '../../core/properties';
 import { CoreTypes } from '../../../core-types';
+import { View } from '../../core/view';
 
 /**
  * A Layout that arranges its children horizontally or vertically. The direction can be set by orientation property.
@@ -8,6 +9,12 @@
  * @nsView StackLayout
  */
 export class StackLayout extends LayoutBase {
+	/**
+	 * Move an existing direct child to its final index without unloading it.
+	 * Returns false for a no-op. Throws for a foreign child or invalid index.
+	 * Proxy children are not supported by the native reorder implementation.
+	 */
+	moveChild(child: View, atIndex: number): boolean;
 	/**
 	 * Gets or sets if layout should be horizontal or vertical.
 	 * The default value is vertical.
--- a/packages/core/ui/layouts/stack-layout/stack-layout-common.ts
+++ b/packages/core/ui/layouts/stack-layout/stack-layout-common.ts
@@ -1,12 +1,54 @@
 import { StackLayout as StackLayoutDefinition } from '.';
 import { LayoutBase } from '../layout-base';
-import { CSSType } from '../../core/view';
+import { CSSType, View } from '../../core/view';
 import { Property, makeParser, makeValidator } from '../../core/properties';
 import { CoreTypes } from '../../../core-types';
 
 @CSSType('StackLayout')
 export class StackLayoutBase extends LayoutBase implements StackLayoutDefinition {
 	public orientation: CoreTypes.OrientationType;
+
+	public moveChild(child: View, atIndex: number): boolean {
+		return this._moveChild(child, atIndex, (children) => {
+			const nativeParent = this.nativeViewProtected;
+			if (!nativeParent) {
+				return; // Before UI setup, only the logical order exists.
+			}
+			const subviews = __APPLE__ ? nativeParent.subviews : null;
+			const count = __APPLE__ ? subviews.count : nativeParent.getChildCount();
+			const currentNative: any[] = [];
+			for (let i = 0; i < count; i++) {
+				currentNative.push(__APPLE__ ? subviews.objectAtIndex(i) : nativeParent.getChildAt(i));
+			}
+			const orderedNative = children.map((view) => {
+				const nativeView = view.nativeViewProtected;
+				if (!nativeView || !currentNative.includes(nativeView)) {
+					throw new Error('StackLayout.moveChild requires direct native children; proxy children are not supported.');
+				}
+				return nativeView;
+			});
+			const ownedNative = new Set(orderedNative);
+			if (ownedNative.size !== orderedNative.length) {
+				throw new Error('StackLayout.moveChild requires distinct native children.');
+			}
+			// Keep host-owned subviews (such as glass effects) in their slots.
+			let next = 0;
+			const finalNative = currentNative.map((view) => ownedNative.has(view) ? orderedNative[next++] : view);
+			// Public platform APIs reorder existing children without detaching them.
+			// This is O(n) native calls; no remove/add or focus restoration is used.
+			for (const view of finalNative) {
+				if (__APPLE__) {
+					nativeParent.bringSubviewToFront(view);
+					const shadow = view.outerShadowContainerLayer;
+					if (shadow && shadow.superlayer === nativeParent.layer) {
+						nativeParent.layer.insertSublayerBelow(shadow, view.layer);
+					}
+				} else {
+					nativeParent.bringChildToFront(view);
+				}
+			}
+		});
+	}
 }
 
 StackLayoutBase.prototype.recycleNativeView = 'auto';

Validation and limits

Test environment: iPhone 17 simulator, iOS 27.0 (ARM64), NativeScript iOS runtime 9.1.0; API 36 Google APIs ARM64 emulator, Android 16, NativeScript Android runtime 9.1.1; core 9.1.3; CLI 9.1.2; Vite integration 8.0.19.

On each platform, a NativeScript-only acceptance probe passes sixteen scenarios combining forward/backward/repeated/no-op moves, direct inputs/nested inputs, and selected ranges/synthetic composition. It checks immediate and settled editing state, native identity, no extra load/unload events, logical/native/rendered order, adjacent/general sibling styles, debugger node order and native-reference cleanup on deletion. Six additional checks cover unloaded layouts, no-op return values, invalid indices, foreign children, proxy rejection and native-only auxiliary views.

Android's synthetic composition test disables the software keyboard and seeds a composing region through the native InputConnection. An earlier run with the software keyboard active showed it asynchronously clearing synthetic composition even in a no-op control. These tests therefore do not establish real IME continuity. VoiceOver/TalkBack traversal, physical devices, native heap reclamation and other layout types remain unverified.

The changed core TypeScript sources pass a targeted type check against the published declarations and NativeScript platform types. The full NativeScript monorepo test suite has not been run. A package patch was also applied automatically in a clean npm installation and reproduced all eight changed runtime/declaration/source-map files byte-for-byte.

The local Svelte adapter passes all seventeen native integration checks on each platform, including native identity, focus and selection during a keyed move. It uses this operation for same-parent StackLayout moves. No changes to removal semantics or global view reuse are needed.

Existing issues and conduct

I searched the repository for reorder/focus, reusable/focus and moveChild, and reviewed the existing Stack Overflow reorder discussion. I did not find an equivalent same-parent StackLayout capability request; the list-widget reorder issues appear to concern a different API.

I will follow the project's Code of Conduct.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions