widgets_extended
A declarative, animated tree sliver for Flutter: SyncedSliverTree.
Hand it your data on every build. It diffs against what is on screen and animates the difference: inserts grow in, removals shrink out, moves and reorders slide, reparenting glides across depths. No controller to manage, no imperative mutation calls, no manual keys bookkeeping. Drag-and-drop reorder is one parameter.
Install
flutter pub add widgets_extended.
Quick start
import 'package:widgets_extended/widgets_extended.dart';
class Folder {
Folder(this.id, this.name, [this.children = const []]);
final String id;
final String name;
final List<Folder> children;
}
Your model already has the structure, so point the tree at it:
CustomScrollView(
slivers: [
SyncedSliverTree<String, Folder>.hierarchy(
roots: folders,
keyOf: (f) => f.id,
childrenOf: (f) => f.children,
indentWidth: 24,
itemBuilder: (context, node) => ListTile(
title: Text(node.item.name),
leading: node.hasChildren
? Icon(node.isExpanded ? Icons.expand_more : Icons.chevron_right)
: const Icon(Icons.insert_drive_file_outlined),
onTap: node.toggle,
),
),
],
)
That is a working animated tree: tapping a row expands or collapses it with
animation, and rows indent by depth automatically (indentWidth pixels per
level).
To change the tree, change your data and hand in a new list instance:
setState(() {
folders = folderStore.currentRoots(); // new instance = diff + animate
});
The widget diffs the new input against the screen and animates every
transition. Passing the identical instance as last time skips the diff
entirely (the ListView.children convention), so a frequently rebuilding
ancestor costs nothing. keyOf / childrenOf must be pure functions of
their argument.
The row builder
itemBuilder receives a TreeItemView with everything a row usually needs,
all kept fresh automatically (rows rebuild when a sibling change shifts
them):
| Data | node.item, node.key, node.depth, node.parentKey, node.isRoot, node.indent |
| Structure | node.hasChildren, node.childCount, node.liveChildCount, node.hasLiveChildren, node.isFirst, node.isLast, node.indexInParent, node.siblingCount |
| Expansion | node.isExpanded, node.toggle(), node.expand(), node.collapse() |
node.isFirst / node.isLast make connector lines and rounded-group
styling trivial. They, node.indexInParent and node.siblingCount are
all live-space: a sibling that is animating out is already excluded, so
the last row stays the last row for the length of a removal. The child
counts split the other way. node.childCount INCLUDES children still
painting their exit, which is what a badge rendered beside those rows
wants; node.liveChildCount and node.hasLiveChildren report the
settled state instead.
node.controller is the escape hatch to the full imperative API if you
ever need it.
Drag-and-drop reorder
Pass a reorder: config. That is the whole integration:
SyncedSliverTree<String, Folder>.hierarchy(
roots: folders,
keyOf: (f) => f.id,
childrenOf: (f) => f.children,
indentWidth: 24,
reorder: TreeReorderConfig<String>(
onReorder: (key, newParent, index) {
setState(() {
folders = folderStore.move(key, toParent: newParent, at: index);
});
},
),
itemBuilder: (context, node) => ListTile(title: Text(node.item.name)),
)
Every row becomes draggable by long-press, with no change to your builder. While dragging, rows part to open a live gap at the prospective slot, a floating proxy follows the pointer, hovering a collapsed parent auto-expands it, the pointer's horizontal position picks the nesting depth at subtree boundaries, and the drop settles in place with no jump.
Rules your onReorder handler lives by:
indexis a final-list position. Removekeyfrom its old parent first, THEN insert atindexin the new parent's children (newParentnull means the root list). Computing an index against the pre-removal list puts same-parent downward moves one slot too far.- Your data stays authoritative. A move you do not record is reverted by the next sync; that is also how you reject a drop. To roll back an already-recorded move, emit a NEW collection instance.
- Async handlers record before awaiting, then reconcile or roll back on the response.
Three policies gate a move, and they compose. enabled: is the
tree-wide runtime switch, for an edit mode you flip with app state.
canReorder: (key) => ... gates dragging per row. canAcceptDrop: ({required movingKey, newParent, index}) => ... filters destinations
instead of sources, and also shapes the zones: a row that refuses
(newParent: thatRow, index: 0) cannot take children at all, so it drops
its into zone and splits in two rather than three.
A refused row keeps its exact widget shape, which is deliberate: a shape
that varied with policy would fail Widget.canUpdate and re-inflate every
row subtree on each toggle, disposing whatever State your builder keeps
there. The trade is that a refused row's handle still RENDERS, inert,
because the package does not decide what your grip looks like. Read
TreeRowDragScope.canDrag from a Builder inside the row to hide it
while reserving its space:
Builder(
builder: (context) {
final canDrag = TreeRowDragScope.maybeOf(context)?.canDrag ?? false;
return Visibility(
visible: canDrag,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: const TreeDragHandle(child: Icon(Icons.drag_indicator)),
);
},
)
On desktop, long-press reads as lag, so turn the default handles off and
place a visible grip anywhere inside the row (TreeDragHandle drags
immediately, TreeDelayedDragHandle on press-and-hold; both draw
nothing):
reorder: TreeReorderConfig<String>(
buildDefaultDragHandles: false,
onReorder: ...,
),
itemBuilder: (context, node) => Row(
children: <Widget>[
Expanded(child: ListTile(title: Text(node.item.name))),
TreeDragHandle(child: Icon(Icons.drag_indicator)),
],
),
Rows built from Material widgets need a dragProxyBuilder that re-provides
a Material ancestor (the proxy floats in the root Overlay, outside your
row's ancestry), the same contract as Draggable.feedback. Screen readers
get move up / down / out / into actions automatically, reported through the
same onReorder.
Expansion
initiallyExpanded: true(the default) opens the whole tree on the first sync, and opens any node that gains its first children in a later one;initialNodeExpansion: (key, item) => bool?overrides it per node (return null to defer). Both are INITIAL policies: once a node exists, its own expansion state wins.expansionMemory(default 1024) remembers up to that many removed nodes' expansion states across remove/re-add cycles (0 disables it), and a user's deliberate collapse is never overridden by later syncs.onExpansionChanged: (key, isExpanded) { ... }is the hook for persisting expansion state.
For capabilities no row can reach, such as toolbar buttons or deep links, grab the internal controller once:
SyncedSliverTree<String, Folder>.hierarchy(
// ...
onControllerCreated: (controller) => _tree = controller,
)
// Later:
_tree.expandAll();
_tree.animateScrollToKey(deepLinkedId, scrollController: scrollController);
Do not dispose it; the widget owns it.
Other input modes
.hierarchy fits nested models. Two siblings cover the rest, with the same
parameters otherwise:
// Flat rows with parent pointers (query results, adjacency lists).
SyncedSliverTree<String, Task>.flat(
items: tasks,
keyOf: (t) => t.id,
parentOf: (t) => t.parentId, // null = root; unknown key = ArgumentError
itemBuilder: ...,
)
// Explicit SyncedTreeNode tree, for when the UI itself edits structure.
SyncedSliverTree<String, Folder>(
tree: [
SyncedTreeNode(key: "docs", data: docs, children: [
SyncedTreeNode(key: "taxes", data: taxes),
]),
],
itemBuilder: ...,
)
Animation styling
One TreeAnimationStyle times every animation family (expand/collapse,
enter/exit, reorder slides, the drag gap, drop settles). Defaults are 300ms
linear across the board:
SyncedSliverTree<String, Folder>.hierarchy(
// ...
animationStyle: TreeAnimationStyle.uniform(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
),
)
Per-family specs are available (TreeAnimationStyle(expandCollapse: ..., reorderSlide: ...)), unset drag families inherit from reorderSlide, and
TreeAnimationStyle.disabled snaps everything, which is also the
synchronous-test configuration.
Also in the box
maxStickyDepth: 1pins root rows as sticky headers while scrolling through their children (higher values pin deeper levels too).SectionedSliverList: a two-level header + items convenience built on the same engine, with per-kind reorder config.SliverTree+TreeController: the imperative lowest layer, for full control over every mutation.
All of it runs on one sliver core: viewport-aware lazy building, dense integer-indexed state storage, and reorder slides that are paint-only per frame, so rows glide without relayout.
Learn more
doc/synced_sliver_tree_tutorial.mdbuilds one drag-and-drop screen from nothing: input modes, the row builder, a custom grip, theonReordercontract, the drag proxy and the accessibility actions.examples/lib/holds a runnable program per feature. Pointexamples/lib/main.dart'shome:at the one you want.
Libraries
- sectioned_sliver_list/_internal_keys
- Internal sealed wrapper types used to keep section keys, item keys, section payloads, and item payloads in disjoint domains within a single underlying TreeController.
- sectioned_sliver_list/_reorder_bridge
- The boundary between
SectionedReorderConfig's kind-paired members and the tree layer's three singular controller callbacks. - sectioned_sliver_list/sectioned_list_controller
- Imperative controller for SectionedSliverList.
- sectioned_sliver_list/sectioned_reorder_config
- Drag-and-drop configuration for SectionedSliverList.
- sectioned_sliver_list/sectioned_sliver_list
- SectionedSliverList — header + items convenience sliver.
- sectioned_sliver_list/sectioned_sliver_list_widget
- Header + items convenience sliver, built on top of SliverTree.
- sectioned_sliver_list/views
- Views handed to
headerBuilder/itemBuildercallbacks. - sliver_tree/_animation_coordinator
- Internal: facade over the five animation sources (standalone, per-operation groups, bulk, slide, make-room preview), plus the cross-source state that spans them.
- sliver_tree/_bulk_animator
- Internal: bulk animation source for TreeController.
- sliver_tree/_deferred_sync_gate
- Internal: holds a structural diff while a drag is live and re-examines it once the drag ends. Not exported from the package barrel.
- sliver_tree/_drag_session
- Internal: per-drag session infrastructure for
TreeReorderController: SessionExit, PointerSample and PointerSpace, DragProbe, and DragSession itself. The behavior collaborators the session fans out to live in_drag_session_behaviors.dart. - sliver_tree/_drag_session_behaviors
- Internal: the drag session's behavior collaborators.
- sliver_tree/_drop_zone_resolver
- Pure drop-target resolution for drag-and-drop reorder.
- sliver_tree/_ghost_registry
- Edge-ghost lifecycle registry: install, re-evaluate under scroll, re-promote when the true structural row re-enters the viewport, prune on settle, and resolve a ghost's painted base Y on demand.
- sliver_tree/_layout_admission_policy
- Internal: cache-region admission policy for RenderSliverTree.
- sliver_tree/_live_index_cache
- Internal: lazy live-index-in-parent cache backing
TreeController.getIndexInParent. - sliver_tree/_node_id_registry
- Internal: bidirectional key-to-nid mapping with free-list recycling.
- sliver_tree/_node_store
- Internal: structural-component storage for TreeController.
- sliver_tree/_operation_group_registry
- Internal: per-operation animation source for TreeController.
- sliver_tree/_reorder_preview_engine
- Internal: paint-only make-room preview engine.
- sliver_tree/_scroll_orchestrator
- Internal: scroll-related operations for TreeController.
- sliver_tree/_slide_animation_engine
- Internal: paint-only FLIP slide engine for TreeController.
- sliver_tree/_slide_baseline_slot
- Single-slot pending-baseline buffer used by the slide pipeline.
- sliver_tree/_slide_composer
- Facade composing the two slide-pipeline collaborators into a single surface the render layer holds: SlideBaselineSlot, which stages and consumes pending baselines, and GhostRegistry, which owns the active edge ghosts. Implements GhostBaseResolver by delegating to the registry, so the render layer's paint-time hot paths can be typed against that narrow read contract.
- sliver_tree/_standalone_animator
- Internal: standalone (per-node) animation source for TreeController.
- sliver_tree/_sticky_header_computer
- Internal: sticky-header layout helper for RenderSliverTree.
- sliver_tree/_sync_helpers
- Internal sync-time helpers shared by widgets that drive a TreeController through a TreeSyncController. Not exported from the package barrel.
- sliver_tree/_synced_input_normalizer
- Input normalization for SyncedSliverTree's input modes.
- sliver_tree/_viewport_snapshot
- Immutable per-frame viewport state shared between the render layer and the slide composer. Owns the predicates the slide pipeline uses to classify rows as "visible" / "meaningfully visible" / "past an edge," so capture-time vs current-time questions can be expressed in terms of two snapshots instead of ad-hoc scroll-offset arithmetic.
- sliver_tree/_visible_order_buffer
- Internal: flattened visible-order buffer for a TreeController.
- sliver_tree/animation_style
- Value types configuring animation timing and easing for the sliver tree stack.
- sliver_tree/render_sliver_tree
- Render object for SliverTree that handles sliver layout and painting.
- sliver_tree/reorder_render_port
- The narrow render-layer surface a drag-and-drop reorder session needs.
- sliver_tree/sliver_reorderable_tree
- Declarative wrapper around SliverTree that adds drag-and-drop reorder over a TreeReorderController.
- sliver_tree/sliver_tree
- SliverTree: a sliver-based tree with animated expand and collapse, FLIP reorder slides, drag-and-drop reordering, and sticky headers.
- sliver_tree/sliver_tree_element
- Element for SliverTree that manages child element lifecycle.
- sliver_tree/sliver_tree_widget
- Widget for displaying a tree structure as a sliver.
- sliver_tree/synced_sliver_tree
- A declarative sliver tree with data-first input modes.
- sliver_tree/synced_tree_node
- Immutable nested tree node for
SyncedSliverTree. - sliver_tree/tree_controller
- Controller that manages tree state, visibility, and animations.
- sliver_tree/tree_drag_handle
- Caller-placed drag handles for a SliverReorderableTree row.
- sliver_tree/tree_node_builder
- A widget that rebuilds only when a specific node's state changes.
- sliver_tree/tree_reorder_config
- Declarative reorder configuration: one nullable object instead of a parameter per knob on every constructor.
- sliver_tree/tree_reorder_controller
- Orchestrates drag-and-drop reorder over a TreeController-backed SliverTree: gesture lifecycle, drop-target resolution, autoscroll near viewport edges, and FLIP slide animation on commit.
- sliver_tree/tree_sync_controller
- A diffing/syncing layer on top of TreeController.
- sliver_tree/types
- Core types for the sliver tree system.
- widgets_extended