collapseAll method
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();
}