moveNode method

void moveNode(
  1. TKey key,
  2. TKey? newParentKey, {
  3. int? index,
  4. bool animate = true,
  5. Duration? slideDuration,
  6. Curve? slideCurve,
})

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, exit-anchor, and affected-keys
  // consumers below all 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
  // three fire instead of up to three full walks per move.
  List<TKey>? movedSubtreeScratch;
  List<TKey> movedSubtree() {
    return movedSubtreeScratch ??= _flattenSubtree(key, includeRoot: true);
  }

  // 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. The shared flatten enumerates the currently-expanded rows
  // (the only ones that can be mounted).
  if (newDepth != oldDepth) {
    affected.addAll(movedSubtree());
  }
  // Both parents' child-list lengths changed (and hasChildren may have
  // flipped on either), and both sibling lists shifted around the move.
  affected.addAll(_siblingRefreshSet(oldParent));
  affected.addAll(_siblingRefreshSet(newParentKey));
  _notifyStructural(affectedKeys: affected);
}