flutterdartflutter-canvasflutter-custompainter

Flutter - drawAtlas circular cutout


I'm trying to implement a circular cutout of an image and I am using drawAtlas for it. Here is my implementation so far:

canvas.drawAtlas(
  image!,
  [
    /* Identity transform */
    RSTransform.fromComponents(
      rotation: 0.0,
      scale: 1,
      anchorX: 0,
      anchorY: 0,
      translateX: 0,
      translateY: 0,
    )
  ],
  [
    Rect.fromCircle(
      center: Offset(size.width / 2, size.height / 2),
      radius: 200,
    ),
  ],
  [],
  null,
  null,
  Paint(),
);

While it does work, it paints a rectangular image. I want to paint a circular cutout of the same image with some strokeWidth. Is it possible to do so using drawAtlas?


Solution

  • Excellent question! Although in your code, you are calling Rect.fromCircle to specify a clipping area, but it's still a rect (you are constructing a rect from a circle). The drawAtlas method only supports clipping rectangular areas from a larger image, as you probably already found out.

    The key here is to use canvas.clipRRect to reduce the "allowed painting area". This is similar to adding a selection box in Photoshop. After doing this, anything that paints outside of the selection box will be ignored. In other words, by calling canvas.clipRRect, we will no longer be able to draw anything outside of the clipped area.

    Presumably, after drawing a circular atlas, you might still want to draw other things to the canvas. And for those other things, you probably won't want to be limited to the clipped circle. To solve this, we can use canvas.saveLayer to save the state of the canvas before clipping happens, then do the circular clipping, then draw the altas, then finally, call canvas.restore to restore the previously saved state. Essentially this would clear the clipping, allowing us to draw more things anywhere on the canvas if needed.

    Solution:

    In short, the paint method might look something like this:

      void paint(Canvas canvas, Size size) {
        // Save the canvas state so we can clip it and then restore it later
        canvas.saveLayer(Rect.largest, Paint());
        // Clip the canvas, so we are only allowed to draw inside the circle
        canvas.clipRRect(
          RRect.fromRectAndRadius(
            Rect.fromLTWH(0, 0, 100, 100),
            Radius.circular(50),
          ),
        );
        // Draw the atlas (call your method)
        _drawAtlas(canvas, size);
        // Restore the canvas to its original state, so further drawings are not clipped
        canvas.restore();
      }
    

    Demo:

    demo picture

    In the demo, when you press the button, the app will take a screenshot of the Flutter Logo inside the grey gradient container, and use that as the ui.image data. Then it will do the drawAtlas to draw a circular portion of the image to canvas.

    Full demo code (paste everything to main.dart to run):

    import 'dart:ui' as ui;
    import 'package:flutter/material.dart';
    import 'package:flutter/rendering.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: MyHomePage(),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage({Key? key}) : super(key: key);
    
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      final _globalKey = GlobalKey();
    
      ui.Image? _image;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Flutter Demo Home Page'),
          ),
          body: Center(
            child: Column(
              children: [
                RepaintBoundary(
                  key: _globalKey,
                  child: Container(
                    width: 300,
                    height: 300,
                    decoration: BoxDecoration(
                      gradient: RadialGradient(
                        colors: [Colors.white, Colors.grey],
                      ),
                    ),
                    child: FlutterLogo(),
                  ),
                ),
                ElevatedButton(
                  child: Text('Press Me'),
                  onPressed: () async {
                    final render = (_globalKey.currentContext!.findRenderObject()
                        as RenderRepaintBoundary);
                    final image = await render.toImage();
                    setState(() {
                      _image = image;
                    });
                  },
                ),
                if (_image != null)
                  CustomPaint(
                    size: Size(300, 300),
                    painter: MyPainter(_image!),
                  ),
              ],
            ),
          ),
        );
      }
    }
    
    class MyPainter extends CustomPainter {
      final ui.Image image;
    
      MyPainter(this.image);
    
      @override
      void paint(Canvas canvas, Size size) {
        // Save the canvas state so we can clip it and then restore it later
        canvas.saveLayer(Rect.largest, Paint());
        // Clip the canvas, so we are only allowed to draw inside the circle
        canvas.clipRRect(
          RRect.fromRectAndRadius(
            Rect.fromLTWH(0, 0, 100, 100),
            Radius.circular(50),
          ),
        );
        // Draw the atlas (call your method)
        _drawAtlas(canvas, size);
        // Restore the canvas to its original state, so further drawings are not clipped
        canvas.restore();
      }
    
      _drawAtlas(Canvas canvas, Size size) {
        canvas.drawAtlas(
          image,
          [
            /* Identity transform */
            RSTransform.fromComponents(
              rotation: 0.0,
              scale: 1,
              anchorX: 0,
              anchorY: 0,
              translateX: 0,
              translateY: 0,
            )
          ],
          [
            Rect.fromCircle(
              center: Offset(size.width / 2, size.height / 2),
              radius: 50,
            ),
          ],
          [],
          null,
          null,
          Paint(),
        );
      }
    
      @override
      bool shouldRepaint(oldDelegate) => true;
    }