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

Fork of the Flutter SDK InteractiveViewer that updates the RenderTransform directly (markNeedsPaint) instead of calling setState on every pan/zoom frame. No widget tree rebuild during pan/zoom interactions.

example/lib/main.dart

import "package:flutter/gestures.dart";
import "package:flutter/material.dart";
import "package:interactive_viewer_vector/interactive_viewer_vector.dart";

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "InteractiveViewerVector Demo",
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(useMaterial3: true),
      home: const VectorDemoPage(),
    );
  }
}

/// Simple node placed on the demo canvas.
class DemoNode {
  const DemoNode({required this.position, required this.size, required this.color, required this.label});

  final Offset position;
  final Size size;
  final Color color;
  final String label;
}

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

  @override
  Widget build(BuildContext context) {
    return _VectorDemoBody();
  }
}

class _VectorDemoBody extends StatelessWidget {
  _VectorDemoBody();

  static const double _canvasSize = 2000;

  final TransformationControllerVector _controller = TransformationControllerVector();

  /// Nodes — ValueNotifier pour déclencher le rebuild des nodes + lignes sans setState.
  final ValueNotifier<List<DemoNode>> _nodesNotifier = ValueNotifier<List<DemoNode>>([
    DemoNode(position: const Offset(300, 250), size: const Size(180, 100), color: Colors.teal, label: "Node A"),
    DemoNode(position: const Offset(900, 200), size: const Size(220, 120), color: Colors.deepOrange, label: "Node B"),
    DemoNode(position: const Offset(600, 700), size: const Size(200, 140), color: Colors.indigo, label: "Node C"),
    DemoNode(position: const Offset(1300, 500), size: const Size(160, 160), color: Colors.purple, label: "Node D"),
    DemoNode(position: const Offset(1100, 1100), size: const Size(240, 100), color: Colors.green, label: "Node E"),
    DemoNode(position: const Offset(250, 1200), size: const Size(200, 180), color: Colors.redAccent, label: "Node F"),
    DemoNode(position: const Offset(1500, 1400), size: const Size(180, 140), color: Colors.amber, label: "Node G"),
    // Node de test — visuellement distinct pour tester le drag.
    DemoNode(position: const Offset(850, 600), size: const Size(200, 100), color: Colors.yellow, label: "DRAG ME"),
  ]);

  /// État de drag — ValueNotifier pour éviter tout setState.
  final ValueNotifier<int?> _draggedNodeIndex = ValueNotifier<int?>(null);

  /// Position de départ du drag (coordonnées canvas) — ValueNotifier pour rester immutable.
  final ValueNotifier<Offset?> _dragStartScene = ValueNotifier<Offset?>(null);

  void _resetTransform() {
    _controller.value = Matrix4.identity();
  }

  /// Pan custom via clampTranslate — reproduit le pattern d'Axomind.
  /// panEnabled: false sur l'InteractiveViewerVector, on gère le pan nous-mêmes
  /// avec clampTranslate pour bénéficier du boundary clamping du fork.
  void _onPointerSignal(PointerSignalEvent event) {
    if (event is PointerScrollEvent) {
      final scale = _controller.value.getMaxScaleOnAxis();
      final translation = Offset(-event.scrollDelta.dx / scale, -event.scrollDelta.dy / scale);
      _controller.value = _controller.clampTranslate(_controller.value, translation);
    }
  }

  void _onPointerDown(PointerDownEvent event) {
    if (event.buttons == kPrimaryMouseButton) {
      // event.localPosition est déjà en coordonnées canvas car le Listener
      // est à l'intérieur du Transform de l'InteractiveViewerVector.
      final scenePoint = event.localPosition;
      final nodes = _nodesNotifier.value;
      for (var i = 0; i < nodes.length; i++) {
        final node = nodes[i];
        final nodeRect = Rect.fromLTWH(node.position.dx, node.position.dy, node.size.width, node.size.height);
        if (nodeRect.contains(scenePoint)) {
          _dragStartScene.value = scenePoint;
          _draggedNodeIndex.value = i;
          return;
        }
      }
    }
  }

  void _onPointerMove(PointerMoveEvent event) {
    if (_draggedNodeIndex.value != null && event.buttons == kPrimaryMouseButton) {
      final scenePoint = event.localPosition;
      final delta = scenePoint - _dragStartScene.value!;
      final nodes = _nodesNotifier.value;
      final i = _draggedNodeIndex.value!;
      final node = nodes[i];
      final newPosition = node.position + delta;
      final newNodes = List<DemoNode>.from(nodes);
      newNodes[i] = DemoNode(position: newPosition, size: node.size, color: node.color, label: node.label);
      _nodesNotifier.value = newNodes;
      _dragStartScene.value = scenePoint;
      return;
    }

    // Pan du canvas via clampTranslate (clic gauche hors node).
    if (event.buttons == kPrimaryMouseButton && _draggedNodeIndex.value == null) {
      final scale = _controller.value.getMaxScaleOnAxis();
      final translation = Offset(event.delta.dx / scale, event.delta.dy / scale);
      _controller.value = _controller.clampTranslate(_controller.value, translation);
    }
  }

  void _onPointerUp(PointerUpEvent event) {
    _dragStartScene.value = null;
    _draggedNodeIndex.value = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("InteractiveViewerVector"),
        actions: [IconButton(icon: const Icon(Icons.center_focus_strong), tooltip: "Reset transform", onPressed: _resetTransform)],
      ),
      body: LayoutBuilder(
        builder: (context, constraints) {
          return InteractiveViewerVector(
            transformationController: _controller,
            constrained: false,
            boundaryMargin: const EdgeInsets.all(_canvasSize),
            minScale: 0.1,
            maxScale: 3,
            // Pan natif désactivé — on gère le pan via clampTranslate dans le
            // Listener ci-dessous, pour reproduire le pattern d'Axomind.
            panEnabled: false,
            child: _buildCanvasContent(),
          );
        },
      ),
      bottomNavigationBar: const Padding(
        padding: EdgeInsets.all(12),
        child: Text(
          "Drag a node to move it — drag empty space to pan (clampTranslate) — scroll to zoom.\n"
          "No setState: nodes + lines are driven by ValueNotifiers.",
          textAlign: TextAlign.center,
          style: TextStyle(fontSize: 12),
        ),
      ),
    );
  }

  Widget _buildCanvasContent() {
    return SizedBox(
      width: _canvasSize,
      height: _canvasSize,
      child: Stack(
        children: [
          // Couche 1 : grille
          const Positioned.fill(child: CustomPaint(painter: _GridPainter())),
          // Couche 2 : lignes de connexion + nodes — rebuild via ValueNotifier, pas de setState.
          ValueListenableBuilder<List<DemoNode>>(
            valueListenable: _nodesNotifier,
            builder: (context, nodes, _) {
              return Stack(
                children: [
                  CustomPaint(size: const Size(_canvasSize, _canvasSize), painter: _LinksPainter(nodes)),
                  for (final node in nodes)
                    Positioned(
                      left: node.position.dx,
                      top: node.position.dy,
                      child: IgnorePointer(child: _DemoNodeWidget(node: node)),
                    ),
                ],
              );
            },
          ),
          // Couche 3 : layer interactive (pan + drag nodes)
          // Placée AU-DESSUS des nodes pour capter tous les pointers.
          // Le hit-test des nodes est fait manuellement dans _onPointerDown.
          Positioned.fill(
            child: Listener(
              behavior: HitTestBehavior.opaque,
              onPointerSignal: _onPointerSignal,
              onPointerDown: _onPointerDown,
              onPointerMove: _onPointerMove,
              onPointerUp: _onPointerUp,
              child: const SizedBox.expand(),
            ),
          ),
        ],
      ),
    );
  }
}

class _DemoNodeWidget extends StatelessWidget {
  const _DemoNodeWidget({required this.node});

  final DemoNode node;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: node.size.width,
      height: node.size.height,
      decoration: BoxDecoration(
        color: node.color.withValues(alpha: 0.85),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: Colors.white38, width: 2),
      ),
      alignment: Alignment.center,
      child: Text(
        node.label,
        style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.black),
      ),
    );
  }
}

class _GridPainter extends CustomPainter {
  const _GridPainter();

  static const double _step = 100;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.white12
      ..strokeWidth = 1;
    for (double x = 0; x <= size.width; x += _step) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
    }
    for (double y = 0; y <= size.height; y += _step) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
    }
  }

  @override
  bool shouldRepaint(_GridPainter oldDelegate) => false;
}

class _LinksPainter extends CustomPainter {
  const _LinksPainter(this.nodes);

  final List<DemoNode> nodes;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.white54
      ..strokeWidth = 3
      ..style = PaintingStyle.stroke;
    for (var i = 0; i < nodes.length - 1; i++) {
      final a = nodes[i].position + nodes[i].size.center(Offset.zero);
      final b = nodes[i + 1].position + nodes[i + 1].size.center(Offset.zero);
      canvas.drawLine(a, b, paint);
    }
  }

  @override
  bool shouldRepaint(_LinksPainter oldDelegate) => nodes != oldDelegate.nodes;
}
0
likes
150
points
102
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Fork of the Flutter SDK InteractiveViewer that updates the RenderTransform directly (markNeedsPaint) instead of calling setState on every pan/zoom frame. No widget tree rebuild during pan/zoom interactions.

Repository (GitHub)
View/report issues

License

BSD-3-Clause (license)

Dependencies

flutter, vector_math

More

Packages that depend on interactive_viewer_vector