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 nodesNodeType.parent: Mid-level nodes that can have childrenNodeType.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
blockModifierScrollis in effect, does not scroll. - How far one notch travels is not a
FolderViewsetting. It is app-wide, throughSmoothWheelBindinginflutter_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
FolderViewwith 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_folderviewand 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.
Libraries
- flutter_folderview
- input/scale_modifier
- models/flat_node
- models/node
- services/flatten_service
- services/flattener
- services/row_metrics
- services/scroll_anchor
- services/size_service
- services/view_mode_projection
- themes/_scale_text_style
- themes/child_node_theme
- themes/expand_icon_theme
- themes/expandable_node_theme_view
- themes/flutter_folder_view_theme
- themes/folder_node_theme
- themes/folder_view_line_theme
- themes/folder_view_node_style_theme
- themes/folder_view_scrollbar_theme
- themes/folder_view_spacing_theme
- themes/folder_view_theme
- themes/node_tooltip_theme
- themes/parent_node_theme
- themes/row_tooltip_theme
- widgets/child_node_renderer
- widgets/custom_ink_well
- widgets/expandable_node_renderer
- widgets/folder_view
- widgets/folder_view_content
- widgets/folder_view_horizontal_scrollbar
- widgets/folder_view_scrollbar_thumb
- widgets/folder_view_vertical_scrollbar
- widgets/modifier_wheel_guard
- widgets/node_render_parts
- widgets/node_widget
- widgets/synced_scroll_controllers
- widgets/tree_lines