You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

339 lines
10 KiB

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),
],
);
}
}