Flutter: Infinite scrolling canvas with CustomPainter

sweesenkoh

Swee Sen

Posted on November 22, 2020

Flutter: Infinite scrolling canvas with CustomPainter

Overview

There are many tutorials on canvas drawing using CustomPainter. However, there are no tutorials on how to create an infinite drawing canvas using Flutter. In this article, I will show you a simple trick that can be used to implement infinite scrolling canvas with high performance. Github Link

ezgif-5-ee57c6228769 1

1. Setup CustomPainter for drawable canvas

I will briefly explain this section as there are many tutorials online on this section. The main idea is that we first need to create a CustomPainter class and implement the logic for drawing. Here is one way of implementing it:

class CanvasCustomPainter extends CustomPainter {
  List<Offset> points;

  CanvasCustomPainter({@required this.points});

  @override
  void paint(Canvas canvas, Size size) {
    //define canvas background color
    Paint background = Paint()..color = Colors.white;

    //define canvas size
    Rect rect = Rect.fromLTWH(0, 0, size.width, size.height);

    canvas.drawRect(rect, background);
    canvas.clipRect(rect);

    //define the paint properties to be used for drawing
    Paint drawingPaint = Paint()
      ..strokeCap = StrokeCap.round
      ..isAntiAlias = true
      ..color = Colors.black
      ..strokeWidth = 1.5;

    //a single line is defined as a series of points followed by a null at the end
    for (int x = 0; x < points.length - 1; x++) {
      //drawing line between the points to form a continuous line
      if (points[x] != null && points[x + 1] != null) {
        canvas.drawLine(points[x], points[x + 1], drawingPaint);
      }
      //if next point is null, means the line ends here
      else if (points[x] != null && points[x + 1] == null) {
        canvas.drawPoints(PointMode.points, [points[x]], drawingPaint);
      }
    }
  }

  @override
  bool shouldRepaint(CanvasCustomPainter oldDelegate) {
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

With the CustomPainter setup done, we can now use a CustomPaint widget to display the canvas:

class _InfiniteCanvasPageState extends State<InfiniteCanvasPage> {
  List<Offset> points = [];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SizedBox.expand(
          child: ClipRRect(
            child: CustomPaint(
              painter: CanvasCustomPainter(points: points),
            ),
          ),
        ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The last step is to use the GestureDetector to add the points into our points variable. The CustomPaint will then render those points onto the canvas.

class _InfiniteCanvasPageState extends State<InfiniteCanvasPage> {
  List<Offset> points = [];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        onPanDown: (details) {
          this.setState(() {
            points.add(details.localPosition);
          });
        },
        onPanUpdate: (details) {
          this.setState(() {
            points.add(details.localPosition);
          });
        },
        onPanEnd: (details) {
          this.setState(() {
            points.add(null);
          });
        },
        child: SizedBox.expand(
          child: ClipRRect(
            child: CustomPaint(
              painter: CanvasCustomPainter(points: points),
            ),
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

By this step we now have a basic drawable canvas!

ezgif-5-edaaad2aa3fc

2. Making an infinite scrolling canvas

Defining CanvasState and Changing between Them

At this point, whenever we touch the screen, it will immediately draw the line on the canvas. To make an infinite scrolling canvas, we must first have a way to pan around the canvas. This implies that our canvas should have two different states: Panning state and Drawing state. Panning state will allow user to pan around the canvas while Drawing state allows user to draw onto the canvas. We can make use of Enum for such scenario.

enum CanvasState { pan, draw }
Enter fullscreen mode Exit fullscreen mode

And we shall initialise a CanvasState variable in our InfiniteCanvasPage class:

class _InfiniteCanvasPageState extends State<InfiniteCanvasPage> {
  List<Offset> points = [];
  CanvasState canvasState = CanvasState.draw;

  @override
  Widget build(BuildContext context) {
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

We can then use a button to switch between the two states. You can implement your button in whichever way you prefer. For demo purposes, I am using the FloatingActionButton in the Scaffold.

@override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        child: Text(canvasState == CanvasState.draw ? "Draw" : "Pan"),
        backgroundColor:
            canvasState == CanvasState.draw ? Colors.red : Colors.blue,
        onPressed: () {
          this.setState(() {
            canvasState = canvasState == CanvasState.draw
                ? CanvasState.pan
                : CanvasState.draw;
          });
        },
      ),
      body: GestureDetector(
        ...
      ),
    );
  }
Enter fullscreen mode Exit fullscreen mode

ezgif-5-bd39a0069508

Implement the canvas pan logic

To create the effect of infinite scrolling canvas, the idea of having an infinitely large CustomPaint widget is not feasible and not a good way to implement it due to performance reasons. Instead, another method that we can use to achieve the same bahaviour is to make use of an Offset variable. Whenever the user pans the screen, we will add the pan distance to the offset variable. Whenever we render the lines onto the canvas, we will subtract this offset to the coordinates of each point in order to render the points at the correct position after offset.

For example, if the user pans the screen from right to left for 50px, the offset variable is now (-50,0) . If we have a point that is originally at (70,10) we can now add this point with the offset, giving the final coordinate to be (20,10) .

First, we add an offset variable into our InfiniteCanvasPage class:

class _InfiniteCanvasPageState extends State<InfiniteCanvasPage> {
  List<Offset> points = [];
  CanvasState canvasState = CanvasState.draw;

    //add the offset variable to keep track of the current offset
  Offset offset = Offset(0, 0);

    ...
}
Enter fullscreen mode Exit fullscreen mode

The next step is to modify our CanvasCustomPainter class. For each of the points being rendered, we need to account for the offset:

class CanvasCustomPainter extends CustomPainter {
  List<Offset> points;
  Offset offset;

  CanvasCustomPainter({@required this.points, this.offset});

  @override
  void paint(Canvas canvas, Size size) {

        ...
        ...

    //added offset when rendering
    for (int x = 0; x < points.length - 1; x++) {

      if (points[x] != null && points[x + 1] != null) {
        canvas.drawLine(
            points[x] + offset, points[x + 1] + offset, drawingPaint);
      }

      else if (points[x] != null && points[x + 1] == null) {
        canvas.drawPoints(PointMode.points, [points[x] + offset], drawingPaint);
      }
    }
  }

    ...
    ...
}
Enter fullscreen mode Exit fullscreen mode

We also have to update our CustomPaint component to pass in the offset variable

SizedBox.expand(
          child: ClipRRect(
            child: CustomPaint(
              painter: CanvasCustomPainter(points: points, offset: offset),
            ),
          ),
        )
Enter fullscreen mode Exit fullscreen mode

The last step is to modify our logic in the various onPan method inside the GestureDetector. The main idea is when canvasState == CanvasState.pan, we have to add the pan distance to the offset variable. Luckily, this can be conveniently achieved by using the details.delta variable provided by onPanUpdate. Here is how our final GestureDetector code will look like:

...

body: GestureDetector(
        onPanDown: (details) {
          this.setState(() {
            if (canvasState == CanvasState.draw) {
              points.add(details.localPosition - offset);
            }
          });
        },
        onPanUpdate: (details) {
          this.setState(() {
            if (canvasState == CanvasState.pan) {
              offset += details.delta;
            } else {
              points.add(details.localPosition - offset);
            }
          });
        },
        onPanEnd: (details) {
          this.setState(() {
            if (canvasState == CanvasState.draw) {
              points.add(null);
            }
          });
        },
        child: SizedBox.expand(
          child: ClipRRect(
            child: CustomPaint(
              painter: CanvasCustomPainter(points: points, offset: offset),
            ),
          ),
        ),
      ),

...
Enter fullscreen mode Exit fullscreen mode

With this, we now have an infinite scrolling canvas!

ezgif-5-ee57c6228769 1

💖 💪 🙅 🚩
sweesenkoh
Swee Sen

Posted on November 22, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related