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:

  • index is a final-list position. Remove key from its old parent first, THEN insert at index in the new parent's children (newParent null 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: 1 pins 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.md builds one drag-and-drop screen from nothing: input modes, the row builder, a custom grip, the onReorder contract, the drag proxy and the accessibility actions.
  • examples/lib/ holds a runnable program per feature. Point examples/lib/main.dart's home: 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 / itemBuilder callbacks.
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