Flutter节点视图插件vs_node_view的使用

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

Flutter节点视图插件vs_node_view的使用

简介

vs_node_view 是一个用于创建和使用的可视化脚本工具包。通过此插件,您可以定义自己的VS节点及其交互方式。这些节点可以被用户用来在您的应用程序中创建自定义行为。

示例演示:

节点树示例

如果您希望在Web上使用此插件,请确保在index.html文件顶部添加以下代码:

<html oncontextmenu="event.preventDefault();"></html>

特性

使用此插件,您可以在Flutter应用中:

  • 创建带有类型化输入输出的节点
  • 在可缩放画布上与节点进行视觉交互
  • 对节点进行评估(也可以不使用UI组件)
  • 序列化和反序列化节点

使用方法

接口

接口由节点使用以创建连接。接口具有类型,并且只会与特定类型的接口交互。

接口分为两类:

  • 输入
  • 输出

有6种基本接口,每种接口都有输入和输出两种变体:

  • dynamic (作为输入:接受任何输出)
  • bool (作为输入:接受booldynamic输出)
  • int (作为输入:接受intnumdynamic输出)
  • double (作为输入:接受doublenumdynamic输出)
  • num (作为输入:接受intdoublenumdynamic输出)
  • string (作为输入:接受stringdynamic输出)

所有接口都有一个“类型”,类型将用于反序列化,如果它已经进入生产阶段,则不应更改,因为反序列化将会失败。 使用“标题”来添加与序列化无关的本地化。 使用“提示”来在鼠标悬停时添加提示。 使用“interfaceIconBuilder”来为每个接口实例自定义接口小部件,而不是为该类型的全部接口。

如果您想使用自己的类进行可视化脚本,可以这样定义接口:

/// 定义一个将在UI中用于其输入和输出的接口颜色
/// 您也可以在类中定义它们,如果您希望输入和输出有不同的颜色
const Color _interfaceColor = Colors.pink;

/// 这是您的输入接口
/// 它需要扩展VSInputData并为其超级提供类型和初始连接
class MyFirstInputData extends VSInputData {
  MyFirstInputData({
    required super.type,
    super.title,
    super.toolTip,
    super.initialConnection,
    super.interfaceIconBuilder,
  });

  /// 列表中定义此输入将接受的类型
  /// 定义此输入将与哪些输出交互
  /// 默认情况下,动态输出将被任何输入接受
  [@override](/user/override)
  List<Type> get acceptedTypes => [
        MyFirstOutputData,
      ];

  /// 定义界面在UI中图标和节点之间线条的颜色
  [@override](/user/override)
  Color get interfaceColor => _interfaceColor;

  /// 可以通过覆盖此函数来控制特定接口类型的界面小部件
  /// 这适用于所有此类类型的接口,使用interfaceIconBuilder可以为每个接口实例自定义小部件
  [@override](/user/override)
  Widget getInterfaceIcon({required BuildContext context, required GlobalKey anchor,}) {
    return MyWidget();
  }
}

/// 这是您的输出接口
/// 它需要扩展VSOutputData并带有一个类型
/// 类型定义附加的输出函数将返回什么
/// 您需要将类型和输出函数传递给超级
class MyFirstOutputData extends VSOutputData<MyCoolClass> {
  MyFirstOutputData({
    required super.type,
    super.title,
    super.toolTip,
    super.outputFunction,
    super.interfaceIconBuilder,
  });

  [@override](/user/override)
  Color get interfaceColor => _interfaceColor;

  /// 这可以为两种类型的接口执行
  [@override](/user/override)
  Widget getInterfaceIcon({required BuildContext context, required GlobalKey anchor,}) {
    return MyWidget();
  }
}

现在我们了解了接口,接下来让我们看看如何定义节点。

定义节点

所有节点都通过一个函数定义。 当创建新节点或反序列化它们时,该函数会被调用以确保所有类都是新实例。

所有节点都有一个“类型”,不能定义具有相同类型的多个节点。 “类型”将用于反序列化,并且如果它已经进入生产阶段,则不应更改,因为反序列化将会失败。 “类型”将作为回退显示给用户,如果未给出标题。 节点有输入,不能在同一节点中定义具有相同“名称”的多个输入。 节点有输出,不能在同一节点中定义具有相同“名称”的多个输出。 输入数据将通过Map<String, dynamic>传递给所有输出。键是输入的名称,值是输入接收到的内容。

正常节点

正常节点是使用VSNodeData的所有节点。这些节点期望输入并返回输出。

定义如下:

VSNodeData parseIntNode(Offset offset, VSOutputData? ref) {
  return VSNodeData(
    type: "Parse int",
    widgetOffset: offset,
    inputData: [
      VSStringInputData(
        name: "Input",
        initialConnection: ref,
      )
    ],
    outputData: [
      VSIntOutputData(
        name: "Output",
        outputFunction: (data) {
          return int.parse(data["Input"]);
        },
      ),
    ],
  );
}

小部件节点

小部件节点是使用VSWidgetNode的所有节点。它们需要一个子小部件以及用于序列化/反序列化的setValue/getValue函数。

小部件节点允许您使用任何小部件创建用户输入。它们不能有任何输入,并返回一个输出。

定义如下:

VSWidgetNode textInputNode(
  Offset offset,
  VSOutputData? ref,
) {
  final controller = TextEditingController();
  final inputWidget = TextField(
    controller: controller,
    decoration: const InputDecoration(
      isDense: true,
      contentPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 10),
    ),
  );

  return VSWidgetNode(
    type: "Input",
    widgetOffset: offset,
    outputData: VSStringOutputData(
      type: "Output",
      outputFunction: (data) => controller.text,
    ),
    child: Expanded(child: inputWidget),
    setValue: (value) => controller.text = value,
    getValue: () => controller.text,
  );
}

列表节点

列表节点是使用VSListNode的所有节点。它们需要一个在运行时创建新接口的inputBuilder。生成器会获取新构建接口的索引以及该接口将拥有的连接。务必在生成器中将连接传递给接口的initialConnection,否则它将无法正常工作。

定义如下:

VSListNode sumNode(
  Offset offset,
  VSOutputData? ref,
) {
  return VSListNode(
    type: "Sum",
    toolTip: "Adds all supplied Numbers together",
    widgetOffset: offset,
    inputBuilder: (index, connection) => VSNumInputData(
      type: "Input $index",
      initialConnection: connection,
    ),
    outputData: [
      VSNumOutputData(
        type: "output",
        toolTip: "The sum of all supplied values",
        outputFunction: (data) {
          return data.values.reduce((value, element) => value + element);
        },
      )
    ],
  );
}

输出节点

输出节点是使用VSOutputNode的所有节点。它们只接受一个输入(动态)并且可用于评估节点树。

要访问它们,您可以使用VSNodeManager的getOutputNodes函数。这将返回当前节点树中的所有输出。 VSOutputNode有一个evaluate函数,它将返回包含节点名称和结果的MapEntry<String,dynamic>。

定义如下:

VSOutputNode outputNode(Offset offset, VSOutputData? ref) {
  return VSOutputNode(
    type: "Output",
    widgetOffset: offset,
    ref: ref,
  );
}

使用VSNodeDataProvider

准备生成器

现在我们已经定义了我们的节点,我们需要将它们移动到一个集合中并传递给VSNodeDataProvider。该集合将传递给VSNodeSerializationManager,以确保所有规则(在此处定义)都被遵守,并将创建两个映射:

  • Map<String, VSNodeDataBuilder> _nodeBuilders (用于反序列化)
  • Map<String, dynamic> contextNodeBuilders

contextNodeBuilders被传递给上下文菜单并定义UI。

我们的节点生成器集合可能只是一个函数列表,如下所示:

final List<dynamic> nodeBuilders = [
  textInputNode,
  parseIntNode,
  outputNode,
];

但是由于UI将基于集合创建,我们还可以使用VSSubgroups对节点进行分组。VSSubgroups定义了一个名称和一个新的节点集合。

final List<dynamic> nodeBuilders = [
  VSSubgroup(
    name: "Number",
    subgroup: [
      parseIntNode,
      parseDoubleNode,
      sumNode,
    ],
  ),
  VSSubgroup(
    name: "Logik",
    subgroup: [
      biggerNode,
      ifNode,
    ],
  ),
  textInputNode,
  outputNode,
];

这样,您的UI可能会看起来像这样:

上下文菜单示例

直接使用VSNodeManager

VSNodeManager是所有节点操作的核心。它跟踪数据并具有一些API,可以在低级别调用这些API来修改所述数据。

节点管理器使用您想要使用的节点生成器初始化,并且可选地可以使用任何序列化的节点。

如果传入了serializedNodes参数,它将尝试反序列化字符串并重新创建所有节点。 重要的是要注意,反序列化的节点将使用提供的节点生成器来重新创建节点。 这意味着如果一个节点不在节点生成器中,它将无法反序列化并且会丢失。

final manager = VSNodeManager(
    nodeBuilders: nodeBuilders,
    serializedNodes: serializedNodes,
);

serializedNodes只是一个字符串,可以通过调用以下方法获得:

VSNodeManager.serializeNodes()

VSNodeManager还公开了getOutputNodes,这将给您当前节点树中的所有输出。 您可以评估它们以获取指定节点的最终结果。

VSNodeManager.getOutputNodes.map((e) => e.evaluate());

创建VSNodeDataProvider

VSNodeDataProvider接受VSNodeManager的一个实例作为参数。 您还可以选择传递VSHistoryManager的实例以跟踪节点历史。 然后您可以调用以下方法来遍历历史:

VSNodeDataProvider.historyManager!.undo()
VSNodeDataProvider.historyManager!.redo()

一个VSNodeDataProvider可以这样初始化:

final provider = VSNodeDataProvider(
  nodeManager: VSNodeManager(nodeBuilders: nodeBuilders),
  historyManager: VSHistoryManger(), 
);

VSNodeDataProvider通过VSNodeView小部件使用InheritedNodeDataProvider InheritedWidget注入到小部件树中。 您可以在小部件树下的任何地方使用VSNodeDataProvider.of(context)来访问它。 如果您需要监听更改,VSNodeDataProvider扩展了ChangeNotifier,因此您可以直接使用ListenableBuilder。

这是一个完整的示例:

final List<dynamic> nodeBuilders = [
  textInputNode,
  VSSubgroup(
    name: "Number",
    subgroup: [
      parseIntNode,
      parseDoubleNode,
      sumNode,
    ],
  ),
  outputNode,
];

VSNodeDataProvider(
  nodeManager: VSNodeManager(nodeBuilders: nodeBuilders),
);

使用VSNodeView

VSNodeView是主要的UI小部件。 它可以覆盖以下内容:

  • 节点UI
  • 节点标题UI
  • 上下文菜单UI
  • 选择区域UI 如果您想以不同的样式来设置它们。

它期望一个VSNodeDataProvider并将其注入到小部件树中。

VSNodeView(
    nodeDataProvider: nodeDataProvider,
),

使用InteractiveVSNodeView

InteractiveVSNodeView将VSNodeView包装在一个InteractiveViewer中。 这允许用户在画布上缩放和平移。

您可以传递宽度和高度参数来定义画布大小。 如果没有给出宽度或高度,将使用屏幕宽度或高度。

VSNodeDataProvider有一个名为applyViewPortTransfrom的函数,它将应用所有的视口变换(缩放、平移)到给定的Offset。 这是必要的,因为偏移通常以屏幕坐标给出,因此一旦视口被改变,它们就不再起作用。

您可以传递自己的VSNodeView小部件来更改其特定设置。

InteractiveVSNodeView(
    width: 5000,
    height: 5000,
    nodeDataProvider: nodeDataProvider,
    baseNodeView: VSNodeView(
      nodeDataProvider: nodeDataProvider,
    ),
),

示例代码

以下是一个完整的示例代码,展示了如何使用vs_node_view插件:

import 'package:flutter/material.dart';
import 'package:vs_node_view/vs_node_view.dart';

void main() async {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  [@override](/user/override)
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        scaffoldBackgroundColor: const Color.fromARGB(255, 46, 46, 46),
      ),
      home: const Scaffold(body: VSNodeExample()),
    );
  }
}

class VSNodeExample extends StatefulWidget {
  const VSNodeExample({super.key});

  [@override](/user/override)
  State<VSNodeExample> createState() => _VSNodeExampleState();
}

class _VSNodeExampleState extends State<VSNodeExample> {
  Iterable<String>? results;

  late VSNodeDataProvider nodeDataProvider;

  [@override](/user/override)
  void initState() {
    super.initState();

    final nodeBuilders = [
      VSSubgroup(
        name: "Numbers",
        subgroup: [
          (Offset offset, VSOutputData? ref) => VSNodeData(
                type: "Parse int",
                widgetOffset: offset,
                inputData: [
                  VSStringInputData(
                    type: "Input",
                    initialConnection: ref,
                  )
                ],
                outputData: [
                  VSIntOutputData(
                    type: "Output",
                    outputFunction: (data) => int.parse(data["Input"]),
                  ),
                ],
              ),
          (Offset offset, VSOutputData? ref) => VSNodeData(
                type: "Sum",
                widgetOffset: offset,
                inputData: [
                  VSNumInputData(
                    type: "Input 1",
                    initialConnection: ref,
                  ),
                  VSNumInputData(
                    type: "Input 2",
                    initialConnection: ref,
                  )
                ],
                outputData: [
                  VSNumOutputData(
                    type: "output",
                    outputFunction: (data) {
                      return (data["Input 1"] ?? 0) + (data["Input 2"] ?? 0);
                    },
                  ),
                ],
              ),
        ],
      ),
      VSSubgroup(
        name: "Logik",
        subgroup: [
          (Offset offset, VSOutputData? ref) => VSNodeData(
                type: "Bigger then",
                widgetOffset: offset,
                inputData: [
                  VSNumInputData(
                    type: "First",
                    initialConnection: ref,
                  ),
                  VSNumInputData(
                    type: "Second",
                    initialConnection: ref,
                  ),
                ],
                outputData: [
                  VSBoolOutputData(
                    type: "Output",
                    outputFunction: (data) => data["First"] > data["Second"],
                  ),
                ],
              ),
          (Offset offset, VSOutputData? ref) => VSNodeData(
                type: "If",
                widgetOffset: offset,
                inputData: [
                  VSBoolInputData(
                    type: "Input",
                    initialConnection: ref,
                  ),
                  VSDynamicInputData(
                    type: "True",
                    initialConnection: ref,
                  ),
                  VSDynamicInputData(
                    type: "False",
                    initialConnection: ref,
                  ),
                ],
                outputData: [
                  VSDynamicOutputData(
                    type: "Output",
                    outputFunction: (data) =>
                        data["Input"] ? data["True"] : data["False"],
                  ),
                ],
              ),
        ],
      ),
      (Offset offset, VSOutputData? ref) {
        final controller = TextEditingController();
        final input = TextField(
          controller: controller,
          decoration: const InputDecoration(
            isDense: true,
            contentPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 10),
          ),
        );

        return VSWidgetNode(
          type: "Input",
          widgetOffset: offset,
          outputData: VSStringOutputData(
            type: "Output",
            outputFunction: (data) => controller.text,
          ),
          child: Expanded(child: input),
          setValue: (value) => controller.text = value,
          getValue: () => controller.text,
        );
      },
      (Offset offset, VSOutputData? ref) => VSOutputNode(
            type: "Output",
            widgetOffset: offset,
            ref: ref,
          ),
    ];

    nodeDataProvider = VSNodeDataProvider(
      nodeManager: VSNodeManager(nodeBuilders: nodeBuilders),
    );
  }

  [@override](/user/override)
  Widget build(BuildContext context) {
    return Stack(
      children: [
        InteractiveVSNodeView(
          width: 5000,
          height: 5000,
          nodeDataProvider: nodeDataProvider,
        ),
        const Positioned(
          bottom: 0,
          right: 0,
          child: Legend(),
        ),
        Positioned(
          top: 0,
          right: 0,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              ElevatedButton(
                onPressed: () => setState(() {
                  final entries =
                      nodeDataProvider.nodeManager.getOutputNodes.map(
                    (e) => e.evaluate(
                      onError: (_, __) => Future.delayed(Duration.zero, () {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(
                            backgroundColor: Colors.deepOrange,
                            content: Text('An error occurred'),
                          ),
                        );
                      }),
                    ),
                  );

                  results = entries.map((e) => "${e.key}: ${e.value}");
                }),
                child: const Text("Evaluate"),
              ),
              if (results != null)
                ...results!.map(
                  (e) => Card(
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Text(e),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ],
    );
  }
}

Map<String, Color> inputTypes = {
  "String": VSStringInputData(type: "legend").interfaceColor,
  "Int": VSIntInputData(type: "legend").interfaceColor,
  "Double": VSDoubleInputData(type: "legend").interfaceColor,
  "Num": VSNumInputData(type: "legend").interfaceColor,
  "Bool": VSBoolInputData(type: "legend").interfaceColor,
  "Dynamic": VSDynamicInputData(type: "legend").interfaceColor,
};

class Legend extends StatelessWidget {
  const Legend({super.key});

  [@override](/user/override)
  Widget build(BuildContext context) {
    final List<Widget> widgets = [];

    final entries = inputTypes.entries;

    for (final entry in entries) {
      widgets.add(
        Row(
          children: [
            Text(entry.key),
            Icon(
              Icons.circle,
              color: entry.value,
            ),
            if (entry != entries.last) const Divider(),
          ],
        ),
      );
    }

    return Card(
        child: Padding(
      padding: const EdgeInsets.all(8.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: widgets,
      ),
    ));
  }
}

更多关于Flutter节点视图插件vs_node_view的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于Flutter节点视图插件vs_node_view的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html


当然,以下是如何在Flutter项目中集成和使用vs_node_view插件的一个基本示例。vs_node_view插件通常用于在Flutter应用中显示和操作节点视图,尽管具体的插件功能和API可能会随着版本的更新而变化,以下代码提供了一个基本的框架来演示如何集成和使用该插件。

首先,确保你已经在pubspec.yaml文件中添加了vs_node_view依赖项:

dependencies:
  flutter:
    sdk: flutter
  vs_node_view: ^最新版本号  # 替换为实际最新版本号

然后,运行flutter pub get来安装依赖项。

接下来,在Flutter应用中创建一个简单的节点视图。以下是一个示例代码,展示了如何初始化和使用vs_node_view插件:

import 'package:flutter/material.dart';
import 'package:vs_node_view/vs_node_view.dart';  // 导入插件

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('VS Node View Example'),
        ),
        body: Center(
          child: NodeViewExample(),
        ),
      ),
    );
  }
}

class NodeViewExample extends StatefulWidget {
  @override
  _NodeViewExampleState createState() => _NodeViewExampleState();
}

class _NodeViewExampleState extends State<NodeViewExample> {
  // 假设我们有一些节点数据
  final List<Node> nodes = [
    Node(id: '1', label: 'Node 1', position: Offset(100, 100)),
    Node(id: '2', label: 'Node 2', position: Offset(300, 100)),
    Node(id: '3', label: 'Node 3', position: Offset(200, 300)),
  ];

  // 连接节点的边
  final List<Edge> edges = [
    Edge(from: '1', to: '2'),
    Edge(from: '1', to: '3'),
  ];

  @override
  Widget build(BuildContext context) {
    return VSNodeView(
      nodes: nodes,
      edges: edges,
      nodeBuilder: (context, node) {
        return Container(
          decoration: BoxDecoration(
            border: Border.all(color: Colors.black),
            borderRadius: BorderRadius.circular(5),
          ),
          padding: EdgeInsets.all(8.0),
          child: Text(node.label),
        );
      },
      edgeBuilder: (context, edge) {
        return CustomPaint(
          painter: LinePainter(edge.fromNodePosition, edge.toNodePosition),
          size: Size.zero,  // 因为CustomPaint本身不定义大小
        );
      },
      onNodeTap: (node) {
        // 当节点被点击时执行的回调
        print('Node ${node.label} tapped!');
      },
    );
  }
}

// 节点数据类
class Node {
  final String id;
  final String label;
  final Offset position;

  Node({required this.id, required this.label, required this.position});

  Offset get nodePosition => position;
}

// 边数据类
class Edge {
  final String from;
  final String to;

  Edge({required this.from, required this.to});

  Node get fromNode {
    // 在实际应用中,这里应该通过ID查找对应的Node对象
    // 这里为了简化,我们假设nodes列表已经按照ID顺序排列并可直接访问
    // 注意:这只是一个示例,实际中应该实现更复杂的查找逻辑
    return nodes.firstWhere((node) => node.id == from);
  }

  Node get toNode {
    // 同上
    return nodes.firstWhere((node) => node.id == to);
  }

  Offset get fromNodePosition => fromNode.nodePosition;
  Offset get toNodePosition => toNode.nodePosition;
}

// 自定义画笔类,用于绘制边
class LinePainter extends CustomPainter {
  final Offset from;
  final Offset to;
  final Paint paint = Paint()
    ..color = Colors.black
    ..strokeWidth = 2.0;

  LinePainter(this.from, this.to);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawLine(from, to, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;  // 如果不需要重绘,返回false
  }
}

注意

  1. 这里的NodeEdge类是根据假设创建的,你可能需要根据vs_node_view插件的实际API进行调整。
  2. LinePainter是一个简单的自定义画笔类,用于绘制从一个节点到另一个节点的线。
  3. 示例中的nodesedges列表是硬编码的,你可能需要根据你的应用逻辑动态生成它们。
  4. vs_node_view插件的具体API和用法可能会随着版本的更新而变化,请参考插件的官方文档和示例代码进行进一步的开发和调整。

这个示例提供了一个基本的框架,展示了如何在Flutter应用中使用vs_node_view插件来显示和操作节点视图。根据你的具体需求,你可能需要添加更多的功能和样式。

回到顶部