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