|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
|
|
/// 树节点数据接口
|
|
|
|
abstract class TreeNode {
|
|
|
|
String get id;
|
|
|
|
String get name;
|
|
|
|
String get title;
|
|
|
|
bool get isExpanded;
|
|
|
|
bool get isDirectory;
|
|
|
|
List<TreeNode> get children;
|
|
|
|
int get depth;
|
|
|
|
IconData? get iconData;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 树视图配置
|
|
|
|
class TreeViewConfig {
|
|
|
|
final bool lazyLoad;
|
|
|
|
final bool singleSelect;
|
|
|
|
final bool showCheckboxes;
|
|
|
|
final bool showIcons;
|
|
|
|
final Color? selectedColor;
|
|
|
|
final double indentWidth;
|
|
|
|
final Map<String, IconData> icons;
|
|
|
|
final bool showRefreshButton;
|
|
|
|
final IconData refreshIcon;
|
|
|
|
final bool draggable; // 是否可拖拽
|
|
|
|
final bool droppable; // 是否可作为拖放目标
|
|
|
|
|
|
|
|
const TreeViewConfig({
|
|
|
|
this.lazyLoad = false,
|
|
|
|
this.singleSelect = false,
|
|
|
|
this.showCheckboxes = false,
|
|
|
|
this.showIcons = true,
|
|
|
|
this.selectedColor,
|
|
|
|
this.indentWidth = 24.0,
|
|
|
|
this.icons = const {},
|
|
|
|
this.showRefreshButton = false,
|
|
|
|
this.refreshIcon = Icons.refresh,
|
|
|
|
this.draggable = false,
|
|
|
|
this.droppable = false,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 通用树视图组件
|
|
|
|
class TreeView extends StatefulWidget {
|
|
|
|
final List<TreeNode> nodes;
|
|
|
|
final TreeViewConfig config;
|
|
|
|
final Function(TreeNode)? onNodeTap;
|
|
|
|
final Function(TreeNode)? onNodeDoubleTap;
|
|
|
|
final Function(TreeNode, bool?)? onNodeCheckChanged;
|
|
|
|
final Widget Function(BuildContext, TreeNode, bool, VoidCallback)? nodeBuilder;
|
|
|
|
final ScrollController? scrollController;
|
|
|
|
final VoidCallback? onRefresh;
|
|
|
|
|
|
|
|
const TreeView({
|
|
|
|
super.key,
|
|
|
|
required this.nodes,
|
|
|
|
this.config = const TreeViewConfig(),
|
|
|
|
this.onNodeTap,
|
|
|
|
this.onNodeDoubleTap,
|
|
|
|
this.onNodeCheckChanged,
|
|
|
|
this.nodeBuilder,
|
|
|
|
this.scrollController,
|
|
|
|
this.onRefresh,
|
|
|
|
});
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<TreeView> createState() => _TreeViewState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TreeViewState extends State<TreeView> {
|
|
|
|
final Set<String> _selectedIds = {};
|
|
|
|
final Set<String> _checkedIds = {};
|
|
|
|
late ScrollController _effectiveController;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
_effectiveController = widget.scrollController ?? ScrollController();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void didUpdateWidget(TreeView oldWidget) {
|
|
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
if (widget.scrollController != oldWidget.scrollController) {
|
|
|
|
if (oldWidget.scrollController == null) {
|
|
|
|
_effectiveController.dispose();
|
|
|
|
}
|
|
|
|
_effectiveController = widget.scrollController ?? ScrollController();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
if (widget.scrollController == null) {
|
|
|
|
_effectiveController.dispose();
|
|
|
|
}
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Stack(
|
|
|
|
children: [
|
|
|
|
DragTarget<TreeNode>(
|
|
|
|
onWillAcceptWithDetails: (data) {
|
|
|
|
// 检查是否允许拖放
|
|
|
|
if (!widget.config.droppable) return false;
|
|
|
|
return true;
|
|
|
|
},
|
|
|
|
onAcceptWithDetails: (draggedNode) {
|
|
|
|
// 处理拖拽放置逻辑
|
|
|
|
// 你可以在这里实现节点移动或排序的逻辑
|
|
|
|
|
|
|
|
debugPrint('Dropped ${draggedNode.data.name}');
|
|
|
|
},
|
|
|
|
builder: (context, candidateData, rejectedData) {
|
|
|
|
return ListView.builder(
|
|
|
|
controller: _effectiveController,
|
|
|
|
physics: const ClampingScrollPhysics(),
|
|
|
|
itemCount: _countVisibleNodes(widget.nodes),
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
final node = _getVisibleNode(widget.nodes, index);
|
|
|
|
final isSelected = _selectedIds.contains(node.id);
|
|
|
|
|
|
|
|
return widget.nodeBuilder != null
|
|
|
|
? widget.nodeBuilder!(context, node, isSelected, () => _handleNodeTap(node))
|
|
|
|
: Draggable<TreeNode>(
|
|
|
|
data: node,
|
|
|
|
feedback: Material(
|
|
|
|
child: Container(
|
|
|
|
width: 200,
|
|
|
|
padding: const EdgeInsets.all(8),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Theme.of(context).cardColor,
|
|
|
|
borderRadius: BorderRadius.circular(4),
|
|
|
|
boxShadow: [
|
|
|
|
BoxShadow(
|
|
|
|
color: Colors.black.withOpacity(0.2),
|
|
|
|
blurRadius: 4,
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
child: Text(node.title),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
childWhenDragging: Opacity(
|
|
|
|
opacity: 0.5,
|
|
|
|
child: TreeNodeWidget(
|
|
|
|
key: ValueKey(node.id),
|
|
|
|
node: node,
|
|
|
|
config: widget.config,
|
|
|
|
isSelected: isSelected,
|
|
|
|
isChecked: _checkedIds.contains(node.id),
|
|
|
|
onTap: () => _handleNodeTap(node),
|
|
|
|
onDoubleTap: () => widget.onNodeDoubleTap?.call(node),
|
|
|
|
onCheckChanged: (value) => _handleNodeCheckChanged(node, value),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
child: TreeNodeWidget(
|
|
|
|
key: ValueKey(node.id),
|
|
|
|
node: node,
|
|
|
|
config: widget.config,
|
|
|
|
isSelected: isSelected,
|
|
|
|
isChecked: _checkedIds.contains(node.id),
|
|
|
|
onTap: () => _handleNodeTap(node),
|
|
|
|
onDoubleTap: () => widget.onNodeDoubleTap?.call(node),
|
|
|
|
onCheckChanged: (value) => _handleNodeCheckChanged(node, value),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
|
|
|
|
// 刷新按钮
|
|
|
|
if (widget.config.showRefreshButton && widget.onRefresh != null)
|
|
|
|
Positioned(
|
|
|
|
right: 8.0,
|
|
|
|
top: 8.0,
|
|
|
|
child: IconButton(
|
|
|
|
icon: Icon(
|
|
|
|
widget.config.refreshIcon,
|
|
|
|
size: 20.0,
|
|
|
|
color: Theme.of(context).primaryColor,
|
|
|
|
),
|
|
|
|
onPressed: widget.onRefresh,
|
|
|
|
tooltip: '刷新',
|
|
|
|
splashRadius: 16.0,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleNodeTap(TreeNode node) {
|
|
|
|
if (widget.config.singleSelect && !node.isDirectory) {
|
|
|
|
// 只处理叶子节点的单选逻辑
|
|
|
|
setState(() {
|
|
|
|
_selectedIds.clear();
|
|
|
|
_selectedIds.add(node.id);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
widget.onNodeTap?.call(node);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleNodeCheckChanged(TreeNode node, bool? value) {
|
|
|
|
setState(() {
|
|
|
|
if (value == true) {
|
|
|
|
_checkedIds.add(node.id);
|
|
|
|
} else {
|
|
|
|
_checkedIds.remove(node.id);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
widget.onNodeCheckChanged?.call(node, value);
|
|
|
|
}
|
|
|
|
|
|
|
|
int _countVisibleNodes(List<TreeNode> nodes) {
|
|
|
|
int count = 0;
|
|
|
|
for (final node in nodes) {
|
|
|
|
count++;
|
|
|
|
if (node.isDirectory && node.isExpanded) {
|
|
|
|
count += _countVisibleNodes(node.children);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return count;
|
|
|
|
}
|
|
|
|
|
|
|
|
TreeNode _getVisibleNode(List<TreeNode> nodes, int index) {
|
|
|
|
int current = 0;
|
|
|
|
for (final node in nodes) {
|
|
|
|
if (current == index) return node;
|
|
|
|
current++;
|
|
|
|
|
|
|
|
if (node.isDirectory && node.isExpanded) {
|
|
|
|
final childCount = _countVisibleNodes(node.children);
|
|
|
|
if (index - current < childCount) {
|
|
|
|
return _getVisibleNode(node.children, index - current);
|
|
|
|
}
|
|
|
|
current += childCount;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
throw Exception('Index out of bounds: $index');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class TreeNodeWidget extends StatelessWidget {
|
|
|
|
final TreeNode node;
|
|
|
|
final TreeViewConfig config;
|
|
|
|
final bool isSelected;
|
|
|
|
final bool isChecked;
|
|
|
|
final VoidCallback onTap;
|
|
|
|
final VoidCallback onDoubleTap;
|
|
|
|
final Function(bool?)? onCheckChanged;
|
|
|
|
|
|
|
|
const TreeNodeWidget({
|
|
|
|
super.key,
|
|
|
|
required this.node,
|
|
|
|
required this.config,
|
|
|
|
required this.isSelected,
|
|
|
|
required this.isChecked,
|
|
|
|
required this.onTap,
|
|
|
|
required this.onDoubleTap,
|
|
|
|
this.onCheckChanged,
|
|
|
|
});
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return InkWell(
|
|
|
|
key: ValueKey(node.id),
|
|
|
|
onTap: onTap,
|
|
|
|
onDoubleTap: onDoubleTap,
|
|
|
|
splashColor: Colors.transparent,
|
|
|
|
highlightColor: Colors.grey.withOpacity(0.1),
|
|
|
|
child: Container(
|
|
|
|
color:
|
|
|
|
isSelected
|
|
|
|
? (config.selectedColor ?? Theme.of(context).primaryColor.withOpacity(0.1))
|
|
|
|
: Colors.transparent,
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 0),
|
|
|
|
child: ListTile(
|
|
|
|
dense: true,
|
|
|
|
visualDensity: const VisualDensity(vertical: -4),
|
|
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 2),
|
|
|
|
minVerticalPadding: 0,
|
|
|
|
leading: _buildLeadingWidget(context),
|
|
|
|
title: Text(node.title, style: Theme.of(context).textTheme.bodyMedium),
|
|
|
|
trailing:
|
|
|
|
config.showCheckboxes
|
|
|
|
? Transform.scale(
|
|
|
|
scale: 0.75,
|
|
|
|
child: Checkbox(
|
|
|
|
value: isChecked,
|
|
|
|
onChanged: onCheckChanged,
|
|
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: null,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildLeadingWidget(BuildContext context) {
|
|
|
|
return Row(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
// 缩进线
|
|
|
|
...List.generate(node.depth, (index) {
|
|
|
|
return Padding(
|
|
|
|
padding: EdgeInsets.only(left: 6, right: config.indentWidth - 6),
|
|
|
|
child: Container(width: 1.0, height: 32.0, color: Colors.grey[500]),
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
|
|
|
|
// 展开/折叠图标
|
|
|
|
if (node.isDirectory)
|
|
|
|
Icon(
|
|
|
|
node.isExpanded ? Icons.expand_more : Icons.chevron_right,
|
|
|
|
color: Colors.cyan[200],
|
|
|
|
size: 20,
|
|
|
|
),
|
|
|
|
|
|
|
|
// 节点图标
|
|
|
|
if (config.showIcons)
|
|
|
|
node.isDirectory
|
|
|
|
? Icon(
|
|
|
|
node.isExpanded ? Icons.folder_open : Icons.folder,
|
|
|
|
color: Colors.cyan[500],
|
|
|
|
size: 18,
|
|
|
|
)
|
|
|
|
: Icon(node.iconData ?? Icons.insert_drive_file, color: Colors.amber[700], size: 20),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|