expand method

void expand({
  1. required TKey key,
  2. bool animate = true,
})

Expands the given node, revealing its children.

Implementation

void expand({required TKey key, bool animate = true}) {
  if (_animationStyle.expandCollapse.duration == Duration.zero) {
    animate = false;
  }
  if (!_hasKey(key)) {
    return;
  }
  if (_isExpandedKey(key)) {
    return;
  }
  final children = _childListOf(key);
  if (children == null || children.isEmpty) {
    return;
  }
  // Don't expand if this node is currently exiting
  if (isExiting(key)) {
    return;
  }
  // See [insert] for the rationale: flush any deferred visible-order
  // rebuild so `_order.indexOf(key)` below reads fresh state.
  _ensureVisibleOrder();
  // If any descendant about to (re)enter visible order currently has
  // a live exit-slide/ghost, stage a slide baseline FIRST (first-wins),
  // capturing each row's pre-expand painted position. Must precede
  // `_setExpandedKey`/op-group install so the baseline is the FLIP "before";
  // the downstream `_notifyStructural` consume composes the rebase against
  // the row's NEW structural offset, preserving painted position across the
  // base change. The O(1) gate inside the helper makes idle expands
  // allocation-free: the thunk is never invoked, so the descendant walk
  // never runs.
  //
  // Use the FULL structural descendant set ([getDescendants]) rather than an
  // expansion-gated flatten: at this point [key] is still collapsed
  // (`_setExpandedKey(key, true)` runs below), so an expansion-gated walk of
  // [key] would return empty and miss the very exit-ghost row whose base is
  // about to change. [getDescendants] walks the structural subtree
  // regardless of expansion state, so the moved exit-ghost (a structural
  // child of [key]) is included.
  _stageSlideBaselineForBaseChange(() => getDescendants(key));
  // If ancestors are collapsed, just record the expansion state.
  // The node is not visible, so there is nothing to animate or
  // insert into the visible order. When ancestors are later expanded,
  // this node's children will appear immediately.
  if (!_ancestorsExpandedFast(key)) {
    _setExpandedKey(key, true);
    _recordExpansionChange(key, wasExpanded: false);
    _notifyStructural(affectedKeys: <TKey>{key});
    return;
  }
  _setExpandedKey(key, true);
  _recordExpansionChange(key, wasExpanded: false);
  // Find where to insert children in visible order
  final parentIndex = _order.indexOf(key);
  if (parentIndex == VisibleOrderBuffer.kNotVisible) {
    return;
  }

  if (!animate) {
    // No animation: insert and return
    final nodesToShow = _flattenSubtree(key, includeRoot: false);
    final nodesToInsert = <TKey>[];
    int alreadyVisibleCount = 0;
    for (final nodeId in nodesToShow) {
      if (_isPendingDeletion(nodeId)) continue;
      if (!_order.contains(nodeId)) {
        nodesToInsert.add(nodeId);
      } else {
        alreadyVisibleCount++;
        _removeAnimation(nodeId);
      }
    }
    if (alreadyVisibleCount == 0) {
      // Every member is new: one memmove, the dominant case.
      if (nodesToInsert.isNotEmpty) {
        final insertIndex = parentIndex + 1;
        _order.insertAllKeys(insertIndex, nodesToInsert);
        _updateIndicesFrom(insertIndex);
      }
    } else {
      // Mixed: members of an in-flight collapse still hold their order
      // slots, because the group's `pendingRemoval` is not consumed
      // until its dismissed handler runs. A block insert at
      // parentIndex + 1 would place the new rows AHEAD of their own
      // earlier siblings, and `_removeAnimation` above cannot correct
      // it: it lands in AnimationCoordinator, which cannot see _order.
      _spliceIntoVisibleOrderAfter(parentIndex, nodesToShow);
    }
    _structureGeneration++;
    _notifyStructural(affectedKeys: <TKey>{key});
    return;
  }

  // Animated expand
  final existingGroup = _opGroupAt(key);
  if (existingGroup != null) {
    // Path 1: Reversing a collapse: group already exists.
    //
    // The op-group's controller is the shared timing primitive;
    // each member's NodeGroupExtent envelope (start/target) defines
    // what it animates between. To reverse the collapse smoothly:
    //   1. Rebase each member: capture its current visual extent under
    //      the OLD envelope, set startExtent = current, targetExtent =
    //      full. Reset value=0 and forward().
    //   2. Clear pendingRemoval so the dismissed handler doesn't yank.
    //
    // Resetting controller.value=0 fires a synchronous dismissed event;
    // the registry's install-time identity guard ignores it if the
    // group has been briefly detached. runWithGroupDetached encapsulates
    // the detach/reattach pattern.
    if (existingGroup.pendingRemoval.isNotEmpty) {
      existingGroup.pendingRemoval.clear();
      _bumpAnimGen();
    }
    final preReversalCurvedValue = existingGroup.curvedValue;
    for (final entry in existingGroup.members.entries) {
      final full = _fullExtentOf(entry.key) ?? defaultExtent;
      final currentExtent = entry.value.computeExtent(
        preReversalCurvedValue,
        full,
      );
      entry.value.startExtent = currentExtent;
      entry.value.targetExtent = full;
      // `full` is a natural reference (the measurement, or the default
      // guess for an unmeasured row), not a capture: leave setFullExtent
      // free to re-target it on a resize or on the first measurement.
      // Unconditional, because the collapse-side reversals set the flag
      // and this member may be arriving from one; a flag left set would
      // hold a stale terminus for the whole animation.
      entry.value.targetIsCaptured = false;
    }
    _anim.opGroups.runWithGroupDetached(key, (group) {
      group.controller.value = 0.0;
    });
    existingGroup.controller.forward();

    // Handle descendants NOT in this group (from nested expansions)
    final nodesToShow = _flattenSubtree(key, includeRoot: false);
    for (final nodeId in nodesToShow) {
      if (_isPendingDeletion(nodeId)) continue;
      if (existingGroup.members.containsKey(nodeId)) continue;

      if (_standaloneAt(nodeId) case final anim?
          when anim.type == AnimationType.exiting) {
        // Reverse the exit to an enter with speedMultiplier
        _startStandaloneEnterAnimation(
          nodeId,
          family: TreeAnimationFamily.expandCollapse,
        );
      } else if (!_order.contains(nodeId)) {
        // New node not yet visible: insert at correct sibling position
        // and animate. _insertNodeIntoVisibleOrder appends at the end of
        // the grandparent's subtree, which drops the node past its
        // following siblings when they are already in the visible order.
        _insertNewNodeAmongSiblings(nodeId);
        _startStandaloneEnterAnimation(
          nodeId,
          family: TreeAnimationFamily.expandCollapse,
        );
      }
    }
    _structureGeneration++;
    _notifyStructural(affectedKeys: <TKey>{key});
    return;
  }

  // Path 2: Fresh expand: create new operation group via the
  // OperationGroupRegistry's install API (constructs the controller +
  // OperationGroup internally and wires the tick + status callbacks).
  final nodesToShow = _flattenSubtree(key, includeRoot: false);
  final group = _anim.opGroups.install(
    key,
    _animationStyle.expandCollapse.curve,
  );
  _bumpAnimGen(); // invalidate animation-generation-keyed caches

  // Fast path check: count new vs existing nodes
  int newNodeCount = 0;
  int effectiveCount = 0;
  for (final nodeId in nodesToShow) {
    if (_isPendingDeletion(nodeId)) continue;
    effectiveCount++;
    if (!_order.contains(nodeId)) {
      newNodeCount++;
    }
  }

  // Path 2 expand: each newly-affected descendant joins the group as
  // a member with a NodeGroupExtent envelope. Per-node animation
  // records are NOT created on a fresh op, they only come into
  // existence on CAPTURE (a prior op's mid-flight node being pulled
  // into a new op). The op-group's controller is the shared timing
  // primitive for non-captured members; private records, when they
  // exist on captured members, carry the captured node's own clock.
  if (newNodeCount == 0) {
    // All nodes already visible (reversing collapse animation)
    for (final nodeId in nodesToShow) {
      if (_isPendingDeletion(nodeId)) continue;
      final capturedExtent = _captureAndRemoveFromGroups(nodeId);
      final nge = NodeGroupExtent(
        startExtent: capturedExtent ?? 0.0,
        targetExtent: _fullExtentOf(nodeId) ?? _unknownExtent,
      );
      group.members[nodeId] = nge;
      _setOperationGroup(nodeId, key);
    }
  } else if (newNodeCount == effectiveCount) {
    // All nodes need insertion (normal expand)
    final nodesToInsert = <TKey>[];
    for (final nodeId in nodesToShow) {
      if (_isPendingDeletion(nodeId)) continue;
      final capturedExtent = _captureAndRemoveFromGroups(nodeId);
      final nge = NodeGroupExtent(
        startExtent: capturedExtent ?? 0.0,
        targetExtent: _fullExtentOf(nodeId) ?? _unknownExtent,
      );
      group.members[nodeId] = nge;
      _setOperationGroup(nodeId, key);
      nodesToInsert.add(nodeId);
    }
    final insertIndex = parentIndex + 1;
    _order.insertAllKeys(insertIndex, nodesToInsert);
    _updateIndicesFrom(insertIndex);
  } else {
    // Mixed path: some visible (exiting), some need insertion. Shares
    // one helper with the non-animated branch, which had the same
    // protocol and got the placement wrong. The helper also replaces k
    // separate `insertKey` calls (each memmoving the whole suffix,
    // O(k * suffix)) with one backward merge, O(suffix + k).
    //
    // The read order is preserved: this body captured
    // `_order.indexOf(nodeId)` BEFORE `_captureAndRemoveFromGroups`,
    // and the helper likewise calls `onVisit` after reading the index.
    _spliceIntoVisibleOrderAfter(
      parentIndex,
      nodesToShow,
      onVisit: (nodeId, _) {
        final capturedExtent = _captureAndRemoveFromGroups(nodeId);
        group.members[nodeId] = NodeGroupExtent(
          startExtent: capturedExtent ?? 0.0,
          targetExtent: _fullExtentOf(nodeId) ?? _unknownExtent,
        );
        _setOperationGroup(nodeId, key);
      },
    );
  }

  _structureGeneration++;
  // Every descendant may have been filtered out of the three member
  // loops above by `_isPendingDeletion`; see
  // [_abandonEmptyOperationGroup]. Expand adds no `pendingRemoval`
  // entries (all such adds are in [collapse]'s two paths or
  // [collapseAll]), so the check here is about members alone.
  if (!_abandonEmptyOperationGroup(key, group)) {
    group.controller.forward();
  }
  // Path 2 creates no standalone states of its own, only keep the
  // standalone ticker alive when states from other sources exist. An
  // ungated start costs one wasted start/stop frame per operation.
  if (_anim.standalone.hasAny) {
    _anim.standalone.ensureRunning();
  }
  _notifyStructural(affectedKeys: <TKey>{key});
}