Flutter力导向图绘制插件flutter_force_directed_graph的使用
Flutter Force Directed Graph
Flutter Force Directed Graph 是一个高性能、可定制的 Flutter 插件,帮助你在 Flutter 应用中创建力导向图可视化。
特性
- 创建具有可自定义节点和边的力导向图。
- 动态添加、移除或更新节点和边。
- 使用提供的
ForceDirectedGraphWidget
方便地集成到你的应用中。 - 内置手势检测用于节点、边以及图形的平移和缩放。
- 提供
ForceDirectedGraphController
以方便管理图表的状态。
安装
在你的 pubspec.yaml
文件中的 dependencies
下添加:
dependencies:
flutter_force_directed_graph: ^1.0.6
然后通过运行 flutter pub get
安装它。
使用示例
下面是一个基本的例子,展示了如何使用 ForceDirectedGraphWidget
和 ForceDirectedGraphController
。这个例子还包含了一个简单的用户界面,允许用户添加/删除节点和边,并且可以通过拖拽节点来调整位置。
示例代码
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_force_directed_graph/flutter_force_directed_graph.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Force Directed Graph Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Force Directed Graph Demo'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late final ForceDirectedGraphController<int> _controller =
ForceDirectedGraphController(
graph: ForceDirectedGraph.generateNTree(
nodeCount: 50,
maxDepth: 3,
n: 4,
generator: () {
_nodeCount++;
return _nodeCount;
},
),
)..setOnScaleChange((scale) {
if (!mounted) return;
setState(() {
_scale = scale;
});
});
int _nodeCount = 0;
final Set<int> _nodes = {};
final Set<String> _edges = {};
double _scale = 1.0;
int _locatedTo = 0;
int? _draggingData;
String? _json;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_controller.needUpdate();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
children: [
_buildMenu(context),
Expanded(
child: ForceDirectedGraphWidget(
controller: _controller,
onDraggingStart: (data) {
setState(() {
_draggingData = data;
});
},
onDraggingEnd: (data) {
setState(() {
_draggingData = null;
});
},
onDraggingUpdate: (data) {},
nodesBuilder: (context, data) {
final Color color;
if (_draggingData == data) {
color = Colors.yellow;
} else if (_nodes.contains(data)) {
color = Colors.green;
} else {
color = Colors.red;
}
return GestureDetector(
onTap: () {
print("onTap $data");
setState(() {
if (_nodes.contains(data)) {
_nodes.remove(data);
} else {
_nodes.add(data);
}
});
},
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: _scale > 0.5 ? Text('$data') : null,
),
);
},
edgesBuilder: (context, a, b, distance) {
final Color color;
if (_draggingData == a || _draggingData == b) {
color = Colors.yellow;
} else if (_edges.contains("$a <-> $b")) {
color = Colors.green;
} else {
color = Colors.blue;
}
return GestureDetector(
onTap: () {
final edge = "$a <-> $b";
setState(() {
if (_edges.contains(edge)) {
_edges.remove(edge);
} else {
_edges.add(edge);
}
print("onTap $a <-$distance-> $b");
});
},
child: Container(
width: distance,
height: 16,
color: color,
alignment: Alignment.center,
child: _scale > 0.5 ? Text('$a <-> $b') : null,
),
);
},
),
),
],
),
);
}
Widget _buildMenu(BuildContext context) {
return Wrap(
children: [
ElevatedButton(
onPressed: () {
_nodeCount++;
_controller.addNode(_nodeCount);
_nodes.clear();
_edges.clear();
},
child: const Text('add node'),
),
ElevatedButton(
onPressed: _nodes.isEmpty
? null
: () {
for (final node in _nodes) {
_controller.deleteNodeByData(node);
}
_nodes.clear();
_edges.clear();
},
child: const Text('del node'),
),
const SizedBox(width: 4),
ElevatedButton(
onPressed: _nodes.length > 2
? null
: () {
if (_nodes.length == 2) {
final a = _nodes.first;
final b = _nodes.last;
_controller.addEdgeByData(a, b);
} else if (_nodes.length == 1) {
final a = _nodes.first;
final l = _controller.graph.nodes.length;
final random = Random();
final randomB =
_controller.graph.nodes[random.nextInt(l)].data;
try {
if (a != randomB) {
_controller.addEdgeByData(a, randomB);
}
} catch (e) {
// ignore
}
} else if (_nodes.isEmpty) {
final l = _controller.graph.nodes.length;
final random = Random();
final randomA = _controller.graph.nodes[random.nextInt(l)];
final randomB = _controller.graph.nodes[random.nextInt(l)];
try {
if (randomA != randomB) {
_controller.addEdgeByNode(randomA, randomB);
}
} catch (e) {
// ignore
}
}
_nodes.clear();
_edges.clear();
},
child: const Text('add edge'),
),
ElevatedButton(
onPressed: _edges.isEmpty
? null
: () {
for (final edge in _edges) {
final a = int.parse(edge.split(' <-> ').first);
final b = int.parse(edge.split(' <-> ').last);
_controller.deleteEdgeByData(a, b);
}
_nodes.clear();
_edges.clear();
},
child: const Text('del edge'),
),
ElevatedButton(
onPressed: () {
_controller.needUpdate();
},
child: const Text('update'),
),
ElevatedButton(
onPressed: () async {
final result = await _showTreeDialogWithInput(context);
if (result == null) return;
setState(() {
_clearData();
_controller.graph = ForceDirectedGraph.generateNTree(
nodeCount: result['nodeCount'] as int,
maxDepth: result['maxDepth'] as int,
n: result['n'] as int,
generator: () {
_nodeCount++;
return _nodeCount;
},
);
});
},
child: const Text('new tree'),
),
ElevatedButton(
onPressed: () async {
final result = await _showNodeDialogWithInput(context);
if (result == null) return;
setState(() {
_clearData();
_controller.graph = ForceDirectedGraph.generateNNodes(
nodeCount: result['nodeCount'] as int,
generator: () {
_nodeCount++;
return _nodeCount;
},
);
});
},
child: const Text('new nodes'),
),
ElevatedButton(
onPressed: () {
_controller.center();
},
child: const Text('center'),
),
ElevatedButton(
onPressed: () {
setState(() {
_locatedTo++;
_locatedTo = _locatedTo % _controller.graph.nodes.length;
final data = _controller.graph.nodes[_locatedTo].data;
_controller.locateTo(data);
});
},
child: Text('locateTo ${_controller.graph.nodes[_locatedTo].data}'),
),
ElevatedButton(
onPressed: () {
setState(() {
if (_json != null) {
_controller.graph = ForceDirectedGraph.fromJson(_json!);
_clearData();
_json = null;
} else {
_json = _controller.toJson();
}
});
},
child: Text(_json == null ? 'save' : 'load'),
),
ElevatedButton(
onPressed: () {
_controller.scale = 1;
},
child: const Text('reset'),
),
Slider(
value: _scale,
min: _controller.minScale,
max: _controller.maxScale,
onChanged: (value) {
_controller.scale = value;
},
),
],
);
}
void _clearData() {
_nodes.clear();
_edges.clear();
_nodeCount = 0;
_locatedTo = 0;
}
Future<Map<String, int>?> _showTreeDialogWithInput(BuildContext context) {
final TextEditingController nodeCountController =
TextEditingController(text: '50');
final TextEditingController maxDepthController =
TextEditingController(text: '3');
final TextEditingController nController = TextEditingController(text: '3');
return showDialog<Map<String, int>>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Enter Values'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: nodeCountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: "Node Count"),
),
TextField(
controller: maxDepthController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: "Max Depth"),
),
TextField(
controller: nController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: "Max Children"))
],
),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop(null);
},
),
TextButton(
child: const Text('Submit'),
onPressed: () {
try {
final result = {
'nodeCount': int.parse(nodeCountController.text),
'maxDepth': int.parse(maxDepthController.text),
'n': int.parse(nController.text),
};
Navigator.of(context).pop(result);
} catch (e) {
Navigator.of(context).pop(null);
}
},
),
],
);
},
);
}
Future<Map<String, int>?> _showNodeDialogWithInput(BuildContext context) {
final TextEditingController nodeCountController =
TextEditingController(text: '50');
return showDialog<Map<String, int>>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Enter Values'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: nodeCountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: "Node Count"),
),
],
),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop(null);
},
),
TextButton(
child: const Text('Submit'),
onPressed: () {
try {
final result = {
'nodeCount': int.parse(nodeCountController.text),
};
Navigator.of(context).pop(result);
} catch (e) {
Navigator.of(context).pop(null);
}
},
),
],
);
},
);
}
}
解释
- 控制器初始化:
ForceDirectedGraphController
被用来管理图表的状态。我们通过generateNTree
方法生成一个树形结构的初始数据。 - 节点和边的构建:
nodesBuilder
和edgesBuilder
回调函数分别用于定义节点和边的外观。这里根据是否正在拖动节点或边来改变颜色。 - 用户交互:提供了按钮来添加、删除节点和边,以及重置、保存、加载图表等操作。
- 动态更新:通过
_controller.needUpdate()
方法可以触发图表的重新布局。
这个示例展示了如何使用 flutter_force_directed_graph
插件创建一个交互式的力导向图,并提供了丰富的功能让用户能够与图表进行互动。
更多关于Flutter力导向图绘制插件flutter_force_directed_graph的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html
更多关于Flutter力导向图绘制插件flutter_force_directed_graph的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html
当然,以下是如何在Flutter项目中使用flutter_force_directed_graph
插件来绘制力导向图的示例代码。这个插件允许你以图形化的方式展示节点和边之间的关系,并应用力导向算法进行布局优化。
1. 添加依赖
首先,你需要在你的pubspec.yaml
文件中添加flutter_force_directed_graph
依赖:
dependencies:
flutter:
sdk: flutter
flutter_force_directed_graph: ^最新版本号 # 请替换为最新版本号
然后运行flutter pub get
来安装依赖。
2. 导入插件
在你的Dart文件中导入插件:
import 'package:flutter_force_directed_graph/flutter_force_directed_graph.dart';
3. 示例代码
以下是一个完整的示例,展示如何使用flutter_force_directed_graph
绘制一个力导向图:
import 'package:flutter/material.dart';
import 'package:flutter_force_directed_graph/flutter_force_directed_graph.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Force Directed Graph Example',
home: Scaffold(
appBar: AppBar(
title: Text('Force Directed Graph Example'),
),
body: Center(
child: ForceDirectedGraph(
nodes: _createNodes(),
links: _createLinks(),
nodeBuilder: (context, nodeData) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
),
width: 30,
height: 30,
child: Center(
child: Text(
nodeData.id.toString(),
style: TextStyle(color: Colors.white),
),
),
);
},
linkBuilder: (context, linkData) {
return CustomPaint(
painter: LinePainter(color: Colors.grey),
size: Size.infinite,
);
},
// Optional: Customize the layout settings
layoutSettings: LayoutSettings(
initialRepulsion: 1000,
damping: 0.85,
desiredLinkDistance: 150,
),
),
),
),
);
}
List<NodeData> _createNodes() {
return [
NodeData(id: 'A'),
NodeData(id: 'B'),
NodeData(id: 'C'),
NodeData(id: 'D'),
NodeData(id: 'E'),
];
}
List<LinkData> _createLinks() {
return [
LinkData(source: 'A', target: 'B'),
LinkData(source: 'A', target: 'C'),
LinkData(source: 'B', target: 'D'),
LinkData(source: 'C', target: 'E'),
LinkData(source: 'D', target: 'E'),
];
}
}
class NodeData {
final String id;
NodeData({required this.id});
}
class LinkData {
final String source;
final String target;
LinkData({required this.source, required this.target});
}
class LinePainter extends CustomPainter {
final Color color;
LinePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = color
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
// You would need to set the actual start and end points based on link data
// Here we just demonstrate a line from (0, 0) to (size.width, size.height)
canvas.drawLine(Offset.zero, Offset(size.width, size.height), paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate != this;
}
}
4. 注意事项
- 节点和边的数据:在实际应用中,你可能需要从后端API或其他数据源获取节点和边的数据。
- 自定义节点和边:上述示例中的
nodeBuilder
和linkBuilder
允许你自定义节点和边的外观。你可以根据需要进一步自定义这些组件。 - 布局设置:
LayoutSettings
类允许你调整力导向图的布局参数,如初始排斥力、阻尼和期望的链接距离等。
这个示例展示了基本的用法,你可以根据需求进一步扩展和自定义。