Flutter FolderView

A customizable Flutter widget for displaying hierarchical data in tree and folder views.

Installation

dependencies:
  flutter_folderview: ^0.12.0

Requires Flutter 3.32.0 (Dart 3.8.0) or newer, raised from 3.13.0 in 0.12.0.

Basic Usage

import 'package:flutter_folderview/flutter_folderview.dart';

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final Set<String> _expandedIds = {};

  @override
  Widget build(BuildContext context) {
    return FolderView(
      data: [
        Node(
          id: '1',
          label: 'Documents',
          type: NodeType.folder,
          children: [
            Node(
              id: '2',
              label: 'Work',
              type: NodeType.parent,
              children: [
                Node(id: '3', label: 'Report.pdf', type: NodeType.child),
              ],
            ),
          ],
        ),
      ],
      expandedNodeIds: _expandedIds,
      mode: ViewMode.folder,
      onNodeTap: (node) {
        if (node.type != NodeType.child) {
          setState(() {
            if (_expandedIds.contains(node.id)) {
              _expandedIds.remove(node.id);
            } else {
              _expandedIds.add(node.id);
            }
          });
        }
      },
    );
  }
}

Node Types

  • NodeType.folder: Top-level container nodes
  • NodeType.parent: Mid-level nodes that can have children
  • NodeType.child: Leaf nodes

View Modes

  • ViewMode.folder: Hierarchical folder structure (folder → parent → child)
  • ViewMode.tree: Flattened tree structure (parent → child)

Line Styles

theme: FlutterFolderViewTheme(
  lineTheme: FolderViewLineTheme(
    lineStyle: LineStyle.connector, // ├─ └─ connectors
    // lineStyle: LineStyle.scope,   // Vertical scope lines
    // lineStyle: LineStyle.none,    // No lines
  ),
)

Theming

FolderView(
  data: nodes,
  expandedNodeIds: expandedIds,
  theme: FlutterFolderViewTheme(
    folderTheme: FolderNodeTheme(
      widget: Icon(Icons.folder),
      openWidget: Icon(Icons.folder_open),
      textStyle: TextStyle(fontSize: 14),
    ),
    parentTheme: ParentNodeTheme(
      widget: Icon(Icons.description),
      textStyle: TextStyle(fontSize: 14),
    ),
    childTheme: ChildNodeTheme(
      widget: Icon(Icons.insert_drive_file),
      textStyle: TextStyle(fontSize: 14),
      selectedTextStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
    ),
  ),
)

Or provide it from an ancestor. FolderView reads FolderViewTheme only while its theme is null, and the lookup is by exact type, so name the data type:

FolderViewTheme<String>(
  data: FlutterFolderViewTheme<String>.dark(),
  child: FolderView<String>(data: nodes, mode: ViewMode.folder),
)

An untyped FolderViewTheme(data: FlutterFolderViewTheme.dark()) is a FolderViewTheme<dynamic>. A FolderView<String> does not find it and falls back to light(); in debug builds this is reported once.

Scale

Scale all content proportionally. Scrollbars and tooltips are not affected — they are treated as chrome (interactive overlays sized for input devices, not for content density).

FolderView(
  data: nodes,
  mode: ViewMode.folder,
  scale: 1.5, // default 1.0
  expandedNodeIds: expandedIds,
)
Scaled Not Scaled
Row height, row spacing Scrollbar thickness, track width
Icon sizes, padding, margin Scrollbar colors
Text fontSize, letterSpacing Tooltip dimensions (padding, arrow, offset, text)
Line width, indentation Animation durations, click intervals
Content padding, border radius Colors

Each theme class also exposes a scale() method directly — useful for callers who need a scaled copy of a theme outside FolderView (custom previews, integration tests, etc.):

final scaledTheme = myTheme.scaledForContext(context, 1.5);
// or, without a BuildContext:
final scaledTheme = myTheme.scale(factor: 1.5, defaultFontSize: 14.0);

While you pass onScaleChanged, Ctrl (Windows/Linux) or Cmd (macOS) + scroll wheel proposes a Scale and does not scroll the rows. blockModifierScroll overrides that. It defaults to null, which follows onScaleChanged:

blockModifierScroll With onScaleChanged Without it
null (default) proposes a Scale, no scroll scrolls
true proposes a Scale, no scroll no scroll, no Scale
false scrolls, no Scale proposed scrolls

So false turns zoom-by-wheel off as well: onScaleChanged is called only while the modifier scroll is blocked. The block covers the rows. A wheel over the vertical scrollbar still scrolls the view, because the scrollbar is a scroll view of its own.

The library also exports isScaleModifierPressed() — a platform-aware helper that returns true when Ctrl (Windows/Linux) or Cmd (macOS) is pressed. Use it to implement custom zoom logic. Pass blockModifierScroll: true, or the same wheel event also scrolls the view:

Listener(
  onPointerSignal: (event) {
    if (event is PointerScrollEvent && isScaleModifierPressed()) {
      final delta = event.scrollDelta.dy > 0 ? -0.1 : 0.1;
      setState(() => _scale = (_scale + delta).clamp(0.5, 3.0));
    }
  },
  child: FolderView(
    data: nodes,
    mode: ViewMode.folder,
    scale: _scale,
    blockModifierScroll: true, // no onScaleChanged, so block explicitly
    expandedNodeIds: expandedIds,
  ),
)

Wheel scrolling

A mouse-wheel notch animates the view to where it points, instead of jumping there. Notches during the motion add to its target, so a fast flick still travels the whole distance. The scrollbars follow on every frame. This is on by default as WheelMotion.spring(): 400 ms, no bounce.

Choose the motion with wheelMotion, or pass null to move at once on every notch:

FolderView(
  data: nodes,
  mode: ViewMode.folder,
  wheelMotion: const WheelMotion.spring(
    duration: Duration(milliseconds: 250),
  ),
)
Motion Options Feel
WheelMotion.spring duration (400 ms), bounce (0) Keeps its speed when you keep scrolling
WheelMotion.curve duration (400 ms), curve (Curves.easeOutCubic) Plays the curve, starting over on every notch
WheelMotion.lerp timeConstant (60 ms) Fast at first, slowing as it arrives
null Moves at once, as before 0.12.0

WheelMotion comes from flutter_smooth_wheel_scroll and is re-exported, so no second import is needed. It can change at runtime; the next notch uses it, and the scroll position is kept.

  • Only the mouse wheel animates. Trackpad scrolling, dragging a scrollbar, and the jumps that keep a Node in place on expand, collapse or a Scale change move the view at once, and stop a motion in progress.
  • A wheel turned over a scrollbar moves the view at once too: the scrollbar is a scroll view of its own.
  • Ctrl+wheel (Cmd+wheel on macOS) anywhere over the view, the scrollbars included, still proposes a Scale and, while blockModifierScroll is in effect, does not scroll.
  • How far one notch travels is not a FolderView setting. It is app-wide, through SmoothWheelBinding in flutter_smooth_wheel_scroll, which this package does not install.

Tooltip

There are two, and they differ by where you declare them and what they explain.

Declared on Styled by Attaches to Explains
Label tooltip NodeTooltipTheme, per node type the same object The label's glyphs The label — hover truncated text to read the rest
Row tooltip FolderView.rowTooltipBuilder, once FolderView.rowTooltipTheme The rest of the row The node — a card of its details

Enable both. Only one is ever visible — the innermost under the pointer — and the label tooltip claims exactly the glyphs it explains, so the row card gets the indent, the icon, the expand chevron, and the space beside a short label. See Row tooltip.

Label tooltip

Each node type supports tooltip via NodeTooltipTheme:

FolderView(
  data: nodes,
  expandedNodeIds: expandedIds,
  theme: FlutterFolderViewTheme(
    folderTheme: FolderNodeTheme(
      tooltipTheme: NodeTooltipTheme(
        useTooltip: true,
        message: 'Folder node',
        direction: TooltipDirection.top,
        alignment: TooltipAlignment.center,
        interactive: true,
        waitDuration: Duration(milliseconds: 500),
        showDuration: Duration(seconds: 3),
        boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 8)],
      ),
    ),
  ),
)
Property Type Description
useTooltip bool Enable tooltip (default: false)
message String? Static tooltip text
tooltipBuilder WidgetBuilder? Custom tooltip widget
tooltipBuilderResolver Function? Node-specific tooltip widget resolver
direction TooltipDirection Position: top, bottom, left, right
alignment TooltipAlignment Alignment: start, center, end, startTargetCenter, endTargetCenter
anchor TooltipAnchor Anchor the tooltip to the row's rect or the cursor: child (default), pointer
offset double Distance from widget (default: 8.0)
crossAxisOffset double Cross-axis offset (default: 0.0)
backgroundColor Color? Background color
elevation double? Shadow depth
boxShadow List<BoxShadow>? Custom shadow (overrides elevation)
borderRadius BorderRadius? Corner rounding
padding EdgeInsets? Inner padding
enableTap bool? Tap to show (default: false)
enableHover bool? Hover to show (default: true)
interactive bool? Keep visible on tooltip hover
waitDuration Duration? Delay before showing on hover
showDuration Duration? Auto-hide after duration (resets on re-enter)
animationDuration Duration? Show/hide animation duration
controllerResolver JustTooltipController? Function(Node<T>)? Per-Node programmatic show/hide; return a distinct, stable controller per Node
onShow VoidCallback? Callback when shown
onHide VoidCallback? Callback when hidden
showArrow bool? Show arrow on tooltip (default: false)
arrowBaseWidth double? Arrow base width (default: 12.0)
arrowLength double? Arrow length (default: 6.0)
arrowPositionRatio double? Arrow position ratio 0.0~1.0 (default: 0.25)
borderColor Color? Tooltip border color
borderWidth double? Tooltip border width (default: 0.0)
screenMargin double? Min margin from screen edges (default: 8.0)
animation TooltipAnimation? Animation type: none, fade, scale, slide, fadeScale, fadeSlide, rotation
animationCurve Curve? Custom curve for animation
fadeBegin double? Starting opacity for fade animations (default: 0.0)
scaleBegin double? Starting scale for scale animations (default: 0.0)
slideOffset double? Slide distance as fraction of tooltip size (default: 0.3)
rotationBegin double? Starting rotation in turns (default: -0.05)

Anchoring to the cursor

A node's tooltip attaches to its icon-and-label content, not to the whole rendered row. But a row is as wide as the tree's longest label, and each label grows to fill its row — so a long label's rect spans the row, and a tooltip anchored to that rect appears at the centre of its visible part, far from where the user is actually pointing. TooltipAnchor.pointer keeps the same hover region but places the tooltip at the cursor:

NodeTooltipTheme(
  useTooltip: true,
  message: 'Report.pdf',
  anchor: TooltipAnchor.pointer, // default: TooltipAnchor.child
)

The anchor is captured when the tooltip is shown and does not follow the pointer, so interactive tooltips stay reachable. Tap-triggered tooltips anchor at the tap, and a show driven by a controllerResolver controller with no pointer present falls back to the label's rect.

Against a point there are no target edges to align to, so under TooltipAnchor.pointer the alignment field selects which of the tooltip's own edges lands on the pointer.

Note that anchor does not widen the hover region. A short label occupies only the left part of its row, and the space to its right raises no tooltip under either anchor. To make the whole row hoverable, use the row tooltip below.

When a tree is wide enough to scroll horizontally, a long label's rect extends past the visible FolderView. TooltipAnchor.child aims at the centre of the part still on screen, so the tooltip stays inside the view.

Row tooltip

rowTooltipBuilder returns a card shown while the pointer is anywhere over a node's row — the indent, the expand chevron, the empty space beside a short label. Return null for a node that should not have one.

FolderView(
  data: nodes,
  expandedNodeIds: expandedIds,
  rowTooltipBuilder: (context, node) {
    if (node.type == NodeType.folder) return null;
    return Card(child: Padding(
      padding: const EdgeInsets.all(12),
      child: Text('${node.label} — ${node.children.length} children'),
    ));
  },
)

The card supplies its own surface, so by default the tooltip around it draws no background, padding, or elevation. Give it a Card, not a bare Text.

It is anchored at the pointer, and that is the one knob you cannot change. A row is laid out at the tree's content width rather than the viewport's, so the visible part of its rect spans the view. Anchored to the row, the card would open at the view's centre wherever you hover; at the pointer, it opens where you are looking.

Styling the card

Everything else is rowTooltipTheme:

FolderView(
  data: nodes,
  expandedNodeIds: expandedIds,
  rowTooltipBuilder: (context, node) => Card(child: Text(node.label)),
  rowTooltipTheme: RowTooltipTheme(
    waitDuration: Duration(milliseconds: 400), // don't flash on a quick sweep
    interactive: true,                         // default: reach into the card
    direction: TooltipDirection.bottom,
    offset: 12,
  ),
)
Property Type Description
interactive bool Card stays while the cursor is over it (default: true)
waitDuration Duration? Delay before showing. Null shows immediately
showDuration Duration? Auto-hide after this long
enableHover bool Whether hovering raises the card at all (default: true)
direction TooltipDirection Side of the pointer; flips when there is no room
alignment TooltipAlignment Which of the card's own edges lands on the pointer
offset double Distance from the pointer (default: 8.0)
crossAxisOffset double Offset along the cross-axis of direction
screenMargin double Minimum distance from the Overlay's edges
surface JustTooltipTheme The tooltip's own chrome. Defaults to JustTooltipTheme.bare() — nothing drawn
animation, animationCurve, animationDuration Show/hide animation
fadeBegin, scaleBegin, slideOffset, rotationBegin double Animation start values
onShow, onHide VoidCallback? Shown / hidden callbacks

If your builder returns unadorned content rather than a Card, give surface a real JustTooltipTheme and let the tooltip draw the box.

Enabling both

Enable both. They divide the row between them.

Only one tooltip is visible at a time — the innermost under the pointer — and the label tooltip claims exactly the glyphs it explains. Hover the label text and you get the label tooltip; hover anywhere else on the row (the indent, the icon, the expand chevron, the space beside a short label) and you get the row card.

The one place the card cannot appear is over the glyphs themselves. On the tree's widest row the label runs the length of the row, so there the card is reachable over the indent and the icon only. That is the geometry, not a setting.

Example

example/ is a gallery app built on flutter_example_template. A menu on the left lists its destinations, the middle shows the running example, and the panel on the right holds its settings.

cd example
flutter run
  • Every setting. This is a FolderView with a control for every public option of the package. The controls are grouped as:

    • Data & View Mode, Layout, and Interaction (Taps & state, Smooth wheel, Scale);
    • the Folder, Parent and Child tiers;
    • Node tooltips and the Row card.

    Each option shows the code it produces. A test fails when a new public option has neither a control nor an issue that tracks it.

  • Recipes. These are self-contained files you can copy into an app. Building a tree shows its own source in the Code pane, and it imports only flutter_folderview and Flutter.

  • Scenarios:

    • A hundred thousand children is a large generated tree.
    • Row card and label tooltip shows that the innermost tooltip under the pointer wins.
    • A row wider than the viewport shows the row card opening beside the pointer after a horizontal scroll.
  • Viewports. The bar above the example shows it in a desktop, tablet or mobile frame, or all three at once on the Device Wall. A frame is scaled down to fit. The Room shows the example at 1:1, at the size of the area it is given. Use it to judge real pixel sizes, such as a tooltip's.