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 (animationDuration == 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;
  }
  // 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);
    _notifyStructural(affectedKeys: <TKey>{key});
    return;
  }
  _setExpandedKey(key, true);
  // 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>[];
    for (final nodeId in nodesToShow) {
      if (_isPendingDeletion(nodeId)) continue;
      if (!_order.contains(nodeId)) {
        nodesToInsert.add(nodeId);
      } else {
        _removeAnimation(nodeId);
      }
    }
    if (nodesToInsert.isNotEmpty) {
      final insertIndex = parentIndex + 1;
      _order.insertAllKeys(insertIndex, nodesToInsert);
      _updateIndicesFrom(insertIndex);
    }
    _structureGeneration++;
    _notifyStructural(affectedKeys: <TKey>{key});
    return;
  }

  // Animated expand
  final existingGroup = _operationGroups[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 at the OLD curvedValue) and set
    //      startExtent to that value, targetExtent to full. Reset
    //      the controller to value=0 and forward(); the group
    //      replays each row from `current → full` over the full
    //      configured duration, no jump.
    //   2. Clear pendingRemoval so the dismissed handler doesn't
    //      yank rows.
    //
    // Resetting `controller.value = 0` fires the `dismissed` status
    // synchronously, which the group's status listener would
    // otherwise treat as a real collapse-complete and dispose the
    // group. The listener has an identity guard
    // (`identical(_operationGroups[key], group)`), so we briefly
    // detach the group from `_operationGroups` around the value=0
    // store, let the gated dismissed event fire harmlessly, then
    // re-attach for the actual `forward()` call.
    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;
    }
    _operationGroups.remove(key);
    try {
      existingGroup.controller.value = 0.0;
    } finally {
      _operationGroups[key] = existingGroup;
    }
    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);
      } 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);
      }
    }
    _structureGeneration++;
    _notifyStructural(affectedKeys: <TKey>{key});
    return;
  }

  // Path 2: Fresh expand — create new operation group
  final nodesToShow = _flattenSubtree(key, includeRoot: false);
  final controller = AnimationController(
    vsync: _vsync,
    duration: animationDuration,
    value: 0.0,
  );
  final group = OperationGroup<TKey>(
    controller: controller,
    curve: animationCurve,
    operationKey: key,
  );
  _installOperationGroup(key, group);

  // 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
    int currentInsertIndex = parentIndex + 1;
    int insertOffset = 0;
    int minInsertIndex = _order.length;
    for (final nodeId in nodesToShow) {
      if (_isPendingDeletion(nodeId)) continue;
      final existingIndex = _order.indexOf(nodeId);
      final capturedExtent = _captureAndRemoveFromGroups(nodeId);
      final nge = NodeGroupExtent(
        startExtent: capturedExtent ?? 0.0,
        targetExtent: _fullExtentOf(nodeId) ?? _unknownExtent,
      );
      group.members[nodeId] = nge;
      _setOperationGroup(nodeId, key);

      if (existingIndex != VisibleOrderBuffer.kNotVisible) {
        // Node already visible (was exiting)
        currentInsertIndex = existingIndex + insertOffset + 1;
      } else {
        // Insert at current position
        if (currentInsertIndex < minInsertIndex) {
          minInsertIndex = currentInsertIndex;
        }
        _order.insertKey(currentInsertIndex, nodeId);
        insertOffset++;
        currentInsertIndex++;
      }
    }
    if (insertOffset > 0) {
      for (int i = minInsertIndex; i < _order.length; i++) {
        _order.setIndexByNid(_order.orderNids[i], i);
      }
      _assertIndexConsistency();
    }
  }

  _structureGeneration++;
  controller.forward();
  _ensureStandaloneTickerRunning();
  _notifyStructural(affectedKeys: <TKey>{key});
}