Search code examples
flutterdartcanvasgraphicstransform

How can I take an rect of arbitrary positioning, and center it in the view, and scale it using only Canvas transform operations?


I have a rect that could be positioned anywhere in the 2d coordinate space. This rect will ultimately represent the bounds of an elaborate path (map) comprised of many points. When I draw the path, I don't want the stroke to scale along with the map coordinate, so I intend to use Path.transform to scale and translate without effecting the stroke. Because of this, I want to be able to do this using only transform operations on the canvas (or transform matrix).

To simplify the problem I am using using only a rect in my current example.

I want to do two things to the rect.

  1. Scale it by some scale value.
  2. Center the rect in the view (based on the Size passed in the paint method).

I believe that I need to compensate for the scale when translating but I am not sure how.

Here is my complete app code, I have comments that I hope clarify my intentions.

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key}) : super(key: key);
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text("Custom Painter Test"),
      ),
      body: Container(
        width: double.infinity,
        height: double.infinity,
        child: CustomPaint(
          painter: MyCustomPainter(),
        ),
      ),
    );
  }
}

class MyCustomPainter extends CustomPainter {
  MyCustomPainter() : super();
  @override
  void paint(Canvas canvas, Size size) {
    var rect = Rect.fromLTRB(-14, -20 , 200.0, 80.0);
    var centerX = size.width / 2;
    var centerY = size.height / 2;
    var centeringXOffset = centerX - rect.center.dx;
    var centeringYOffset = centerY - rect.center.dy;
    var scale = 2.0;

    canvas.save();

    canvas.translate(-rect.center.dx, -rect.center.dy); // translate the rect so it is at 0,0 to scale from center of rect
    canvas.scale(scale);
    canvas.translate(rect.center.dx, rect.center.dy); // translate the rect to original position
    canvas.translate(centeringXOffset, centeringYOffset); // translate to center the rect

    // draw the rect with border
    canvas.drawRect(rect, Paint()..color = Colors.red);
    var innerRect = rect.deflate(3);
    canvas.drawRect(innerRect, Paint()..color = Colors.white.withOpacity(0.8));

    // draw the rect center point
    canvas.drawCircle(Offset(rect.center.dx,rect.center.dy) , 3.0 , Paint()..color = Colors.red);

    // draw the origincal center point of the view
    canvas.restore();
    canvas.drawCircle(Offset(centerX , centerY) , 4.0 , Paint()..color = Colors.black.withOpacity(0.32));
  }

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

Solution

  • when using Matrix4 api you can apply two approaches: the first one is a generic matrix multiplication so you can combine translation, scale and rotation matrices or direct matrix scaling and translating which can be somehow tricky (as you found out when scaling and translating the Canvas in the code you posted)

    below code uses those two approaches: the red shape uses matrix multiplication, the orange one uses direct matrix scaling and translating (note that for your particular case you can use rectToRect2() simplified method too)

    // your widget code:
      final dragDetails = ValueNotifier(DragUpdateDetails(globalPosition: Offset.zero, primaryDelta: 0.0));
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onVerticalDragUpdate: (d) => dragDetails.value = d,
          child: CustomPaint(
            painter: FooPainter(dragDetails),
            child: Center(child: Text('move your finger up and down', textScaleFactor: 2)),
          ),
        );
      }
    
    // custom painter code    
    class FooPainter extends CustomPainter {
      final ValueNotifier<DragUpdateDetails> dragDetails;
      FooPainter(this.dragDetails) : super(repaint: dragDetails);
    
      double scale = 4.0;
      @override
      void paint(Canvas canvas, Size size) {
        final painterRect = Offset.zero & size;
        canvas.drawRect(painterRect, Paint()..color = Colors.black26);
        final center1 = Offset(size.width / 2, size.height / 3);
        final center2 = Offset(size.width / 2, size.height * 2 / 3);
        canvas.drawPoints(PointMode.lines, [
          Offset(0, center1.dy), Offset(size.width, center1.dy),
          Offset(0, center2.dy), Offset(size.width, center2.dy),
          painterRect.topCenter, painterRect.bottomCenter
        ], Paint()..color = Colors.black38);
    
        final scaleFactor = pow(2, -dragDetails.value.primaryDelta / 128);
        scale *= scaleFactor;
        final rect = Rect.fromLTWH(14, 20, 20, 28);
        final path = Path()
          ..fillType = PathFillType.evenOdd
          ..addOval(Rect.fromCircle(center: rect.bottomLeft, radius: 8))
          ..addRRect(RRect.fromRectAndRadius(rect, Radius.circular(3)))
          ..addOval(Rect.fromCircle(center: rect.center, radius: 3));
        Matrix4 matrix;
        Path transformedPath;
    
        // first solution
        final tranlationMatrix = _translate(center1 - rect.center);
        final scaleMatrix = _scale(scale, center1);
        matrix = scaleMatrix * tranlationMatrix;
        transformedPath = path.transform(matrix.storage);
        canvas.drawPath(transformedPath, Paint()..color = Colors.red);
        canvas.drawPath(transformedPath, Paint()..style = PaintingStyle.stroke ..strokeWidth = 4 ..color = Colors.white);
    
        // second solution
        // you can also use simplified rectToRect2() that "fills" src rect into dst one
        matrix = rectToRect(rect, Rect.fromCenter(center: center2, width: scale * rect.width, height: scale * rect.height));
        transformedPath = path.transform(matrix.storage);
        canvas.drawPath(transformedPath, Paint()..color = Colors.orange);
        canvas.drawPath(transformedPath, Paint()..style = PaintingStyle.stroke ..strokeWidth = 4 ..color = Colors.white);
    
        canvas.drawPath(path, Paint()..color = Colors.deepPurple);
      }
    
      @override
      bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
    }
    
    Matrix4 _translate(Offset translation) {
      var dx = translation.dx;
      var dy = translation.dy;
      return Matrix4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
    }
    
    Matrix4 _scale(double scale, Offset focalPoint) {
      var dx = (1 - scale) * focalPoint.dx;
      var dy = (1 - scale) * focalPoint.dy;
      return Matrix4(scale, 0, 0, 0, 0, scale, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
    }
    
    /// Return a scaled and translated [Matrix4] that maps [src] to [dst] for given [fit]
    /// aligned by [alignment] within [dst]
    ///
    /// For example, if you have a [CustomPainter] with size 300 x 200 logical pixels and
    /// you want to draw an expanded, centered image with size 80 x 100 you can do the following:
    /// 
    /// ```dart
    ///  canvas.save();
    ///  var matrix = sizeToRect(imageSize, Offset.zero & customPainterSize);
    ///  canvas.transform(matrix.storage);
    ///  canvas.drawImage(image, Offset.zero, Paint());
    ///  canvas.restore();
    /// ```
    /// 
    ///  and your image will be drawn inside a rect Rect.fromLTRB(70, 0, 230, 200)
    Matrix4 sizeToRect(Size src, Rect dst, {BoxFit fit = BoxFit.contain, Alignment alignment = Alignment.center}) {
      FittedSizes fs = applyBoxFit(fit, src, dst.size);
      double scaleX = fs.destination.width / fs.source.width;
      double scaleY = fs.destination.height / fs.source.height;
      Size fittedSrc = Size(src.width * scaleX, src.height * scaleY);
      Rect out = alignment.inscribe(fittedSrc, dst);
    
      return Matrix4.identity()
        ..translate(out.left, out.top)
        ..scale(scaleX, scaleY);
    }
    
    /// Like [sizeToRect] but accepting a [Rect] as [src]
    Matrix4 rectToRect(Rect src, Rect dst, {BoxFit fit = BoxFit.contain, Alignment alignment = Alignment.center}) {
      return sizeToRect(src.size, dst, fit: fit, alignment: alignment)
        ..translate(-src.left, -src.top);
    }
    
    Matrix4 rectToRect2(Rect src, Rect dst) {
      final scaleX = dst.width / src.width;
      final scaleY = dst.height / src.height;
      return Matrix4.identity()
        ..translate(dst.left, dst.top)
        ..scale(scaleX, scaleY)
        ..translate(-src.left, -src.top);
    }
    

    EDIT

    you can further simplify rectToRect2 method to:

    Matrix4 pointToPoint(double scale, Offset srcFocalPoint, Offset dstFocalPoint) {
      return Matrix4.identity()
        ..translate(dstFocalPoint.dx, dstFocalPoint.dy)
        ..scale(scale)
        ..translate(-srcFocalPoint.dx, -srcFocalPoint.dy);
    }
    

    and use it like this:

    matrix = pointToPoint(scale, rect.center, center2);