Flutter力导向图绘制插件flutter_force_directed_graph的使用

发布于 1周前 作者 zlyuanteng 来自 Flutter

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 安装它。

使用示例

下面是一个基本的例子,展示了如何使用 ForceDirectedGraphWidgetForceDirectedGraphController。这个例子还包含了一个简单的用户界面,允许用户添加/删除节点和边,并且可以通过拖拽节点来调整位置。

示例代码

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 方法生成一个树形结构的初始数据。
  • 节点和边的构建nodesBuilderedgesBuilder 回调函数分别用于定义节点和边的外观。这里根据是否正在拖动节点或边来改变颜色。
  • 用户交互:提供了按钮来添加、删除节点和边,以及重置、保存、加载图表等操作。
  • 动态更新:通过 _controller.needUpdate() 方法可以触发图表的重新布局。

这个示例展示了如何使用 flutter_force_directed_graph 插件创建一个交互式的力导向图,并提供了丰富的功能让用户能够与图表进行互动。


更多关于Flutter力导向图绘制插件flutter_force_directed_graph的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于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. 注意事项

  1. 节点和边的数据:在实际应用中,你可能需要从后端API或其他数据源获取节点和边的数据。
  2. 自定义节点和边:上述示例中的nodeBuilderlinkBuilder允许你自定义节点和边的外观。你可以根据需要进一步自定义这些组件。
  3. 布局设置LayoutSettings类允许你调整力导向图的布局参数,如初始排斥力、阻尼和期望的链接距离等。

这个示例展示了基本的用法,你可以根据需求进一步扩展和自定义。

回到顶部