1

I want to achieve a transformation on the canvas using two fingers for rotation, scaling, and translation, while using one finger to paint in the correct position.

What I have so far:

import 'package:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' as vm; // can't used it, or i don't know how i use it

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: MyHomePage());
  }
}

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

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  double initialRotation = 0.0;
  double currentRotation = 0.0;
  double previousRotation = 0.0;

  double initialScale = 1.0;
  double currentScale = 1.0;
  double previousScale = 1.0;

  Offset initialFocalPoint = Offset.zero;
  Offset initialDisplacement = Offset.zero;
  Offset previousDisplacement = Offset.zero;
  Offset currentDisplacement = Offset.zero;



  // --- Gesture Handlers ---

  void _handleOnScaleStart(ScaleStartDetails details) {
    setState(() {

      initialRotation = previousRotation;

      initialScale = previousScale;

      initialFocalPoint = details.focalPoint;

      initialDisplacement =
          previousDisplacement; 
    });
  }

  void _handleOnScaleUpdate(ScaleUpdateDetails details) {
    setState(() {

      currentRotation = initialRotation + details.rotation;
      currentScale = initialScale * details.scale;
      currentDisplacement =
          initialDisplacement + (details.focalPoint - initialFocalPoint);

    });
  }

  void _handleOnScaleEnd(ScaleEndDetails details) {

    setState(() {

      previousRotation = currentRotation;
      previousScale = currentScale;
      previousDisplacement =
          currentDisplacement; // Save the translation for the next gesture
    });
  }

  @override
  Widget build(BuildContext context) {
    const canvasSize = Size(200, 400);

    return Scaffold(
      body: Center(
        child: GestureDetector(
          onScaleStart: _handleOnScaleStart,
          onScaleUpdate: _handleOnScaleUpdate,
          onScaleEnd: _handleOnScaleEnd,

          child: Container(
            color: Colors.grey[200],
            width: double.infinity,
            height: double.infinity,
            child: Center(
              child: Transform.translate(
                offset: currentDisplacement,
                child: Transform.rotate(
                  angle: currentRotation,
                  child: Transform.scale(
                    scale: currentScale,
                    child: CustomPaint(
                      size: canvasSize,
                      // Pass the current Matrix4 to the painter
                      painter: MyPainter(
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class MyPainter extends CustomPainter {

  @override
  void paint(Canvas canvas, Size size) {
    // main canvas
    final rectPaint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.fill
      ..strokeWidth = 3.0;
    final rect = Rect.fromLTRB(0, 0, 200, 400);

    final circlePaint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill;
    
    canvas.drawRect(rect, rectPaint); 

    const double radius = 5.0;
    canvas.drawCircle(Offset(0, 0), radius, circlePaint); 
  }

  @override
  bool shouldRepaint(covariant MyPainter oldDelegate) {
    return false;
  }
}

The code currently handles rotation, scaling, and translation, but I'm unsure how to implement painting with one finger.

Is there an approach I can use to achieve both transformation and accurate painting?

I don't know how to enable single-finger drawing or how to map the stroke correctly so that it appears under my finger at the right position.

15
  • why not use InteractiveViewer widget with CustomPaint? Commented Dec 3 at 11:10
  • @MunsifAli I tried using InteractiveViewer, but it's not working properly. If you know how to use it correctly, please help me with that. Commented Dec 3 at 16:13
  • use Matrix4 api for transforming points (flutter contains a helper class MatrixUtils for that) Commented Dec 4 at 4:11
  • @pskink I don't know how to use MatrixUtils. Commented Dec 4 at 11:15
  • there is a static transformPoint method Commented Dec 4 at 13:45

1 Answer 1

0

Let's start with InteractiveViewer, instead of GestureDetector, as we need more control over transformation and interactions
I would recommend using such a structure for whiteboard layout:

Widget build(BuildContext context) {
  return LayoutBuilder(
    builder: (context, constraints) {
      return InteractiveViewer(
        transformationController: _transformationController,
        minScale: _minZoom,
        maxScale: _maxZoom,
        panEnabled: false,
        scaleEnabled: true,
        boundaryMargin: const EdgeInsets.all(double.infinity),
        onInteractionStart: onInteractionStart,
        onInteractionUpdate: onInteractionUpdate,
        onInteractionEnd: (_) => onInteractionEnd(),
        child: RepaintBoundary(
          child: SizedBox.fromSize(
            size: constraints.smallest,
            child: CustomPaint(
              key: _canvasKey,
              painter: CanvasCustomPainter(
                nodes: _nodes,
                offset: _lastFocalPoint,
                scale: _currentZoom,
                screenSize: constraints.biggest,
                transformationController: _transformationController,
              ),
            ),
          ),
        ),
      );
    },
  );
}

Now lets add a bit models for pen/drawwing logic, lets declare some abstraction, for example something like this:

abstract class WhiteboardNode {
  WhiteboardNode({required this.order});

  int order;

  NodeBoundingBox get boundingBox;

  void shift(Offset delta);
}

add a point model:

class DrawPoint extends Offset {
  DrawPoint(super.dx, super.dy, {this.visible = true});

  DrawPoint.fromOffset(Offset o)
      : visible = true,
        super(o.dx, o.dy);

  bool visible;

  @override
  DrawPoint translate(double translateX, double translateY) => DrawPoint(
        dx + translateX,
        dy + translateY,
      );
}

and finally a pen node:

class WhiteboardPenSettings {
  const WhiteboardPenSettings({
    required this.strokeWidth,
    required this.strokeCap,
    required this.currentColor,
    this.onDrawOptionChange,
  }) : assert(strokeWidth >= 0, "strokeWidth can't be negative");

  const WhiteboardPenSettings.initial()
      : strokeWidth = 2.5,
        strokeCap = StrokeCap.round,
        currentColor = AppColors.black,
        onDrawOptionChange = null;

  final double strokeWidth;
  final StrokeCap strokeCap;
  final Color currentColor;
  final ValueChanged<WhiteboardPenSettings>? onDrawOptionChange;
}

class NodeBoundingBox {
  const NodeBoundingBox({
    required this.rect,
    required this.paddingOffset,
  });

  static const NodeBoundingBox zero = NodeBoundingBox(
    rect: Rect.zero,
    paddingOffset: Offset.zero,
  );

  final Rect rect;
  final Offset paddingOffset;
}

class NodeExtremity {
  NodeExtremity({
    required this.left,
    required this.top,
    required this.right,
    required this.bottom,
  });

  NodeExtremity.initial()
      : left = 0,
        top = 0,
        right = 0,
        bottom = 0;

  double left;
  double top;
  double right;
  double bottom;
}

class PenNode extends WhiteboardNode {
  PenNode({
    required this.uuid,
    required super.order,
    required this.penSettings,
    required this.paintingStyle,
    required this.extremity,
    required this.points,
  });

  factory PenNode.fromSettings({
    required WhiteboardPenSettings settings,
    required int order,
  }) {
    return PenNode(
      uuid: const Uuid().v4(),
      penSettings: settings,
      paintingStyle: PaintingStyle.stroke,
      order: order,
      extremity: NodeExtremity.initial(),
      points: [],
    );
  }

  final String uuid;

  final List<DrawPoint> points;

  final PaintingStyle paintingStyle;
  final WhiteboardPenSettings penSettings;

  final NodeExtremity extremity;

  @override
  void shift(Offset delta) {
    for (var i = 0; i < points.length; i++) {
      points[i] = points[i].translate(delta.dx, delta.dy);
    }
  }

  @override
  NodeBoundingBox get boundingBox {
    if (points.isEmpty) return NodeBoundingBox.zero;

    var minX = double.infinity, minY = double.infinity;
    var maxX = double.negativeInfinity, maxY = double.negativeInfinity;

    for (final point in points) {
      if (point.dx < minX) minX = point.dx;
      if (point.dy < minY) minY = point.dy;
      if (point.dx > maxX) maxX = point.dx;
      if (point.dy > maxY) maxY = point.dy;
    }

    return NodeBoundingBox(
      rect: Rect.fromLTRB(minX, minY, maxX, maxY),
      paddingOffset: Offset.zero,
    );
  }
}

Soooo, yeah, we ready to go, lets focus now on custom painter logic:


class CanvasCustomPainter extends CustomPainter {
  CanvasCustomPainter({
    required this.nodes,
    required this.offset,
    required this.scale,
    required this.screenSize,
    this.transformationController,
    this.backgroundColor,
  });

  List<WhiteboardNode> nodes;

  double scale;
  Offset offset;
  Size screenSize;

  final Color? backgroundColor;

  TransformationController? transformationController;

  @override
  void paint(Canvas canvas, Size size) {
    if (backgroundColor is Color) {
      canvas.drawColor(backgroundColor!, BlendMode.src);
    }

    if (nodes.isEmpty) return;

    // we need order to pay attention to backward/forward layers
    nodes.sort((a, b) => a.order.compareTo(b.order));

    canvas.saveLayer(Rect.largest, Paint());

    for (final node in nodes) {
      if (node is! PenNode) continue;

      // if not on the screen, lets skip rendering it
      if (_checkScribbleInvisible(
        scale: scale,
        offset: offset,
        screenSize: screenSize,
        extremity: node.extremity,
      )) {
        break;
      }

      final paint = Paint()
        ..strokeCap = node.penSettings.strokeCap
        ..isAntiAlias = true
        ..color = node.penSettings.currentColor
        ..strokeWidth = node.penSettings.strokeWidth
        ..blendMode = BlendMode.srcOver;

      _drawAllPoints(points: node.points, canvas: canvas, paint: paint);
    }

    canvas.restore();
  }

  bool _checkScribbleInvisible({
    required double scale,
    required Offset offset,
    required Size screenSize,
    required NodeExtremity extremity,
  }) {
    if ((extremity.left == 0 ||
        extremity.right == 0 ||
        extremity.top == 0 ||
        extremity.bottom == 0)) {
      return false;
    }

    return (extremity.left + offset.dx < 0 && extremity.right + offset.dx < 0)
            // Check Right
            ||
            (extremity.right + offset.dx > (screenSize.width / scale) &&
                extremity.left + offset.dx > (screenSize.width / scale))
            // Check Top
            ||
            (extremity.top + offset.dy < 0 && extremity.bottom + offset.dy < 0)
            //    Check Bottom
            ||
            (extremity.bottom + offset.dy > (screenSize.height / scale) &&
                extremity.top + offset.dy > (screenSize.height / scale))
        ? true
        : false;
  }

  void _drawAllPoints({
    required Paint paint,
    required Canvas canvas,
    required List<DrawPoint> points,
  }) {
    for (var x = 0; x < points.length - 1; x++) {
      if (!points[x + 1].visible) continue;

      canvas.drawLine(points[x], points[x + 1], paint);
    }
  }

  @override
  bool shouldRepaint(CanvasCustomPainter oldDelegate) => true;
}

so now we support smart drawing, thinking about performance, and ready to add other nodes (eraser, shapes, images, text, etc.)

final step is to implement our methods to handle interactions with whiteboard:

enum WhiteboardPointerMode {
  none,
  singleTap,
  doubleTap;

  static WhiteboardPointerMode fromPointersCount(int count) {
    switch (count) {
      case 1:
        return singleTap;
      case 2:
        return doubleTap;
      default:
        return none;
    }
  }
}

extension WhiteboardPointerModeX on WhiteboardPointerMode {
  bool get isNone => this == WhiteboardPointerMode.none;
  bool get isSingle => this == WhiteboardPointerMode.singleTap;
  bool get isDouble => this == WhiteboardPointerMode.doubleTap;
}

GlobalKey _canvasKey;
TransformationController _transformationController;

double _currentZoom = 1;
double _maxZoom = 5;
double _minZoom = 0.2;

int lastOrder = 0;
List<PenNode> nodes = [];
Offset lastFocalPoint = Offset.zero;
Offset? initialInteractionPoint;
WhiteboardPointerMode pointerMode = WhiteboardPointerMode.none;

// helper for transformation
Offset _toCurrentScene(
    TransformationController controller,
    Offset viewportPoint,
) {
    final inverseMatrix = Matrix4.tryInvert(controller.value);
    if (inverseMatrix is! Matrix4) return viewportPoint;
    return MatrixUtils.transformPoint(inverseMatrix, viewportPoint);
}

// helpers for drawing
List<PenNode> _startDrawing({
    required int order,
    required List<PenNode> nodes,
    required WhiteboardPenSettings penSettings,
}) {
    final node = PenNode.fromSettings(settings: penSettings, order: order);
    final tempNodes = List<PenNode>.from(nodes)..add(node);

    return tempNodes;
}

List<PenNode> _updateDrawing({
    required Offset point,
    required List<PenNode> scribbles,
}) {
    final tempNodes = List<PenNode>.from(scribbles);
    tempNodes.lastOrNull?.points.add(DrawPoint.fromOffset(point));

    return tempNodes;
}

void onInteractionStart(ScaleStartDetails details) {
     final pointerMode = WhiteboardPointerMode.fromPointersCount(
      details.pointerCount,
    );
    // we support only one finger (pointer) for drawing
    if (!pointerMode.isSingle) return;

    final lastFocalPoint = details.focalPoint;
    final point = _toCurrentScene(
      _transformationController,
      lastFocalPoint,
    );

    // start drawing here
    final penNodes = startDrawing(
        penSettings: _penSettings,
        nodes: nodes,
        order: lastOrder + 1,
    );

    pointerMode = WhiteboardPointerMode.singleTap;
    lastFocalPoint = point;
    nodes = penNodes;

    setState((){});
}

// here we will handle zoom, move and drawing at once
void onInteractionUpdate(ScaleUpdateDetails details) {
     final pointerMode = WhiteboardPointerMode.fromPointersCount(
      details.pointerCount,
    );
    final scale = _transformationController.value.getMaxScaleOnAxis();

    if (_currentZoom != scale) {
      _currentZoom = scale;
      setState((){});
    }

    // we handled zoom/move with two fingers, for drawing we need only 1
    if (!pointerMode.isSingle) return;

    final point = _toCurrentScene(
      _transformationController,
      details.localFocalPoint,
    );

    if (nodes.isEmpty) return;

    final penNodes = _updateDrawing(
        scribbles: nodes,
        point: point,
    );

    nodes = penNodes;
    setState((){});
}


// once interactions ended - reset
void onInteractionEnd() {
    pointerMode = WhiteboardPointerMode.fromPointersCount(0);
    initialInteractionPoint = null;
    setState((){});
}

And thats actually it, hope i didn't miss anything cause my original implementation is using BLOC for state and events, so feel free to comment if you have some issues or questions

Sign up to request clarification or add additional context in comments.

3 Comments

I'm interested in trying your solution, but I notice several elements are missing. Specifically, I'd like it to implement the BLOC architecture. What is isSingle, _transformationController, _currentZoom, _minZoom, _maxZoom, _canvasKey, _currentZoom.
I've updated the last code block
I tested your code, but it only shows a white screen—no transformation or drawing occurs.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.