interactive_graph_view 0.2.0 copy "interactive_graph_view: ^0.2.0" to clipboard
interactive_graph_view: ^0.2.0 copied to clipboard

A performant rendering library for displaying and manipulating custom-defined graph structures through a simple API with maximum control over the graph's look and feel.

example/main.dart

import "dart:math";

import "package:flutter/material.dart";
import "package:flutter/services.dart";

import "package:interactive_graph_view/interactive_graph_view.dart";

void main() {
  runApp(const GraphViewExampleApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Graph View Demo",
      home: const GraphViewExampleHomePage(),
      theme: ThemeData(
        extensions: {
          GraphStyle(backgroundColor: Colors.blue.shade900),
          NodeStyle(
            backgroundColor: Colors.blue.shade100,
            textStyle: TextStyle(color: Colors.blue.shade900),
            borderRadius: Radius.circular(5),
          ),
          EdgeStyle(
            arrowStyle: ArrowStyle(length: 8, width: 12),
            lineStyle: SolidLineStyle(thickness: 2),
          ),
        },
      ),
    );
  }
}

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

  @override
  State<GraphViewExampleHomePage> createState() => _GraphViewExampleHomePageState();
}

class _GraphViewExampleHomePageState extends State<GraphViewExampleHomePage> {
  final Map<String, ExampleNode> _nodes = Map.fromIterable({
    ExampleNode(position: Offset(-50, -50)),
    ExampleNode(position: Offset(50, -50)),
    ExampleNode(position: Offset(-50, 50)),
    ExampleNode(position: Offset(50, 50)),
  }, key: (node) => node.id);

  late final Map<String, ExampleEdge> _edges = Map.fromIterable({
    ExampleEdge(
      startNodeId: _nodes.keys.elementAt(0),
      endNodeId: _nodes.keys.elementAt(1),
    ),
    ExampleEdge(
      startNodeId: _nodes.keys.elementAt(2),
      endNodeId: _nodes.keys.elementAt(3),
    ),
  }, key: (edge) => edge.id);

  late final GraphViewportController<String, String> _graphViewportController;

  late Offset _tapDownPosition;

  final Set<String> _selectedNodeIds = {};

  @override
  void initState() {
    super.initState();

    _graphViewportController = GraphViewportController(
      initialNodeIds: _nodes.keys,
      initialEdgeIds: _edges.keys,

      onNodesMoved: (nodeIds, offset) {
        for (String nodeId in nodeIds) {
          // Reflect the dragged node offset back to the graph structure.
          _nodes[nodeId]!.position += offset;

          // Rebuild the node at the new position.
          _graphViewportController.rebuildNode(nodeId);
        }
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Graph View Demo")),
      body: HorizontalOrVertical(
        primary: GraphView<String, String>(
          // If this is set to true, every hot-reload and every rebuild of the viewport, for example by hot-reloading
          // or calling setState() on a parent, will rebuild *all* children (nodes and edges). This is very useful for
          // during development, but should generally be set to false.
          // When set to false, only calls to GraphViewportController.rebuildNode() or .rebuildEdge() will trigger
          // child rebuilds.
          rebuildAllChildrenOnWidgetUpdate: true,

          viewportController: _graphViewportController,
          onTapDown: (details) {
            _tapDownPosition = details.graphPosition;
          },
          onTap: () {
            _clearSelection();
          },
          onDoubleTap: () {
            // Create a new node when double tapping on an empty spot.
            final String newNodeId = _createNode(_tapDownPosition);

            // Create a new edge from each currently selected node to
            // the newly created node.
            for (final String selectedNodeId in _selectedNodeIds) {
              _createEdge(selectedNodeId, newNodeId);
            }

            // Clear the selection.
            _clearSelection();
          },
          nodeBuilder: (context, nodeId) {
            final bool isSelected = _selectedNodeIds.contains(nodeId);
            final ExampleNode node = _nodes[nodeId]!;

            return NodeWidget.basic(
              position: node.position,
              text: node.text,
              style:
                  (isSelected
                          ? NodeStyle(
                              borderSide: BorderSide(
                                color: Colors.red,
                                width: 2.0,
                              ),
                            )
                          : NodeStyle())
                      .merge(node.style),

              // Only enable dragging if the node is selected.
              isDragEnabled: isSelected,

              onTap: () {
                // Select this node, unselect all others.
                _singleSelectNode(nodeId);
              },
              onLongPress: () {
                // Toggle the selection state.
                _toggleNodeSelection(nodeId);
              },
              onDoubleTap: () {
                // Create an edge from each currently selected node to this node.

                // Do not create edges if the node is source and target.
                if (_selectedNodeIds.contains(nodeId)) return;
                // Do not create edges if no node is selected.
                if (_selectedNodeIds.isEmpty) return;

                // Create the edges.
                for (final String selectedNodeId in _selectedNodeIds) {
                  _createEdge(selectedNodeId, nodeId);
                }

                // Clear selection.
                _clearSelection();
              },
            );
          },
          edgeBuilder: (context, edgeId) {
            final ExampleEdge edge = _edges[edgeId]!;

            return EdgeWidget(
              startNodeId: edge.startNodeId,
              endNodeId: edge.endNodeId,
              text: null,
            );
          },
        ),
        secondary: PropertiesPanel(
          selectedNodeIds: _selectedNodeIds,
          nodes: _nodes,
          graphViewportController: _graphViewportController,
        ),
      ),
    );
  }

  void _toggleNodeSelection(String nodeId) {
    if (_selectedNodeIds.contains(nodeId)) {
      _selectedNodeIds.remove(nodeId);
    } else {
      _selectedNodeIds.add(nodeId);
    }

    // Trigger rebuild to update the properties panel
    setState(() {});

    // Tell the viewport, which nodes should move when dragging a drag-enabled node.
    _graphViewportController.movingNodeIds = _selectedNodeIds;
  }

  void _singleSelectNode(String nodeId) {
    _clearSelection();
    _selectedNodeIds.add(nodeId);

    // Tell the viewport, which nodes should move when dragging a drag-enabled node.
    _graphViewportController.movingNodeIds = _selectedNodeIds;
  }

  void _clearSelection() {
    for (final String selectedNodeId in _selectedNodeIds) {
      _graphViewportController.rebuildNode(selectedNodeId);
    }
    _selectedNodeIds.clear();

    // Trigger rebuild to update the properties panel
    setState(() {});

    // Tell the viewport, which nodes should move when dragging a drag-enabled node.
    _graphViewportController.movingNodeIds = _selectedNodeIds;
  }

  String _createNode(Offset position) {
    final ExampleNode newNode = ExampleNode(position: _tapDownPosition);
    _nodes[newNode.id] = newNode;
    _graphViewportController.insertNode(newNode.id);

    return newNode.id;
  }

  String _createEdge(String startNodeId, String endNodeId) {
    final ExampleEdge newEdge = ExampleEdge(
      startNodeId: startNodeId,
      endNodeId: endNodeId,
    );
    _edges[newEdge.id] = newEdge;
    _graphViewportController.insertEdge(newEdge.id);

    return newEdge.id;
  }
}

class ExampleNode {
  ExampleNode({
    String? id,
    required this.position,
    String? text,
    NodeStyle? style,
  }) : id = id ?? _newRandomId(),
       style = style ?? const NodeStyle() {
    this.text = text ?? this.id;
  }

  final String id;

  Offset position;

  late String text;
  NodeStyle style;
}

class ExampleEdge {
  ExampleEdge({String? id, required this.startNodeId, required this.endNodeId}) : id = id ?? _newRandomId();

  final String id;

  String startNodeId;
  String endNodeId;
}

// =============================
// ========== HELPERS ==========
// =============================

final Random _random = Random();
final String idCharSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
String _newRandomId({int length = 8}) {
  String result = "";
  for (int i = 0; i < length; i++) {
    result += idCharSet[_random.nextInt(idCharSet.length)];
  }
  return result;
}

class PropertiesPanel extends StatelessWidget {
  const PropertiesPanel({
    super.key,
    required Set<String> selectedNodeIds,
    required Map<String, ExampleNode> nodes,
    required GraphViewportController<String, String> graphViewportController,
  }) : _selectedNodeIds = selectedNodeIds,
       _nodes = nodes,
       _graphViewportController = graphViewportController;

  final Set<String> _selectedNodeIds;
  final Map<String, ExampleNode> _nodes;
  final GraphViewportController<String, String> _graphViewportController;

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData(
        inputDecorationTheme: InputDecorationThemeData(
          border: OutlineInputBorder(),
        ),
      ),
      child: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: Builder(
            builder: (context) {
              if (_selectedNodeIds.length != 1) {
                return Center(
                  child: Text(
                    "Please select a single node to change its properties",
                  ),
                );
              }

              final String nodeId = _selectedNodeIds.single;
              final ExampleNode node = _nodes[nodeId]!;

              return Column(
                key: ValueKey("property-panel-$nodeId"),
                mainAxisAlignment: MainAxisAlignment.start,
                mainAxisSize: MainAxisSize.max,
                children: [
                  Text("Node $nodeId"),
                  const SizedBox(height: 16),
                  TextFormField(
                    decoration: InputDecoration(labelText: "Text"),
                    initialValue: node.text,
                    onChanged: (value) {
                      node.text = value;
                      _graphViewportController.rebuildNode(nodeId);
                    },
                  ),
                  const SizedBox(height: 16),
                  DropdownButtonFormField<Color>(
                    decoration: InputDecoration(labelText: "BackgroundColor"),
                    items: [
                      DropdownMenuItem(child: Text("None")),
                      ...[Colors.black, Colors.white, ...Colors.primaries].map(
                        (color) => DropdownMenuItem(
                          value: color,
                          child: Container(
                            color: color,
                            child: Text(
                              "#${color.toARGB32().toRadixString(16)}",
                            ),
                          ),
                        ),
                      ),
                    ],
                    initialValue: node.style.backgroundColor,
                    onChanged: (value) {
                      node.style = node.style.copyWith(
                        backgroundColor: Nullable(value),
                      );
                      _graphViewportController.rebuildNode(nodeId);
                    },
                  ),
                  const SizedBox(height: 16),
                  DropdownButtonFormField<Color>(
                    decoration: InputDecoration(labelText: "Text Color"),
                    items: [
                      DropdownMenuItem(child: Text("None")),
                      ...[Colors.black, Colors.white, ...Colors.primaries].map(
                        (color) => DropdownMenuItem(
                          value: color,
                          child: Container(
                            color: color,
                            child: Text(
                              "#${color.toARGB32().toRadixString(16)}",
                            ),
                          ),
                        ),
                      ),
                    ],
                    initialValue: node.style.textStyle.color,
                    onChanged: (value) {
                      node.style = node.style.copyWith(
                        textStyle: node.style.textStyle.copyWith(color: value),
                      );
                      _graphViewportController.rebuildNode(nodeId);
                    },
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    decoration: InputDecoration(labelText: "Border radius"),
                    inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                    initialValue: (node.style.borderRadius != null)
                        ? node.style.borderRadius!.x.toInt().toString()
                        : "",
                    onChanged: (value) {
                      node.style = node.style.copyWith(
                        borderRadius: Nullable(
                          value.isEmpty ? null : Radius.circular(double.parse(value)),
                        ),
                      );
                      _graphViewportController.rebuildNode(nodeId);
                    },
                  ),
                ],
              );
            },
          ),
        ),
      ),
    );
  }
}

class HorizontalOrVertical extends StatelessWidget {
  const HorizontalOrVertical({
    super.key,
    required this.primary,
    required this.secondary,
  });

  final Widget primary;
  final Widget secondary;

  @override
  Widget build(BuildContext context) {
    final Size size = MediaQuery.sizeOf(context);

    if (size.width >= size.height) {
      return Row(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Expanded(child: primary),
          SizedBox(width: size.width / 5, child: secondary),
        ],
      );
    } else {
      return Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Expanded(child: primary),
          SizedBox(height: size.height / 3, child: secondary),
        ],
      );
    }
  }
}
1
likes
0
points
130
downloads

Publisher

unverified uploader

Weekly Downloads

A performant rendering library for displaying and manipulating custom-defined graph structures through a simple API with maximum control over the graph's look and feel.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, path_drawing

More

Packages that depend on interactive_graph_view