diff --git a/docs/app/docs/data/tree/page.mdx b/docs/app/docs/data/tree/page.mdx
new file mode 100644
index 000000000..8b3d6299e
--- /dev/null
+++ b/docs/app/docs/data/tree/page.mdx
@@ -0,0 +1,269 @@
+import { Callout } from 'nextra/components';
+import { Tabs } from 'nextra/components';
+import { Widget } from '@/components/demo/widget.tsx';
+import LinkBadge from '@/components/ui/link-badge/link-badge.tsx';
+import LinkBadgeGroup from '@/components/ui/link-badge/link-badge-group.tsx';
+
+# Tree
+
+A hierarchical tree widget for displaying nested data with visual connecting lines.
+
+
+
+
+
+
+
+
+
+
+ ```dart copy
+ FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Apple'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Red Apple'),
+ onPress: () {},
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Green Apple'),
+ onPress: () {},
+ ),
+ ],
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Banana'),
+ onPress: () {},
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Cherry'),
+ onPress: () {},
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('Date'),
+ selected: true,
+ onPress: () {},
+ ),
+ ],
+ );
+ ```
+
+
+
+## Usage
+
+### Basic Tree
+
+A basic tree structure with collapsible items.
+
+
+
+
+
+
+ ```dart copy
+ FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Folder 1'),
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('File 1.1'),
+ onPress: () {},
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('File 1.2'),
+ onPress: () {},
+ ),
+ ],
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('File 2'),
+ onPress: () {},
+ ),
+ ],
+ );
+ ```
+
+
+
+### Nested Tree
+
+Trees support arbitrary nesting depth with visual connecting lines.
+
+
+
+
+
+
+ ```dart copy
+ FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 1'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 2'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 3'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('Deep File'),
+ onPress: () {},
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+ ```
+
+
+
+### Selected Item
+
+Tree items can be marked as selected.
+
+
+
+
+
+
+ ```dart copy
+ FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Item 1'),
+ selected: true,
+ onPress: () {},
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Item 2'),
+ onPress: () {},
+ ),
+ ],
+ );
+ ```
+
+
+
+## Examples
+
+### File Explorer
+
+A file explorer-style tree with folders and files.
+
+```dart
+FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('src'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('components'),
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('button.dart'),
+ onPress: () {},
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('card.dart'),
+ onPress: () {},
+ ),
+ ],
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('main.dart'),
+ selected: true,
+ onPress: () {},
+ ),
+ ],
+ ),
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('test'),
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.file),
+ label: const Text('button_test.dart'),
+ onPress: () {},
+ ),
+ ],
+ ),
+ ],
+);
+```
+
+## Customization
+
+### Custom Styling
+
+You can customize the tree appearance using `FTreeStyle`.
+
+```dart
+FTree(
+ style: (style) => style.copyWith(
+ indentWidth: 30,
+ spacing: 4,
+ itemStyle: (itemStyle) => itemStyle.copyWith(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ children: [
+ // ... tree items
+ ],
+);
+```
+
+### Custom Line Style
+
+Customize the connecting line style.
+
+```dart
+FTree(
+ style: (style) => style.copyWith(
+ itemStyle: (itemStyle) => itemStyle.copyWith(
+ lineStyle: (lineStyle) => lineStyle.copyWith(
+ color: Colors.blue,
+ width: 2,
+ dashPattern: [4, 4], // Dashed line
+ ),
+ ),
+ ),
+ children: [
+ // ... tree items
+ ],
+);
+```
diff --git a/forui/CHANGELOG.md b/forui/CHANGELOG.md
index d05a113e9..5d0f424a0 100644
--- a/forui/CHANGELOG.md
+++ b/forui/CHANGELOG.md
@@ -1,3 +1,11 @@
+## Next
+
+### `FTree`
+- Add `FTree` - A hierarchical tree widget for displaying nested data with visual connecting lines.
+- Add `FTreeItem` - Individual tree node widget with expand/collapse support.
+- Add `FTreeController`.
+- Add `FTreeStyle`, `FTreeItemStyle`, `FTreeLineStyle`, and `FTreeItemMotion`.
+
## 0.16.0
### Better Generated Documentation
diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart
index c0577aa74..ca7377d77 100644
--- a/forui/lib/forui.dart
+++ b/forui/lib/forui.dart
@@ -45,5 +45,6 @@ export 'widgets/tabs.dart';
export 'widgets/text_field.dart';
export 'widgets/tile.dart';
export 'widgets/time_picker.dart';
+export 'widgets/tree.dart';
export 'widgets/time_field.dart';
export 'widgets/tooltip.dart';
diff --git a/forui/lib/src/theme/theme_data.dart b/forui/lib/src/theme/theme_data.dart
index bd503ab9f..9e54a0d93 100644
--- a/forui/lib/src/theme/theme_data.dart
+++ b/forui/lib/src/theme/theme_data.dart
@@ -535,6 +535,17 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
@override
final FTileGroupStyle tileGroupStyle;
+ /// The tree style.
+ ///
+ /// ## CLI
+ /// To generate and customize this style:
+ ///
+ /// ```shell
+ /// dart run forui style create tree
+ /// ```
+ @override
+ final FTreeStyle treeStyle;
+
/// The time field's style.
///
/// ## CLI
@@ -622,6 +633,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
FTextFieldStyle? textFieldStyle,
FTileStyle? tileStyle,
FTileGroupStyle? tileGroupStyle,
+ FTreeStyle? treeStyle,
FTimeFieldStyle? timeFieldStyle,
FTimePickerStyle? timePickerStyle,
FTooltipStyle? tooltipStyle,
@@ -691,6 +703,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
textFieldStyle: textFieldStyle ?? FTextFieldStyle.inherit(colors: colors, typography: typography, style: style),
tileStyle: tileStyle ?? FTileStyle.inherit(colors: colors, typography: typography, style: style),
tileGroupStyle: tileGroupStyle ?? FTileGroupStyle.inherit(colors: colors, typography: typography, style: style),
+ treeStyle: treeStyle ?? FTreeStyle.inherit(colors: colors, typography: typography, style: style),
timeFieldStyle: timeFieldStyle ?? FTimeFieldStyle.inherit(colors: colors, typography: typography, style: style),
timePickerStyle:
timePickerStyle ?? FTimePickerStyle.inherit(colors: colors, typography: typography, style: style),
@@ -751,6 +764,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
textFieldStyle: a.textFieldStyle.lerp(b.textFieldStyle, t),
tileStyle: a.tileStyle.lerp(b.tileStyle, t),
tileGroupStyle: a.tileGroupStyle.lerp(b.tileGroupStyle, t),
+ treeStyle: a.treeStyle.lerp(b.treeStyle, t),
timeFieldStyle: a.timeFieldStyle.lerp(b.timeFieldStyle, t),
timePickerStyle: a.timePickerStyle.lerp(b.timePickerStyle, t),
tooltipStyle: a.tooltipStyle.lerp(b.tooltipStyle, t),
@@ -811,6 +825,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
required this.textFieldStyle,
required this.tileStyle,
required this.tileGroupStyle,
+ required this.treeStyle,
required this.timeFieldStyle,
required this.timePickerStyle,
required this.tooltipStyle,
@@ -1311,6 +1326,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
FTextFieldStyle Function(FTextFieldStyle style)? textFieldStyle,
FTileStyle Function(FTileStyle style)? tileStyle,
FTileGroupStyle Function(FTileGroupStyle style)? tileGroupStyle,
+ FTreeStyle Function(FTreeStyle style)? treeStyle,
FTimeFieldStyle Function(FTimeFieldStyle style)? timeFieldStyle,
FTimePickerStyle Function(FTimePickerStyle style)? timePickerStyle,
FTooltipStyle Function(FTooltipStyle style)? tooltipStyle,
@@ -1376,6 +1392,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
textFieldStyle: textFieldStyle != null ? textFieldStyle(this.textFieldStyle) : this.textFieldStyle,
tileStyle: tileStyle != null ? tileStyle(this.tileStyle) : this.tileStyle,
tileGroupStyle: tileGroupStyle != null ? tileGroupStyle(this.tileGroupStyle) : this.tileGroupStyle,
+ treeStyle: treeStyle != null ? treeStyle(this.treeStyle) : this.treeStyle,
timeFieldStyle: timeFieldStyle != null ? timeFieldStyle(this.timeFieldStyle) : this.timeFieldStyle,
timePickerStyle: timePickerStyle != null ? timePickerStyle(this.timePickerStyle) : this.timePickerStyle,
tooltipStyle: tooltipStyle != null ? tooltipStyle(this.tooltipStyle) : this.tooltipStyle,
diff --git a/forui/lib/src/widgets/tree/tree.dart b/forui/lib/src/widgets/tree/tree.dart
new file mode 100644
index 000000000..40008426e
--- /dev/null
+++ b/forui/lib/src/widgets/tree/tree.dart
@@ -0,0 +1,112 @@
+import 'dart:ui' show lerpDouble;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+
+import 'package:forui/forui.dart';
+
+part 'tree.design.dart';
+
+/// A hierarchical tree widget for displaying nested data with visual connecting lines.
+///
+/// The [FTree] widget is useful for creating tree-like structures such as file explorers, organization charts,
+/// or any hierarchical data representation.
+///
+/// See:
+/// * https://forui.dev/docs/data/tree for working examples.
+/// * [FTreeStyle] for customizing a tree's appearance.
+/// * [FTreeItem] for creating individual tree nodes.
+class FTree extends StatelessWidget {
+ /// The style.
+ ///
+ /// ## CLI
+ /// To generate and customize this style:
+ ///
+ /// ```shell
+ /// dart run forui style create tree
+ /// ```
+ final FTreeStyle Function(FTreeStyle style)? style;
+
+ /// The root-level tree items.
+ final List children;
+
+ /// Creates a [FTree].
+ const FTree({required this.children, this.style, super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final style = this.style?.call(context.theme.treeStyle) ?? context.theme.treeStyle;
+ final textDirection = Directionality.of(context);
+
+ return FTreeData(
+ style: style,
+ child: Column(
+ crossAxisAlignment: textDirection == TextDirection.rtl ? CrossAxisAlignment.end : CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ spacing: style.spacing,
+ children: children,
+ ),
+ );
+ }
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty('style', style));
+ }
+}
+
+/// A [FTree]'s data.
+@internal
+class FTreeData extends InheritedWidget {
+ /// Returns the [FTreeData] of the [FTree] in the given [context].
+ ///
+ /// ## Contract
+ /// Throws [AssertionError] if there is no ancestor [FTree] in the given [context].
+ static FTreeData? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType();
+
+ /// The [FTree]'s style.
+ final FTreeStyle style;
+
+ /// Creates a [FTreeData].
+ const FTreeData({required this.style, required super.child, super.key});
+
+ @override
+ bool updateShouldNotify(FTreeData old) => style != old.style;
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty('style', style));
+ }
+}
+
+/// A [FTree]'s style.
+class FTreeStyle with Diagnosticable, _$FTreeStyleFunctions {
+ /// The spacing between root-level tree items. Defaults to 2.
+ @override
+ final double spacing;
+
+ /// The indentation for each nesting level. Defaults to 20.
+ @override
+ final double indentWidth;
+
+ /// The item's style.
+ @override
+ final FTreeItemStyle itemStyle;
+
+ /// Creates a [FTreeStyle].
+ const FTreeStyle({
+ required this.itemStyle,
+ this.spacing = 2,
+ this.indentWidth = 20,
+ });
+
+ /// Creates a [FTreeStyle] that inherits its properties.
+ FTreeStyle.inherit({required FColors colors, required FTypography typography, required FStyle style})
+ : this(
+ itemStyle: FTreeItemStyle.inherit(colors: colors, typography: typography, style: style),
+ );
+}
diff --git a/forui/lib/src/widgets/tree/tree_controller.dart b/forui/lib/src/widgets/tree/tree_controller.dart
new file mode 100644
index 000000000..e2e29f433
--- /dev/null
+++ b/forui/lib/src/widgets/tree/tree_controller.dart
@@ -0,0 +1,58 @@
+import 'package:flutter/foundation.dart';
+
+import 'package:forui/forui.dart';
+
+/// A controller that controls which [FTreeItem]s are expanded/collapsed.
+///
+/// By default, [FTreeItem]s are initially collapsed unless [FTreeItem.initiallyExpanded] is set to true.
+///
+/// See:
+/// * https://forui.dev/docs/data/tree for working examples.
+final class FTreeController extends FChangeNotifier with Diagnosticable {
+ final Set _expanded;
+
+ /// Creates a [FTreeController].
+ ///
+ /// ## Contract
+ /// Throws [AssertionError] if:
+ /// * `expanded` contains duplicate values.
+ /// * `min <= expanded` is false for any value in `expanded`.
+ FTreeController({Set expanded = const {}})
+ : assert(expanded.length == {...expanded}.length, 'expanded should not have duplicates.'),
+ _expanded = {...expanded};
+
+ /// The indices of the expanded items.
+ Set get expanded => {..._expanded};
+
+ /// Expands the item at the given [index].
+ void expand(int index) {
+ if (_expanded.add(index)) {
+ notifyListeners();
+ }
+ }
+
+ /// Collapses the item at the given [index].
+ void collapse(int index) {
+ if (_expanded.remove(index)) {
+ notifyListeners();
+ }
+ }
+
+ /// Toggles the expansion state of the item at the given [index].
+ void toggle(int index) {
+ if (_expanded.contains(index)) {
+ collapse(index);
+ } else {
+ expand(index);
+ }
+ }
+
+ /// Returns true if the item at the given [index] is expanded.
+ bool isExpanded(int index) => _expanded.contains(index);
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(IterableProperty('expanded', _expanded));
+ }
+}
diff --git a/forui/lib/src/widgets/tree/tree_item.dart b/forui/lib/src/widgets/tree/tree_item.dart
new file mode 100644
index 000000000..fd5d61b2e
--- /dev/null
+++ b/forui/lib/src/widgets/tree/tree_item.dart
@@ -0,0 +1,599 @@
+import 'dart:ui' show lerpDouble, Color;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart' show Colors;
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/widgets/tree/tree_painter.dart';
+
+part 'tree_item.design.dart';
+
+/// A tree item that can be expanded or collapsed to show/hide children.
+///
+/// The [FTreeItem] widget is useful for creating hierarchical structures with visual connecting lines.
+///
+/// See:
+/// * https://forui.dev/docs/data/tree for working examples.
+/// * [FTreeItemStyle] for customizing a tree item's appearance.
+class FTreeItem extends StatefulWidget {
+ /// The tree item's style.
+ ///
+ /// ## CLI
+ /// To generate and customize this style:
+ ///
+ /// ```shell
+ /// dart run forui style create tree
+ /// ```
+ final FTreeItemStyle Function(FTreeItemStyle style)? style;
+
+ /// The icon to display before the label.
+ final Widget? icon;
+
+ /// The main content of the item.
+ final Widget? label;
+
+ /// Whether this item is currently selected.
+ final bool selected;
+
+ /// Whether this item is initially expanded. Defaults to false.
+ final bool initiallyExpanded;
+
+ /// {@macro forui.foundation.doc_templates.autofocus}
+ final bool autofocus;
+
+ /// {@macro forui.foundation.doc_templates.focusNode}
+ final FocusNode? focusNode;
+
+ /// Called when the item is pressed.
+ ///
+ /// The method will run concurrently with animations if [children] is non-null.
+ final VoidCallback? onPress;
+
+ /// Called when the item is long pressed.
+ final VoidCallback? onLongPress;
+
+ /// Called when the hover state changes.
+ final ValueChanged? onHoverChange;
+
+ /// Called when the state changes.
+ final ValueChanged? onStateChange;
+
+ /// Called when the expansion state changes.
+ final ValueChanged? onExpandChange;
+
+ /// Called when the visible row count changes (including this item and all visible descendants).
+ final ValueChanged? _onRowCountChange;
+
+ /// The tree item's children.
+ final List children;
+
+ /// Creates a [FTreeItem].
+ const FTreeItem({
+ this.style,
+ this.icon,
+ this.label,
+ this.selected = false,
+ this.initiallyExpanded = false,
+ this.autofocus = false,
+ this.focusNode,
+ this.onPress,
+ this.onLongPress,
+ this.onHoverChange,
+ this.onStateChange,
+ this.onExpandChange,
+ this.children = const [],
+ super.key,
+ }) : _onRowCountChange = null;
+
+ const FTreeItem._({
+ this.style,
+ this.icon,
+ this.label,
+ this.selected = false,
+ this.initiallyExpanded = false,
+ this.autofocus = false,
+ this.focusNode,
+ this.onPress,
+ this.onLongPress,
+ this.onHoverChange,
+ this.onStateChange,
+ this.onExpandChange,
+ ValueChanged? onRowCountChange,
+ this.children = const [],
+ super.key,
+ }) : _onRowCountChange = onRowCountChange;
+
+ @override
+ State createState() => _FTreeItemState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(FlagProperty('selected', value: selected, ifTrue: 'selected'))
+ ..add(FlagProperty('initiallyExpanded', value: initiallyExpanded, ifTrue: 'initiallyExpanded'))
+ ..add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus'))
+ ..add(DiagnosticsProperty('focusNode', focusNode))
+ ..add(ObjectFlagProperty.has('onPress', onPress))
+ ..add(ObjectFlagProperty.has('onLongPress', onLongPress))
+ ..add(ObjectFlagProperty.has('onHoverChange', onHoverChange))
+ ..add(ObjectFlagProperty.has('onStateChange', onStateChange))
+ ..add(ObjectFlagProperty.has('onExpandChange', onExpandChange))
+ ..add(ObjectFlagProperty.has('onRowCountChange', _onRowCountChange))
+ ..add(DiagnosticsProperty('children', children));
+ }
+}
+
+class _FTreeItemState extends State with TickerProviderStateMixin {
+ FTreeItemStyle? _style;
+ AnimationController? _controller;
+ CurvedAnimation? _curvedReveal;
+ CurvedAnimation? _curvedFade;
+ CurvedAnimation? _curvedIconRotation;
+ Animation? _reveal;
+ Animation? _fade;
+ Animation? _iconRotation;
+ late bool _expanded;
+ late Map _childExpansionStates;
+ late Map _childRowCounts;
+ bool _hasNotifiedInitialRowCount = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _expanded = widget.initiallyExpanded;
+ _childExpansionStates = {for (var i = 0; i < widget.children.length; i++) i: widget.children[i].initiallyExpanded};
+ // Initialize with 1 row per child (will be updated when children report their actual counts)
+ _childRowCounts = {for (var i = 0; i < widget.children.length; i++) i: 1};
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _update();
+
+ // Notify parent of initial row count after first build
+ if (!_hasNotifiedInitialRowCount) {
+ _hasNotifiedInitialRowCount = true;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) {
+ _notifyRowCountChange();
+ }
+ });
+ }
+ }
+
+ @override
+ void didUpdateWidget(FTreeItem old) {
+ super.didUpdateWidget(old);
+ _update();
+ }
+
+ void _update() {
+ final treeData = FTreeData.maybeOf(context);
+ final inheritedStyle = treeData?.style.itemStyle ?? context.theme.treeStyle.itemStyle;
+ final style = widget.style?.call(inheritedStyle) ?? inheritedStyle;
+
+ if (_style != style) {
+ _style = style;
+ _curvedIconRotation?.dispose();
+ _curvedFade?.dispose();
+ _curvedReveal?.dispose();
+ _controller?.dispose();
+
+ _controller = AnimationController(
+ vsync: this,
+ value: _expanded ? 1.0 : 0.0,
+ duration: style.motion.expandDuration,
+ reverseDuration: style.motion.collapseDuration,
+ );
+ _curvedReveal = CurvedAnimation(
+ curve: style.motion.expandCurve,
+ reverseCurve: style.motion.collapseCurve,
+ parent: _controller!,
+ );
+ _curvedFade = CurvedAnimation(curve: Curves.easeIn, reverseCurve: Curves.easeOut, parent: _controller!);
+ _curvedIconRotation = CurvedAnimation(
+ curve: style.motion.iconExpandCurve,
+ reverseCurve: style.motion.iconCollapseCurve,
+ parent: _controller!,
+ );
+ _reveal = style.motion.revealTween.animate(_curvedReveal!);
+ _fade = style.motion.fadeTween.animate(_curvedFade!);
+ _iconRotation = style.motion.iconTween.animate(_curvedIconRotation!);
+
+ if (_expanded) {
+ _controller!.value = 1.0;
+ }
+ }
+ }
+
+ void _toggle() {
+ setState(() {
+ _expanded = !_expanded;
+ widget.onExpandChange?.call(_expanded);
+ _notifyRowCountChange();
+ if (_expanded) {
+ _controller!.forward();
+ } else {
+ _controller!.reverse();
+ }
+ });
+ }
+
+ /// Calculates and notifies parent of this item's total visible row count.
+ void _notifyRowCountChange() {
+ final rowCount = _calculateRowCount();
+ widget._onRowCountChange?.call(rowCount);
+ }
+
+ /// Calculates the total number of visible rows for this item including descendants.
+ int _calculateRowCount() {
+ if (!_expanded || widget.children.isEmpty) {
+ return 1; // Just this item
+ }
+ // This item plus all visible children
+ var count = 1;
+ for (var i = 0; i < widget.children.length; i++) {
+ count += _childRowCounts[i] ?? 1;
+ }
+ return count;
+ }
+
+ @override
+ void dispose() {
+ _curvedIconRotation?.dispose();
+ _curvedFade?.dispose();
+ _curvedReveal?.dispose();
+ _controller?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final treeData = FTreeData.maybeOf(context);
+ final indentWidth = treeData?.style.indentWidth ?? context.theme.treeStyle.indentWidth;
+ final textDirection = Directionality.of(context);
+
+ return Column(
+ crossAxisAlignment: textDirection == TextDirection.rtl ? CrossAxisAlignment.end : CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // Main item content
+ FTappable(
+ style: _style!.tappableStyle,
+ focusedOutlineStyle: _style!.focusedOutlineStyle,
+ selected: widget.selected,
+ autofocus: widget.autofocus,
+ focusNode: widget.focusNode,
+ onPress: widget.children.isNotEmpty
+ ? () {
+ _toggle();
+ widget.onPress?.call();
+ }
+ : widget.onPress,
+ onLongPress: widget.onLongPress,
+ onHoverChange: widget.onHoverChange,
+ onStateChange: widget.onStateChange,
+ builder: (_, states, child) => Container(
+ alignment: textDirection == TextDirection.rtl ? Alignment.centerRight : Alignment.centerLeft,
+ padding: _style!.padding,
+ decoration: BoxDecoration(
+ color: _style!.backgroundColor.resolve(states),
+ borderRadius: _style!.borderRadius,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ textDirection: textDirection,
+ spacing: _style!.iconSpacing,
+ children: [
+ if (widget.children.isNotEmpty)
+ IconTheme(
+ data: _style!.expandIconStyle.resolve(states),
+ child: RotationTransition(turns: _iconRotation!, child: const Icon(FIcons.chevronRight)),
+ )
+ else if (widget.icon != null)
+ IconTheme(data: _style!.iconStyle.resolve(states), child: widget.icon!),
+ if (widget.label != null)
+ Flexible(
+ child: DefaultTextStyle.merge(style: _style!.textStyle.resolve(states), child: widget.label!),
+ ),
+ ],
+ ),
+ ),
+ ),
+ // Expandable children
+ if (widget.children.isNotEmpty)
+ AnimatedBuilder(
+ animation: _reveal!,
+ builder: (_, _) => FCollapsible(
+ value: _reveal!.value,
+ child: AnimatedBuilder(
+ animation: _fade!,
+ builder: (context, child) {
+ // Use tracked row counts from children
+ final childRowCounts = [for (var i = 0; i < widget.children.length; i++) _childRowCounts[i] ?? 1];
+
+ return FadeTransition(
+ opacity: _fade!,
+ child: Padding(
+ padding: textDirection == TextDirection.rtl
+ ? EdgeInsets.only(right: indentWidth)
+ : EdgeInsets.only(left: indentWidth),
+ child: CustomPaint(
+ painter: FTreeChildrenLinePainter(
+ lineStyle: _style!.lineStyle,
+ childCount: widget.children.length,
+ childrenSpacing: _style!.childrenSpacing,
+ childRowCounts: childRowCounts,
+ itemPadding: _style!.padding.resolve(textDirection).left,
+ textDirection: textDirection,
+ ),
+ child: Column(
+ crossAxisAlignment: textDirection == TextDirection.rtl
+ ? CrossAxisAlignment.end
+ : CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ spacing: _style!.childrenSpacing,
+ children: [
+ for (var i = 0; i < widget.children.length; i++)
+ FTreeItem._(
+ key: widget.children[i].key,
+ style: widget.children[i].style,
+ icon: widget.children[i].icon,
+ label: widget.children[i].label,
+ selected: widget.children[i].selected,
+ initiallyExpanded: widget.children[i].initiallyExpanded,
+ autofocus: widget.children[i].autofocus,
+ focusNode: widget.children[i].focusNode,
+ onPress: widget.children[i].onPress,
+ onLongPress: widget.children[i].onLongPress,
+ onHoverChange: widget.children[i].onHoverChange,
+ onStateChange: widget.children[i].onStateChange,
+ onExpandChange: (expanded) {
+ setState(() {
+ _childExpansionStates[i] = expanded;
+ });
+ },
+ onRowCountChange: (rowCount) {
+ setState(() {
+ _childRowCounts[i] = rowCount;
+ });
+ // Propagate the change up to our parent
+ _notifyRowCountChange();
+ },
+ children: widget.children[i].children,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// The [FTreeItemStyle] for a [FTreeItem].
+class FTreeItemStyle with Diagnosticable, _$FTreeItemStyleFunctions {
+ /// The background color.
+ ///
+ /// {@macro forui.foundation.doc_templates.WidgetStates.tappable}
+ @override
+ final FWidgetStateMap backgroundColor;
+
+ /// The border radius. Defaults to `BorderRadius.circular(6)`.
+ @override
+ final BorderRadius borderRadius;
+
+ /// The text style.
+ ///
+ /// {@macro forui.foundation.doc_templates.WidgetStates.tappable}
+ @override
+ final FWidgetStateMap textStyle;
+
+ /// The icon style.
+ ///
+ /// {@macro forui.foundation.doc_templates.WidgetStates.tappable}
+ @override
+ final FWidgetStateMap iconStyle;
+
+ /// The expand/collapse icon style.
+ ///
+ /// {@macro forui.foundation.doc_templates.WidgetStates.tappable}
+ @override
+ final FWidgetStateMap expandIconStyle;
+
+ /// The padding around the item content. Defaults to `EdgeInsets.symmetric(horizontal: 8, vertical: 4)`.
+ @override
+ final EdgeInsetsGeometry padding;
+
+ /// The spacing between the icon and label. Defaults to 6.
+ @override
+ final double iconSpacing;
+
+ /// The spacing between child items. Defaults to 2.
+ @override
+ final double childrenSpacing;
+
+ /// The tappable style.
+ @override
+ final FTappableStyle tappableStyle;
+
+ /// The focused outline style.
+ @override
+ final FFocusedOutlineStyle focusedOutlineStyle;
+
+ /// The line style for tree connectors.
+ @override
+ final FTreeLineStyle lineStyle;
+
+ /// The animation style for expand/collapse.
+ @override
+ final FTreeItemMotion motion;
+
+ /// Creates a [FTreeItemStyle].
+ const FTreeItemStyle({
+ required this.backgroundColor,
+ required this.textStyle,
+ required this.iconStyle,
+ required this.expandIconStyle,
+ required this.tappableStyle,
+ required this.focusedOutlineStyle,
+ required this.lineStyle,
+ required this.motion,
+ this.borderRadius = const BorderRadius.all(Radius.circular(6)),
+ this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ this.iconSpacing = 6,
+ this.childrenSpacing = 2,
+ });
+
+ /// Creates a [FTreeItemStyle] that inherits its properties.
+ FTreeItemStyle.inherit({required FColors colors, required FTypography typography, required FStyle style})
+ : this(
+ backgroundColor: FWidgetStateMap({
+ WidgetState.disabled: colors.disable(colors.background),
+ WidgetState.hovered: colors.secondary,
+ WidgetState.selected: colors.secondary,
+ WidgetState.any: Colors.transparent,
+ }),
+ textStyle: FWidgetStateMap({
+ WidgetState.disabled: typography.base.copyWith(color: colors.disable(colors.foreground)),
+ WidgetState.any: typography.base.copyWith(color: colors.foreground),
+ }),
+ iconStyle: FWidgetStateMap({
+ WidgetState.disabled: IconThemeData(color: colors.disable(colors.mutedForeground), size: 16),
+ WidgetState.any: IconThemeData(color: colors.mutedForeground, size: 16),
+ }),
+ expandIconStyle: FWidgetStateMap({
+ WidgetState.disabled: IconThemeData(color: colors.disable(colors.mutedForeground), size: 16),
+ WidgetState.any: IconThemeData(color: colors.mutedForeground, size: 16),
+ }),
+ tappableStyle: FTappableStyle(),
+ focusedOutlineStyle: style.focusedOutlineStyle,
+ lineStyle: FTreeLineStyle.inherit(colors: colors, style: style),
+ motion: FTreeItemMotion(),
+ );
+}
+
+/// The animation style for [FTreeItem] expand/collapse.
+class FTreeItemMotion with Diagnosticable {
+ /// The expand duration. Defaults to 250ms.
+ final Duration expandDuration;
+
+ /// The collapse duration. Defaults to 200ms.
+ final Duration collapseDuration;
+
+ /// The expand curve. Defaults to [Curves.easeInOut].
+ final Curve expandCurve;
+
+ /// The collapse curve. Defaults to [Curves.easeInOut].
+ final Curve collapseCurve;
+
+ /// The icon expand curve. Defaults to [Curves.easeInOut].
+ final Curve iconExpandCurve;
+
+ /// The icon collapse curve. Defaults to [Curves.easeInOut].
+ final Curve iconCollapseCurve;
+
+ /// The reveal tween. Defaults to `Tween(begin: 0.0, end: 1.0)`.
+ final Tween revealTween;
+
+ /// The fade tween. Defaults to `Tween(begin: 0.0, end: 1.0)`.
+ final Tween fadeTween;
+
+ /// The icon rotation tween. Defaults to `Tween(begin: 0.0, end: 0.25)`.
+ final Tween iconTween;
+
+ /// Creates a [FTreeItemMotion].
+ FTreeItemMotion({
+ this.expandDuration = const Duration(milliseconds: 250),
+ this.collapseDuration = const Duration(milliseconds: 200),
+ this.expandCurve = Curves.easeInOut,
+ this.collapseCurve = Curves.easeInOut,
+ this.iconExpandCurve = Curves.easeInOut,
+ this.iconCollapseCurve = Curves.easeInOut,
+ Tween? revealTween,
+ Tween? fadeTween,
+ Tween? iconTween,
+ }) : revealTween = revealTween ?? Tween(begin: 0.0, end: 1.0),
+ fadeTween = fadeTween ?? Tween(begin: 0.0, end: 1.0),
+ iconTween = iconTween ?? Tween(begin: 0.0, end: 0.25);
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('expandDuration', expandDuration))
+ ..add(DiagnosticsProperty('collapseDuration', collapseDuration))
+ ..add(DiagnosticsProperty('expandCurve', expandCurve))
+ ..add(DiagnosticsProperty('collapseCurve', collapseCurve))
+ ..add(DiagnosticsProperty('iconExpandCurve', iconExpandCurve))
+ ..add(DiagnosticsProperty('iconCollapseCurve', iconCollapseCurve))
+ ..add(DiagnosticsProperty('revealTween', revealTween))
+ ..add(DiagnosticsProperty('fadeTween', fadeTween))
+ ..add(DiagnosticsProperty('iconTween', iconTween));
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is FTreeItemMotion &&
+ runtimeType == other.runtimeType &&
+ expandDuration == other.expandDuration &&
+ collapseDuration == other.collapseDuration &&
+ expandCurve == other.expandCurve &&
+ collapseCurve == other.collapseCurve &&
+ iconExpandCurve == other.iconExpandCurve &&
+ iconCollapseCurve == other.iconCollapseCurve &&
+ revealTween == other.revealTween &&
+ fadeTween == other.fadeTween &&
+ iconTween == other.iconTween;
+
+ @override
+ int get hashCode =>
+ expandDuration.hashCode ^
+ collapseDuration.hashCode ^
+ expandCurve.hashCode ^
+ collapseCurve.hashCode ^
+ iconExpandCurve.hashCode ^
+ iconCollapseCurve.hashCode ^
+ revealTween.hashCode ^
+ fadeTween.hashCode ^
+ iconTween.hashCode;
+}
+
+/// The line style for tree connectors.
+///
+/// Note: The [dashPattern] is nullable, which affects equality comparisons.
+class FTreeLineStyle with Diagnosticable, _$FTreeLineStyleFunctions {
+ /// The line color.
+ @override
+ final Color color;
+
+ /// The line width. Defaults to 1.0.
+ @override
+ final double width;
+
+ /// The line dash pattern. If null, the line is solid.
+ @override
+ final List? dashPattern;
+
+ /// Creates a [FTreeLineStyle].
+ const FTreeLineStyle({required this.color, this.width = 1.0, this.dashPattern});
+
+ /// Creates a [FTreeLineStyle] that inherits its properties.
+ FTreeLineStyle.inherit({required FColors colors, required FStyle style})
+ : this(color: colors.border, width: style.borderWidth);
+
+ @override
+ int get hashCode => Object.hash(color, width, Object.hashAll(dashPattern ?? []));
+}
diff --git a/forui/lib/src/widgets/tree/tree_painter.dart b/forui/lib/src/widgets/tree/tree_painter.dart
new file mode 100644
index 000000000..869c65d47
--- /dev/null
+++ b/forui/lib/src/widgets/tree/tree_painter.dart
@@ -0,0 +1,135 @@
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:meta/meta.dart';
+
+import 'package:forui/forui.dart';
+
+/// A custom painter that draws tree connection lines from a parent to its children.
+///
+/// This painter draws:
+/// - A vertical line from the top (parent item) to the last child
+/// - Horizontal lines from the vertical line to each child
+@internal
+class FTreeChildrenLinePainter extends CustomPainter {
+ /// The line style.
+ final FTreeLineStyle lineStyle;
+
+ /// The number of children.
+ final int childCount;
+
+ /// The spacing between children.
+ final double childrenSpacing;
+
+ /// The number of visible rows (including nested children) that each direct child occupies.
+ final List childRowCounts;
+
+ /// The horizontal padding of each tree item.
+ final double itemPadding;
+
+ /// The text direction for drawing lines.
+ final TextDirection textDirection;
+
+ /// Creates a [FTreeChildrenLinePainter].
+ const FTreeChildrenLinePainter({
+ required this.lineStyle,
+ required this.childCount,
+ required this.childrenSpacing,
+ required this.childRowCounts,
+ required this.itemPadding,
+ required this.textDirection,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (childCount == 0) {
+ return;
+ }
+
+ final paint = Paint()
+ ..color = lineStyle.color
+ ..strokeWidth = lineStyle.width
+ ..style = PaintingStyle.stroke;
+
+ // For RTL, vertical line starts at the right edge; for LTR, at the left edge
+ final lineStartX = textDirection == TextDirection.rtl ? size.width : 0.0;
+
+ // Calculate total number of rows and total spacing
+ final totalRows = childRowCounts.reduce((a, b) => a + b);
+
+ // Calculate the height of a single row based on actual rendered size
+ final itemHeight = (size.height) / totalRows;
+
+ // Calculate the vertical center position of each direct child
+ final childPositions = [];
+ var currentY = itemHeight / 2; // Center of first child (which is in the first row)
+
+ for (var i = 0; i < childCount; i++) {
+ childPositions.add(currentY);
+ // Move to next child: advance by this child's row count, plus spacing
+ currentY += childRowCounts[i] * itemHeight;
+ }
+
+ // Draw vertical line from top to last child's center
+ final lastChildY = childPositions.last;
+ if (lineStyle.dashPattern != null) {
+ _drawDashedLine(canvas, Offset(lineStartX, 0), Offset(lineStartX, lastChildY), paint, lineStyle.dashPattern!);
+ } else {
+ canvas.drawLine(Offset(lineStartX, 0), Offset(lineStartX, lastChildY), paint);
+ }
+
+ // Draw horizontal lines to each child
+ // For RTL: line goes from right edge inward; for LTR: from left edge inward
+ final horizontalEndX = textDirection == TextDirection.rtl
+ ? size.width - (itemPadding - 2) // Start from right edge, go left
+ : itemPadding - 2; // Stop 2px before the icon area
+
+ for (final childY in childPositions) {
+ if (lineStyle.dashPattern != null) {
+ _drawDashedLine(
+ canvas,
+ Offset(lineStartX, childY),
+ Offset(horizontalEndX, childY),
+ paint,
+ lineStyle.dashPattern!,
+ );
+ } else {
+ canvas.drawLine(Offset(lineStartX, childY), Offset(horizontalEndX, childY), paint);
+ }
+ }
+ }
+
+ void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint, List dashPattern) {
+ final path = Path()
+ ..moveTo(start.dx, start.dy)
+ ..lineTo(end.dx, end.dy);
+
+ final metric = path.computeMetrics().first;
+ var distance = 0.0;
+ var drawDash = true;
+ var dashIndex = 0;
+
+ while (distance < metric.length) {
+ final dashLength = dashPattern[dashIndex % dashPattern.length];
+ if (drawDash) {
+ final extractPath = metric.extractPath(
+ distance,
+ distance + dashLength > metric.length ? metric.length : distance + dashLength,
+ );
+ canvas.drawPath(extractPath, paint);
+ }
+ distance += dashLength;
+ drawDash = !drawDash;
+ dashIndex++;
+ }
+ }
+
+ @override
+ bool shouldRepaint(FTreeChildrenLinePainter oldDelegate) =>
+ lineStyle != oldDelegate.lineStyle ||
+ childCount != oldDelegate.childCount ||
+ childrenSpacing != oldDelegate.childrenSpacing ||
+ childRowCounts != oldDelegate.childRowCounts ||
+ itemPadding != oldDelegate.itemPadding ||
+ textDirection != oldDelegate.textDirection;
+}
diff --git a/forui/lib/widgets/tree.dart b/forui/lib/widgets/tree.dart
new file mode 100644
index 000000000..4d17344e0
--- /dev/null
+++ b/forui/lib/widgets/tree.dart
@@ -0,0 +1,10 @@
+/// {@category Widgets}
+///
+/// A hierarchical tree widget for displaying nested data with visual connecting lines.
+///
+/// See https://forui.dev/docs/data/tree for working examples.
+library forui.widgets.tree;
+
+export '../src/widgets/tree/tree.dart';
+export '../src/widgets/tree/tree_controller.dart';
+export '../src/widgets/tree/tree_item.dart';
diff --git a/forui/test/golden/macos/tree/zinc-dark/default.png b/forui/test/golden/macos/tree/zinc-dark/default.png
new file mode 100644
index 000000000..3f6fea68d
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/default.png differ
diff --git a/forui/test/golden/macos/tree/zinc-dark/nested.png b/forui/test/golden/macos/tree/zinc-dark/nested.png
new file mode 100644
index 000000000..963a59a14
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/nested.png differ
diff --git a/forui/test/golden/macos/tree/zinc-dark/rtl-default.png b/forui/test/golden/macos/tree/zinc-dark/rtl-default.png
new file mode 100644
index 000000000..83d772309
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/rtl-default.png differ
diff --git a/forui/test/golden/macos/tree/zinc-dark/rtl-nested.png b/forui/test/golden/macos/tree/zinc-dark/rtl-nested.png
new file mode 100644
index 000000000..3d3999b7e
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/rtl-nested.png differ
diff --git a/forui/test/golden/macos/tree/zinc-dark/selected.png b/forui/test/golden/macos/tree/zinc-dark/selected.png
new file mode 100644
index 000000000..a7d794bab
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/selected.png differ
diff --git a/forui/test/golden/macos/tree/zinc-light/default.png b/forui/test/golden/macos/tree/zinc-light/default.png
new file mode 100644
index 000000000..a327df4a8
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/default.png differ
diff --git a/forui/test/golden/macos/tree/zinc-light/nested.png b/forui/test/golden/macos/tree/zinc-light/nested.png
new file mode 100644
index 000000000..a8ec757d3
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/nested.png differ
diff --git a/forui/test/golden/macos/tree/zinc-light/rtl-default.png b/forui/test/golden/macos/tree/zinc-light/rtl-default.png
new file mode 100644
index 000000000..130bf5eb8
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/rtl-default.png differ
diff --git a/forui/test/golden/macos/tree/zinc-light/rtl-nested.png b/forui/test/golden/macos/tree/zinc-light/rtl-nested.png
new file mode 100644
index 000000000..3c124cf38
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/rtl-nested.png differ
diff --git a/forui/test/golden/macos/tree/zinc-light/selected.png b/forui/test/golden/macos/tree/zinc-light/selected.png
new file mode 100644
index 000000000..d9af33084
Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/selected.png differ
diff --git a/forui/test/src/widgets/tree/tree_golden_test.dart b/forui/test/src/widgets/tree/tree_golden_test.dart
new file mode 100644
index 000000000..82eee2de7
--- /dev/null
+++ b/forui/test/src/widgets/tree/tree_golden_test.dart
@@ -0,0 +1,182 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import '../../test_scaffold.dart';
+
+void main() {
+ group('blue screen', () {
+ testWidgets('FTree', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.blue(
+ child: FTree(
+ style: TestScaffold.blueScreen.treeStyle,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Folder 1'),
+ children: [FTreeItem(icon: const Icon(FIcons.file), label: const Text('File 1'), onPress: () {})],
+ ),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Folder 2'), onPress: () {}),
+ ],
+ ),
+ ),
+ );
+
+ await expectBlueScreen();
+ });
+ });
+
+ for (final theme in TestScaffold.themes) {
+ testWidgets('default - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ theme: theme.data,
+ child: FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Apple'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Red Apple'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Green Apple'), onPress: () {}),
+ ],
+ ),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Banana'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Cherry'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.file), label: const Text('Date'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Elderberry'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Fig'), onPress: () {}),
+ ],
+ ),
+ ),
+ );
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/default.png'));
+ });
+
+ testWidgets('nested - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ theme: theme.data,
+ child: FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 1'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 2'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 3'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(icon: const Icon(FIcons.file), label: const Text('Deep File'), onPress: () {}),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/nested.png'));
+ });
+
+ testWidgets('selected - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ theme: theme.data,
+ child: FTree(
+ children: [
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Item 1'), selected: true, onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Item 2'), onPress: () {}),
+ ],
+ ),
+ ),
+ );
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/selected.png'));
+ });
+
+ testWidgets('rtl-default - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ theme: theme.data,
+ child: Directionality(
+ textDirection: TextDirection.rtl,
+ child: FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Apple'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Red Apple'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Green Apple'), onPress: () {}),
+ ],
+ ),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Banana'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Cherry'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.file), label: const Text('Date'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Elderberry'), onPress: () {}),
+ FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Fig'), onPress: () {}),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/rtl-default.png'));
+ });
+
+ testWidgets('rtl-nested - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ theme: theme.data,
+ child: Directionality(
+ textDirection: TextDirection.rtl,
+ child: FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 1'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 2'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 3'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(icon: const Icon(FIcons.file), label: const Text('Deep File'), onPress: () {}),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/rtl-nested.png'));
+ });
+ }
+}
diff --git a/forui/test/src/widgets/tree/tree_test.dart b/forui/test/src/widgets/tree/tree_test.dart
new file mode 100644
index 000000000..8e577cdf2
--- /dev/null
+++ b/forui/test/src/widgets/tree/tree_test.dart
@@ -0,0 +1,127 @@
+import 'package:flutter/widgets.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import '../../test_scaffold.dart';
+
+void main() {
+ group('FTree', () {
+ testWidgets('renders basic tree', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ child: const FTree(
+ children: [
+ FTreeItem(label: Text('Item 1')),
+ FTreeItem(label: Text('Item 2')),
+ ],
+ ),
+ ),
+ );
+
+ expect(find.text('Item 1'), findsOneWidget);
+ expect(find.text('Item 2'), findsOneWidget);
+ });
+
+ testWidgets('renders nested tree collapsed by default', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ child: const FTree(
+ children: [
+ FTreeItem(
+ label: Text('Parent'),
+ children: [
+ FTreeItem(label: Text('Child 1')),
+ FTreeItem(label: Text('Child 2')),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+
+ expect(find.text('Parent'), findsOneWidget);
+ // Note: Children exist in the widget tree but are hidden via FCollapsible
+ // They will be found but not visible
+ });
+
+ testWidgets('expands and collapses when pressed', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ child: const FTree(
+ children: [
+ FTreeItem(
+ label: Text('Parent'),
+ children: [FTreeItem(label: Text('Child'))],
+ ),
+ ],
+ ),
+ ),
+ );
+
+ // Tap to expand
+ await tester.tap(find.text('Parent'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Child'), findsOneWidget);
+
+ // Tap to collapse
+ await tester.tap(find.text('Parent'));
+ await tester.pumpAndSettle();
+
+ // Child still exists but is collapsed
+ expect(find.text('Parent'), findsOneWidget);
+ });
+
+ testWidgets('initially expanded shows children', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold(
+ child: const FTree(
+ children: [
+ FTreeItem(
+ label: Text('Parent'),
+ initiallyExpanded: true,
+ children: [FTreeItem(label: Text('Child'))],
+ ),
+ ],
+ ),
+ ),
+ );
+
+ expect(find.text('Parent'), findsOneWidget);
+ expect(find.text('Child'), findsOneWidget);
+ });
+ });
+
+ group('FTreeController', () {
+ test('expands item', () {
+ final controller = autoDispose(FTreeController());
+
+ expect(controller.isExpanded(0), false);
+
+ controller.expand(0);
+ expect(controller.isExpanded(0), true);
+ });
+
+ test('collapses item', () {
+ final controller = autoDispose(FTreeController(expanded: {0}));
+
+ expect(controller.isExpanded(0), true);
+
+ controller.collapse(0);
+ expect(controller.isExpanded(0), false);
+ });
+
+ test('toggles item', () {
+ final controller = autoDispose(FTreeController());
+
+ expect(controller.isExpanded(0), false);
+
+ controller.toggle(0);
+ expect(controller.isExpanded(0), true);
+
+ controller.toggle(0);
+ expect(controller.isExpanded(0), false);
+ });
+ });
+}
diff --git a/samples/lib/main.dart b/samples/lib/main.dart
index 73c60032e..e9c322c60 100644
--- a/samples/lib/main.dart
+++ b/samples/lib/main.dart
@@ -198,5 +198,6 @@ class _AppRouter extends RootStackRouter {
AutoRoute(path: '/toast/behavior', page: BehaviorToastRoute.page),
AutoRoute(path: '/toast/swipe', page: SwipeToastRoute.page),
AutoRoute(path: '/tooltip/default', page: TooltipRoute.page),
+ AutoRoute(path: '/tree/default', page: TreeRoute.page),
];
}
diff --git a/samples/lib/widgets/tree.dart b/samples/lib/widgets/tree.dart
new file mode 100644
index 000000000..f18b63ac4
--- /dev/null
+++ b/samples/lib/widgets/tree.dart
@@ -0,0 +1,43 @@
+import 'package:flutter/widgets.dart';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:forui/forui.dart';
+
+import 'package:forui_samples/sample.dart';
+
+@RoutePage()
+class TreePage extends Sample {
+ final bool rtl;
+
+ TreePage({@queryParam super.theme, @queryParam this.rtl = false});
+
+ @override
+ Widget sample(BuildContext context) {
+ final tree = FTree(
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 1'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 2'),
+ initiallyExpanded: true,
+ children: [
+ FTreeItem(
+ icon: const Icon(FIcons.folder),
+ label: const Text('Level 3'),
+ initiallyExpanded: true,
+ children: [FTreeItem(icon: const Icon(FIcons.file), label: const Text('Deep File'), onPress: () {})],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+
+ return rtl ? Directionality(textDirection: TextDirection.rtl, child: tree) : tree;
+ }
+}