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.
286 lines
7.9 KiB
286 lines
7.9 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; |
|
|
|
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, |
|
}); |
|
} |
|
|
|
/// 通用树视图组件 |
|
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: [ |
|
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)) |
|
: 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), |
|
], |
|
); |
|
} |
|
}
|
|
|