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.
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. 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 controller's animationDuration is Duration.zero (the
engine no-ops in that case to honor the global animation-disabled
setting).
Implementation
void moveNode(
TKey key,
TKey? newParentKey, {
int? index,
bool animate = false,
Duration slideDuration = const Duration(milliseconds: 220),
Curve slideCurve = Curves.easeOutCubic,
}) {
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).
if (newParentKey != null && _getDescendants(key).contains(newParentKey)) {
throw StateError(
"Cannot move $key under its own descendant $newParentKey",
);
}
// 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], fall through so the
// node is repositioned among its existing siblings.
//
// CRITICAL: this no-op return MUST precede the animate staging below.
// Otherwise an animated no-op call would stage a baseline (via
// _stageSlideBaselineOnHosts → 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 && index == null) 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);
// 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.
if (animate) {
_stageSlideBaselineOnHosts(
duration: slideDuration,
curve: slideCurve,
);
// Phantom-anchor for collapsed → 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) {
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 _flattenSubtree(key, includeRoot: true)) {
_pendingPhantomAnchors![k] = cursor;
}
}
}
}
// Snapshot state needed to compute precise affected-keys after the move.
final oldDepth = _depthOfKey(key);
final newParentWasEmpty = newParentKey != null
? (_childListOf(newParentKey)?.isEmpty ?? true)
: false;
// 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
// `_applyClampAndInstallNewGhosts`. Composition correctly 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).
_setParentKey(key, newParentKey);
final node = _dataOf(key)!;
if (newParentKey != null) {
final siblings = _childListOrCreate(newParentKey);
final effectiveIndex =
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 ?? (comparator != null ? _sortedIndex(_roots, node) : null);
if (effectiveIndex != null && effectiveIndex < _roots.length) {
_roots.insert(effectiveIndex, key);
} else {
_roots.add(key);
}
}
final newDepth = newParentKey != null ? (_depthOfKey(newParentKey)) + 1 : 0;
_refreshSubtreeDepths(key, newDepth);
_markVisibleOrderDirty();
// Exit-phantom for visible → 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 && 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 _flattenSubtree(key, includeRoot: true)) {
_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. Use _flattenSubtree so we enumerate the currently-expanded
// rows (the only ones that can be mounted).
if (newDepth != oldDepth) {
affected.addAll(_flattenSubtree(key, includeRoot: true));
}
// Old parent may have just lost its last child (hasChildren true → false).
if (oldParent != null) {
final siblings = _childListOf(oldParent);
if (siblings == null || siblings.isEmpty) {
affected.add(oldParent);
}
}
// New parent may have just gained its first child (hasChildren false → true).
if (newParentKey != null && newParentWasEmpty) {
affected.add(newParentKey);
}
_notifyStructural(affectedKeys: affected);
}