collapseAll method

void collapseAll({
  1. bool animate = true,
  2. int? maxDepth,
})

Collapses all nodes in the tree.

Uses batch operations for better performance with large trees.

Implementation

void collapseAll({bool animate = true, int? maxDepth}) {
  if (_animationStyle.expandCollapse.duration == Duration.zero) {
    animate = false;
  }
  // Flush any pending visible-order rebuild from a prior in-batch mutator.
  // `_getVisibleDescendants` below reads `_order.contains`; inside a
  // [runBatch] the order still reflects state at batch entry, so a node
  // made visible by an earlier in-batch mutation would be missed,
  // never joining the bulk group and popping out in one frame. Same
  // pattern as [insert] / [expand] / [collapse].
  _ensureVisibleOrder();
  // Collect all expanded nodes and their visible descendants
  final nodesToCollapse = <TKey>[];
  final nodesToHide = <TKey>[];
  for (final rootId in _roots) {
    if (_isExpandedKey(rootId)) {
      nodesToCollapse.add(rootId);
      nodesToHide.addAll(_getVisibleDescendants(rootId));
    }
  }
  // Also check for nodes that are entering (from an interrupted expandAll)
  final nodesToHideSet = nodesToHide.toSet();

  // Check standalone entering animations
  for (final nid in _anim.standalone.activeNids) {
    final state = _anim.standalone.slotAtNid(nid)!;
    if (state.type != AnimationType.entering) continue;
    final key = _nids.keyOfUnchecked(nid);
    if (nodesToHideSet.contains(key)) continue;
    if (_parentKeyOfKey(key) != null) {
      nodesToHide.add(key);
      nodesToHideSet.add(key);
    }
  }

  // Check operation group members (expanding)
  for (final entry in _opGroupEntries) {
    final group = entry.value;
    // (loop body uses `group`)
    if (group.pendingRemoval.isEmpty) {
      // Group is expanding
      for (final key in group.members.keys) {
        if (!nodesToHideSet.contains(key)) {
          if (_parentKeyOfKey(key) != null) {
            nodesToHide.add(key);
            nodesToHideSet.add(key);
          }
        }
      }
    }
  }

  // Check bulk group members (expanding nodes)
  if (_activeBulkGroup != null) {
    for (final key in _activeBulkGroup!.members) {
      if (!nodesToHideSet.contains(key)) {
        if (_parentKeyOfKey(key) != null) {
          nodesToHide.add(key);
          nodesToHideSet.add(key);
        }
      }
    }
  }

  if (nodesToHide.isEmpty) {
    if (nodesToCollapse.isNotEmpty) {
      _recordExpansionChanges(
        _collapseAllInRegistry(maxDepth),
        wasExpanded: true,
      );
      // Bulk expansion-state clear: see main collapseAll branch below.
      _notifyStructural();
    }
    return;
  }
  // Clear expansion state for ALL nodes within depth limit,
  // not just visible ones.
  _recordExpansionChanges(
    _collapseAllInRegistry(maxDepth),
    wasExpanded: true,
  );
  // The harvest above is depth-blind; the flips just applied are not.
  // Keep only the rows that actually became invisible, so every branch
  // below (including the non-animated removal) is a no-op for rows a
  // depth limit left visible. The ancestors-expanded cache is post-flip
  // here: _collapseAllInRegistry rebuilds it itself.
  nodesToHide.retainWhere((k) => !_ancestorsExpandedFast(k));
  _structureGeneration++;
  if (animate) {
    // Reverse expanding operation groups. Snapshot before iterating: a
    // group's controller already at lowerBound would fire `dismissed`
    // synchronously inside `reverse()`, removing itself from `_groups`
    // mid-iteration. See [_opGroupSnapshot] docs.
    bool opGroupReversed = false;
    _opGroupSnapshot
      ..clear()
      ..addAll(_opGroupEntries);
    for (final entry in _opGroupSnapshot) {
      final group = entry.value;
      if (group.pendingRemoval.isEmpty) {
        _partitionByPostFlipVisibility(
          group.members.keys,
          _partitionVisibleScratch,
          _partitionHiddenScratch,
        );
        if (_partitionHiddenScratch.isEmpty) {
          // Nothing under this group became invisible: the group is
          // already animating in the right direction and this call has
          // nothing to say about it. Tearing it apart into per-member
          // standalone enters would dispose a live group and produce a
          // visible timing discontinuity for a call that flipped
          // nothing. No re-pend, no rebase, no reset, no reverse().
          continue;
        }
        // Capture the curved value BEFORE any write; the displacement
        // and the rebase both read it.
        final preReversalCurvedValue = group.curvedValue;
        // Re-pend FIRST, so pendingRemoval is non-empty before any
        // detach and disposeIfEmpty stays hard-blocked during the
        // displacement below.
        for (final nodeId in _partitionHiddenScratch) {
          if (!_isPendingDeletion(nodeId)) {
            group.pendingRemoval.add(nodeId);
            opGroupReversed = true;
          }
        }
        // Displace the post-flip VISIBLE members to standalone enters,
        // so those rows finish growing on their own timeline instead of
        // being dragged to zero by the group's reverse(). Must precede
        // the rebase and the reset: the capture inside
        // _startStandaloneEnterAnimation reads the endpoints the rebase
        // overwrites and the curved value the reset moves.
        for (final key in _partitionVisibleScratch) {
          _startStandaloneEnterAnimation(
            key,
            family: TreeAnimationFamily.expandCollapse,
          );
        }
        // Path-1 mirror rebase (collapse Path 1) over the SURVIVORS:
        // capture each member's CURRENT painted extent under the OLD
        // envelope, make it the target, re-anchor start to zero, and
        // reset the controller so computeExtent at value 1 reproduces
        // exactly that extent. Normalizing startExtent alone pops by
        // startExtent * (1 - cv).
        for (final member in group.members.entries) {
          // COMPUTE FIRST, into a local, BEFORE either write:
          // computeExtent READS startExtent, so zeroing it first would
          // capture lerp(0, oldTarget, cv) instead of the pre-reversal
          // painted extent. targetExtent IS a genuine capture here,
          // which is what targetIsCaptured exists to protect, so the
          // flag is set beside it: without it, setFullExtent re-targets
          // the member onto the row's next differing measurement and
          // the collapsing row pops up. The targetExtent write is also
          // what clears an incoming sentinel out of the record before
          // the reset to 1.0 could read it (a surviving sentinel would
          // paint the whole default row for a frame).
          final currentExtent = member.value.computeExtent(
            preReversalCurvedValue,
            _fullExtentOf(member.key) ?? defaultExtent,
          );
          member.value.startExtent = 0.0;
          member.value.targetExtent = currentExtent;
          member.value.targetIsCaptured = true;
        }
        _anim.opGroups.runWithGroupDetached(entry.key, (g) {
          g.controller.value = 1.0;
        });
        group.controller.reverse();
      }
    }
    _opGroupSnapshot.clear();
    if (opGroupReversed) _bumpAnimGen();

    // Check if there's an expanding bulk animation we can reverse
    if (_activeBulkGroup != null &&
        _activeBulkGroup!.members.isNotEmpty &&
        _activeBulkGroup!.pendingRemoval.isEmpty) {
      // Partition the members by post-flip visibility: a member the
      // depth limit left visible must not be dragged to zero and popped
      // back by the group's reverse().
      _partitionByPostFlipVisibility(
        _activeBulkGroup!.members,
        _partitionVisibleScratch,
        _partitionHiddenScratch,
      );
      if (_partitionHiddenScratch.isNotEmpty) {
        // Mark the hidden members for removal when the animation
        // completes at 0.
        for (final key in _partitionHiddenScratch) {
          if (!_isPendingDeletion(key)) {
            _addBulkPending(key);
          }
        }
        // Displace still-visible members to standalone enters so they
        // finish growing on their own timeline. Captures full *
        // bulk.value and removes the member from the group.
        for (final key in _partitionVisibleScratch) {
          _startStandaloneEnterAnimation(
            key,
            family: TreeAnimationFamily.expandCollapse,
          );
        }
        // Reverse the controller direction
        _activeBulkGroup!.controller.reverse();
        _bumpBulkGen();
      }
      // else: nothing under the group became invisible; leave it
      // expanding untouched.

      // Handle additional nodes not in any group. Runs in both cases:
      // it serves nodes that are NOT bulk members, and is a no-op once
      // nodesToHide has been filtered to the post-flip-hidden rows.
      for (final key in nodesToHide) {
        if (_isPendingDeletion(key)) continue;
        if (!_activeBulkGroup!.members.contains(key) &&
            !_hasOperationGroup(key)) {
          _startStandaloneExitAnimation(
            key,
            family: TreeAnimationFamily.expandCollapse,
          );
        }
      }
    } else if (_activeBulkGroup != null &&
        _activeBulkGroup!.pendingRemoval.isNotEmpty) {
      // Continuation: a bulk collapse is already mid-flight. Keep the
      // group, existing members continue from their current extent.
      // Creating a fresh group at value 1.0 here (e.g. on a double-tap
      // of a "collapse all" button) would dispose the in-flight one and
      // snap every half-collapsed row back to full extent for a frame.
      //
      // Genuinely NEW nodes must NOT join the mid-flight group (they
      // would jump straight to `full * currentValue`); route them
      // through standalone exit animations instead.
      for (final key in nodesToHide) {
        if (_isPendingDeletion(key)) continue;
        if (_hasOperationGroup(key)) continue;
        if (_activeBulkGroup!.members.contains(key) ||
            _activeBulkGroup!.pendingRemoval.contains(key)) {
          continue;
        }
        final st = _standaloneAt(key);
        if (st != null && st.type == AnimationType.exiting) {
          continue;
        }
        _startStandaloneExitAnimation(
          key,
          family: TreeAnimationFamily.expandCollapse,
        );
      }
      _bumpBulkGen();
    } else {
      // Create fresh group via the BulkAnimator with value=1.0 (collapse
      // starts fully expanded and reverses toward 0).
      _anim.bulk.createGroup(
        _animationStyle.expandCollapse.duration,
        _animationStyle.expandCollapse.curve,
        initialValue: 1.0,
      );
      _bumpBulkGen();

      // Add nodes to the bulk group, keeping individually-animating
      // nodes on their own timeline for smooth transitions.
      for (final key in nodesToHide) {
        if (_isPendingDeletion(key)) continue;
        if (_hasOperationGroup(key)) continue;
        if (_hasStandalone(key)) {
          // Reverse standalone animation smoothly
          _startStandaloneExitAnimation(
            key,
            family: TreeAnimationFamily.expandCollapse,
          );
        } else {
          _removeAnimation(key);
          _addBulkMember(key);
          _addBulkPending(key);
        }
      }

      // Start collapsing (value 1 -> 0)
      if (_activeBulkGroup!.members.isNotEmpty) {
        _activeBulkGroup!.controller.reverse();
      }
      _bumpBulkGen();
    }
  } else {
    // Remove immediately
    final toRemove = <TKey>{};
    for (final key in nodesToHide) {
      if (!_isPendingDeletion(key)) {
        toRemove.add(key);
        _removeAnimation(key);
      }
    }
    if (toRemove.isNotEmpty) {
      _removeFromVisibleOrder(toRemove);
    }
  }
  // Bulk expansion-state clear: every node whose isExpanded state flipped
  // may render differently (chevron rotation, etc.). The set can span
  // arbitrary subtrees; fall back to a full refresh.
  _notifyStructural();
}