Flutter: Infinite scrolling canvas with CustomPainter
Swee Sen
Posted on November 22, 2020
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
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;
}
}
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),
),
),
),
);
}
}
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),
),
),
),
),
);
}
}
By this step we now have a basic drawable canvas!
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 }
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) {
...
}
}
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(
...
),
);
}
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);
...
}
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);
}
}
}
...
...
}
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),
),
),
)
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),
),
),
),
),
...
With this, we now have an infinite scrolling canvas!
Posted on November 22, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.