moveNode method
Moves a node from its current parent to newParentKey.
If newParentKey is null, the node becomes a root. If index is
provided, the node is inserted at that position among its new siblings;
otherwise it is appended. index is the position among live
siblings, exiting (pending-deletion) siblings are skipped, matching
getIndexInParent / getLiveChildren and the space
TreeReorderController computes drop indices in.
The node's subtree (children, expansion state, and measured extents) is preserved. Any in-flight enter/exit animations on the moved subtree are cancelled so a mid-exit node isn't purged at its new location when the animation finalizes.
When animate is true, every visible row whose painted position changes
as a result of the move (the moved subtree itself plus any siblings
that shift to make room) gets a FLIP slide that lerps from its
pre-move painted position to its post-move structural position over
slideDuration using slideCurve. Both default to the
controller's animationStyle reorderSlide spec when omitted.
The slide is paint-only: layout settles immediately at the new
structural positions.
Same-frame composition: multiple animated moveNode calls in the
same synchronous block (or inside the same runBatch) coalesce under
a first-wins baseline policy, the first call's pre-mutation snapshot
covers every visible row, and subsequent calls' deltas are computed
relative to that single baseline. The first call's slideDuration
and slideCurve win for the cohesive transition. If the batch
contains both animate: true and animate: false mutations, all
mutations effectively animate (the baseline captures everything; any
row whose final position differs from its baseline gets a slide).
Has no effect when no SliverTree is mounted on this controller, or
when the style's reorderSlide duration is Duration.zero (the
engine no-ops in that case to honor the global animation-disabled
setting).
Throws a StateError: in all build modes, not just debug, if
key is newParentKey, if newParentKey is a descendant of key
(either would form a cycle), or if newParentKey is pending
deletion (it will be purged when its exit animation completes,
orphaning the moved subtree). Callers driving this from a gesture,
such as TreeReorderController, must re-validate against current
tree state before committing.
Implementation
void moveNode(
TKey key,
TKey? newParentKey, {
int? index,
bool animate = true,
Duration? slideDuration,
Curve? slideCurve,
}) {
assert(_hasKey(key), 'Node $key not found');
assert(
newParentKey == null || _hasKey(newParentKey),
'New parent $newParentKey not found',
);
// Self-reparent would build a cycle in _childListOf(key) and stack-overflow
// _refreshSubtreeDepths. Guard at runtime so release builds don't crash.
if (newParentKey != null && newParentKey == key) {
throw StateError("Cannot move $key onto itself");
}
// Reparenting under a descendant would form a cycle; check at runtime
// (release builds skip the assert below). O(depth) ancestor walk from
// the new parent, materializing every descendant
// (`_getDescendants(key).contains(...)`) cost O(subtree) time and
// allocation per move.
if (newParentKey != null) {
TKey? cycleCursor = newParentKey;
while (cycleCursor != null) {
if (cycleCursor == key) {
throw StateError(
"Cannot move $key under its own descendant $newParentKey",
);
}
cycleCursor = _parentKeyOfKey(cycleCursor);
}
}
// Reparenting under a pending-deletion node would orphan the moved
// subtree when the new parent's exit animation finalizes:
// `_finalizeAnimation` only purges descendants that are themselves
// pending-deletion, so a non-pending child is left behind with a stale
// `parentKey` pointing at a freed nid, and the grandparent's
// visible-subtree-size cache is decremented for a row that still
// exists. Mirror the policy `insert(parentKey:)` already enforces.
// Runtime check (not just an assert) so release builds also reject
// this rather than silently corrupting state.
if (newParentKey != null && _isPendingDeletion(newParentKey)) {
throw StateError(
"Cannot move $key under $newParentKey while $newParentKey is "
"animating out (pending deletion). The parent will be purged when "
"its exit animation completes, leaving the moved subtree orphaned.",
);
}
final oldParent = _parentKeyOfKey(key);
// If already under the target parent and no explicit position was
// requested, nothing to do. With an explicit [index] that matches the
// node's current position under the same parent, also a no-op,
// avoid wasted baseline staging + structural notification + slide
// composition for a mutation that produces zero visual change.
//
// CRITICAL: this no-op return MUST precede the animate staging below.
// Otherwise an animated no-op call would stage a baseline (via
// _stageSlideBaselineOnHosts to beginSlideBaseline) that triggers no
// layout (no _notifyStructural fires for a no-op), leaving the
// _pendingSlideBaseline stuck and blocking all subsequent stages
// under first-wins until something else triggers a layout.
if (oldParent == newParentKey) {
if (index == null) return;
// Compare against the LIVE index (excluding pending-deletion
// siblings). getIndexInParent returns -1 only when the key is
// unknown or pending-deletion, neither applies here, so the
// value is the current live index.
if (index == getIndexInParent(key)) return;
}
// Capture pre-mutation visibility so we can decide entry vs exit
// phantom paths after the visible-order rebuild runs.
//
// Use the structural predicate ([_ancestorsExpandedFast]) instead of
// [_order.contains] because, inside [runBatch] with deferred rebuilds,
// [_order] still reflects state at batch entry, any prior in-batch
// mutation that changed this key's visibility hasn't been flushed yet.
// The structural predicate reads parent-chain expansion which is
// eagerly maintained on every [_setParentKey] / [_setExpandedKey],
// so it's correct regardless of [_order] freshness. The predicate
// also gives the desired user-facing answer for the rare "key is in
// [_order] only because it has an active animation under a collapsed
// ancestor" case, the user sees a collapsed parent, no row visible,
// so an entry-phantom path is the right choice.
final wasVisible = animate && _isStructurallyVisible(key);
// Lazily computed, shared expansion-gated flatten of the moved
// subtree. The phantom-anchor and exit-anchor consumers below need
// the identical set, the
// subtree's INTERNAL structure (children lists, expansion flags) is
// invariant across the move; only key's parent pointer and the
// subtree's depths change, so one walk serves whichever of the
// two fire instead of up to two full walks per move.
List<TKey>? movedSubtreeScratch;
List<TKey> movedSubtree() {
return movedSubtreeScratch ??= _flattenSubtree(key, includeRoot: true);
}
// Structural counterpart for the depth-change affected-keys branch:
// enumerates the whole subtree regardless of expansion. Separate
// memo; do not merge with [movedSubtree], the two walks answer
// different questions (which rows WERE VISIBLE for the anchor sites,
// which rows COULD STILL BE MOUNTED for the depth branch below).
List<TKey>? movedSubtreeStructuralScratch;
List<TKey> movedSubtreeStructural() {
return movedSubtreeStructuralScratch ??= <TKey>[
key,
...getDescendants(key),
];
}
// First-wins staging fan-out. Every attached sliver render object's
// beginSlideBaseline is invoked. Inside runBatch (or for adjacent
// same-frame moveNode calls), the first such call wins; subsequent
// calls no-op at the host level. The single staged baseline is
// consumed by the next layout post-mutation.
//
// The participation result gates the phantom-anchor staging below
// (and the exit-anchor staging further down): with no participating
// host, nothing ever drains the anchor maps, and a LATER animated
// mutation's consume would apply anchors recorded for this
// long-finished move to the wrong slide cycle.
bool hostParticipating = false;
if (animate) {
// Null timing resolves to the style's reorderSlide spec. Zero
// durations are killed downstream by the slide engine's disabled
// gate, not here, hostParticipating has anchor-draining side
// effects that must match today's master-zero shape.
hostParticipating = _stageSlideBaselineOnHosts(
duration: slideDuration ?? _animationStyle.reorderSlide.duration,
curve: slideCurve ?? _animationStyle.reorderSlide.curve,
);
// Phantom-anchor for collapsed-to-visible reparenting:
// If the moved subtree's root is currently NOT in the visible order
// (because the old parent or an ancestor is collapsed), the staged
// baseline contains no entry for it, animateFromOffsets would skip
// installing a slide and the row would pop instantly into its new
// visible position. Walk up the OLD parent chain to find the
// deepest visible ancestor (the row the user actually sees with
// the chevron) and record it as the phantom anchor for every node
// in the moved subtree. The render object resolves these to
// painted positions during baseline consumption, anchor's painted
// position when it's on-screen, viewport edge otherwise, so the
// emerging row visually slides "out from behind" its old parent.
if (!wasVisible && hostParticipating) {
TKey? cursor = _parentKeyOfKey(key);
while (cursor != null && !_isStructurallyVisible(cursor)) {
cursor = _parentKeyOfKey(cursor);
}
if (cursor != null) {
_pendingPhantomAnchors ??= <TKey, TKey>{};
// Apply the same anchor to the entire moved subtree, children
// inherit the parent's anchor since they were all hidden inside
// the same collapsed ancestor.
for (final k in movedSubtree()) {
_pendingPhantomAnchors![k] = cursor;
}
}
}
}
// Snapshot state needed to compute precise affected-keys after the move.
final oldDepth = _depthOfKey(key);
// Cancel any animation/deletion state tied to the moved subtree's old
// position. Without this, a node caught mid-exit-animation would still
// be purged by _finalizeAnimation after the move, destroying the subtree
// under its new parent.
//
// For the animate=true path we keep any in-flight FLIP slide entries
// alive: the staged baseline above captured each row's mid-flight
// painted position (structural + currentDelta), and the next consume's
// composition path requires reading the still-live slideY in the
// post-mutation snapshot to recognize the row as having an active
// slide and avoid the both-off-screen suppression guard inside
// `GhostRegistry.applyClampAndInstallNewGhosts`. Composition absorbs the
// structural shift into the new currentDelta, no double-counting.
_cancelAnimationStateForSubtree(key, cancelSlides: !animate);
// Remove from old parent's child list (or roots).
if (oldParent != null) {
_childListOf(oldParent)?.remove(key);
} else {
_roots.remove(key);
}
// Insert into new parent's child list (or roots). Explicit index is
// live-space (matching the live-space no-op guard above); the
// comparator path already returns a full-space position.
_setParentKey(key, newParentKey);
final node = _dataOf(key)!;
if (newParentKey != null) {
final siblings = _childListOrCreate(newParentKey);
final effectiveIndex = index != null
? _liveIndexToFullInsertIndex(siblings, index)
: (comparator != null ? _sortedIndex(siblings, node) : null);
if (effectiveIndex != null && effectiveIndex < siblings.length) {
siblings.insert(effectiveIndex, key);
} else {
siblings.add(key);
}
} else {
final effectiveIndex = index != null
? _liveIndexToFullInsertIndex(_roots, index)
: (comparator != null ? _sortedIndex(_roots, node) : null);
if (effectiveIndex != null && effectiveIndex < _roots.length) {
_roots.insert(effectiveIndex, key);
} else {
_roots.add(key);
}
}
// Raw sibling-list writes above (the unlink AND the re-insert; a
// comparator read between them refreshed against the half-mutated
// list, and this bump is what discards that refresh); invalidate
// cached live indices.
_liveIndexCache.bump();
final newDepth = newParentKey != null ? (_depthOfKey(newParentKey)) + 1 : 0;
_refreshSubtreeDepths(key, newDepth);
_markVisibleOrderDirty();
// Phase B of the deferred pending-deletion handling started in
// `_cancelAnimationStateForSubtree`. Now that ancestry reflects the
// new parent chain, revert any pending-deletion members of the moved
// subtree using post-mutation `_ancestorsExpandedFast`:
// - exit reverses to enter where the new chain is expanded (visible
// row regrows from current extent), composing with the staged
// slide baseline so the row simultaneously lerps Y/X to its new
// painted position;
// - exit continues to extent 0 where the new chain is collapsed,
// with pending-deletion cleared so finalize skips the purge.
// No-op when the subtree contains no pending-deletion members (the
// common case).
if (animate) {
_revertSubtreeFromPendingDeletion(key);
}
// Exit-phantom for visible-to-hidden reparenting:
// Symmetric to the entry-phantom block above. If the moved subtree
// was visible BEFORE mutation but is hidden AFTER (because the new
// parent or an ancestor is collapsed), the staged baseline has the
// moved row at its OLD position but the post-mutation snapshot has
// no entry for it (it's not in visibleNodes). animateFromOffsets
// would skip installing a slide and the row would pop out of
// existence. Walk the NEW parent chain to find the deepest visible
// ancestor (typically the new collapsed parent's row); record it
// as the exit anchor for every node in the moved subtree. The
// render object will inject the anchor's painted position as the
// slide DESTINATION, retain a ghost render box, and paint the
// sliding row clipped to "outside the anchor" so it visually
// disappears INTO the new parent.
if (animate &&
hostParticipating &&
wasVisible &&
!_isStructurallyVisible(key)) {
TKey? cursor = newParentKey;
while (cursor != null && !_isStructurallyVisible(cursor)) {
cursor = _parentKeyOfKey(cursor);
}
if (cursor != null) {
_pendingExitPhantomAnchors ??= <TKey, TKey>{};
// Every node that was in the visible OLD subtree shares the same
// exit anchor. Use the OLD-subtree flatten by enumerating from
// baseline keys is impractical here; instead, flatten the now-
// structural subtree (children list still intact post-move),
// the moved subtree's expanded structure is preserved through
// moveNode, so the same set of nodes was visible before and is
// hidden after.
for (final k in movedSubtree()) {
_pendingExitPhantomAnchors![k] = cursor;
}
}
}
final affected = <TKey>{};
// If the moved subtree's depth changed, every row in it must rebuild:
// nodeBuilder receives `depth` as an argument and indentation scales
// with it.
if (newDepth != oldDepth) {
// Structural, not expansion-gated: a row collapsed earlier in the
// same handler is still mounted (removeChild is a no-op in the
// element and eviction is post-frame), and createChild never
// rebuilds a mounted row that is not dirty, so an expansion-gated
// set leaves it rendering its pre-move depth. Same reason expand()
// uses getDescendants for its base-change staging.
affected.addAll(movedSubtreeStructural());
}
// Both parents' child-list lengths changed (and hasChildren may have
// flipped on either), and both sibling lists shifted around the move.
affected.addAll(_siblingRefreshOrToken(oldParent));
affected.addAll(_siblingRefreshOrToken(newParentKey));
_notifyStructural(affectedKeys: affected);
}