Supabase has a low latency real-time communication feature called Broadcast. With it, you can have your clients communicate with other clients with low latencies. This is useful for creating apps with connected experiences. Flutter has a CustomPainter class, which allows developers to interact with the low-level canvas API allowing us to render virtually anything on the app. Combining these two tools allows us to create interactive apps.
In this article, I am combining the Supabase Realtime Broadcast with Flutter’s CustomPainter
to create a collaborative design board app like Figma.
You can find the full code example here.
Overview of the Figma clone app
We are building an interactive design canvas app where multiple users can collaborate in real time. We will add the following features to the app:
- Draw shapes such as circles or rectangles
- Move those shapes around
- Sync the cursor position and the design objects with other clients in real-time
- Persist the state of the canvas in a Postgres database
Okay, Figma clone might be an overstatement. However, the point of this article is to demonstrate how to build a collaborative app with all the fundamental elements of a collaborative design canvas. You can take the concepts of this app, add features, refine it, and make it as sophisticated as Figma.
Setting up the app
Create a blank Flutter application
Let’s start by creating a blank Flutter app.
_10flutter create canvas --empty --platforms=web
--empty
flag creates a blank Flutter project without the initial counter template. --platforms
specify which platform to support with this Flutter application. Because we are working on an app that involves cursors, we are going to focus on the web for this example, but you can certainly run the same code on other platforms as well.
Install the dependencies
We will use two dependencies for this app.
- supabase_flutter: Used to interact with the Supabase instance for real-time communication and storing canvas data.
- uuid: Used to generate unique identifiers for each user and canvas objects. To keep this example simple, we will not add authentication, and will just assign randomly generated UUIDs to each user.
Run the following command to add the dependencies to your app.
_10flutter pub add supabase_flutter uuid
Setup the Supabase project
In this example, we will be using a remote Supabase instance, but if you would like to follow along with a local Supabase instance, that is fine too.
You can head to database.new to create a new Supabase project for free. It will only take a minute or two to set up your project with a fully-fledged Postgres database.
Once your project is ready, run the following SQL from the SQL editor of your dashboard to set up the table and RLS policies for this app. To keep this article simple, we will not implement auth, so the policies you see are fairly simple.
_10create table canvas_objects (_10 id uuid primary key default gen_random_uuid() not null,_10 "object" jsonb not null,_10 created_at timestamp with time zone default timezone('utc'::text, now()) not null_10);_10_10alter table canvas_objects enable row level security;_10create policy select_canvas_objects on canvas_objects as permissive for select to anon using (true);_10create policy insert_canvas_objects on canvas_objects as permissive for insert to anon with check (true);_10create policy update_canvas_objects on canvas_objects as permissive for update to anon using (true);
Building the Figma clone app
The app that we will build will have the following structure.
_10lib/_10├── canvas/ # A folder containing the main canvas-related files._10│ ├── canvas_object.dart # Data model definitions._10│ ├── canvas_page.dart # The canvas page widget._10│ └── canvas_painter.dart # Custom painter definitions._10├── utils/_10│ └── constants.dart # A file to hold Supabase Realtime specific constants_10└── main.dart # Entry point of the app
Step1: Initialize Supabase
Open the lib/main.dart
file and add the following. You should replace the credentials with your own from the Supabase dashboard under settings > API
. You should see an error with the import of the canvas_page.dart
file, but we will create it momentarily.
_27import 'package:canvas/canvas/canvas_page.dart';_27import 'package:flutter/material.dart';_27import 'package:supabase_flutter/supabase_flutter.dart';_27_27void main() async {_27 Supabase.initialize(_27 // TODO: Replace the credentials with your own_27 url: 'YOUR_SUPABASE_URL',_27 anonKey: 'YOUR_SUPABASE_ANON_KEY',_27 );_27 runApp(const MyApp());_27}_27_27final supabase = Supabase.instance.client;_27_27class MyApp extends StatelessWidget {_27 const MyApp({super.key});_27_27 @override_27 Widget build(BuildContext context) {_27 return const MaterialApp(_27 title: 'Figma Clone',_27 debugShowCheckedModeBanner: false,_27 home: CanvasPage(),_27 );_27 }_27}
Step 2: Create the constants file
It is nice to organize the app’s constants in a file. Create lib/utils/constants.dart
file and add the following. These values will later be used when we are setting up Supabase Realtime listeners.
_10abstract class Constants {_10 /// Name of the Realtime channel_10 static const String channelName = 'canvas';_10_10 /// Name of the broadcast event_10 static const String broadcastEventName = 'canvas';_10}
Step 3: Create the data model
We will need to create data models for each of the following:
- The cursor position of the user.
- The objects we can draw on the canvas. Includes:
- Circle
- Rectangle
Create lib/canvas/canvas_object.dart
file. The file is a bit long, so I will break it down in each component below. Add all of the code into the canvas_object.dart
file as we step through them.
At the top of the file, we have an extension method to generate random colors. One of the methods generates a random color, which will be used to set the color of a newly created object, and the other generates a random with a seed of a UUID, which will be used to determine the user’s cursor color.
_19import 'dart:convert';_19import 'dart:math';_19import 'dart:ui';_19_19import 'package:uuid/uuid.dart';_19_19/// Handy extension method to create random colors_19extension RandomColor on Color {_19 static Color getRandom() {_19 return Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0);_19 }_19_19 /// Quick and dirty method to create a random color from the userID_19 static Color getRandomFromId(String id) {_19 final seed = utf8.encode(id).reduce((value, element) => value + element);_19 return Color((Random(seed).nextDouble() * 0xFFFFFF).toInt())_19 .withOpacity(1.0);_19 }_19}
We then have the SyncedObject
class. SyncedObject
class is the base class for anything that will be synced in real time, this includes both the cursor and the objects. It has an id
property, which will be UUID, and it has toJson
method, which is required to pass the object information over Supabase’s broadcast feature.
_22/// Objects that are being synced in realtime over broadcast_22///_22/// Includes mouse cursor and design objects_22abstract class SyncedObject {_22 /// UUID unique identifier of the object_22 final String id;_22_22 factory SyncedObject.fromJson(Map<String, dynamic> json) {_22 final objectType = json['object_type'];_22 if (objectType == UserCursor.type) {_22 return UserCursor.fromJson(json);_22 } else {_22 return CanvasObject.fromJson(json);_22 }_22 }_22_22 SyncedObject({_22 required this.id,_22 });_22_22 Map<String, dynamic> toJson();_22}
Now to sync the user’s cursor with other clients, we have the UserCursor
class. It inherits the SyncedObject
class and has JSON parsing implemented.
_29/// Data model for the cursors displayed on the canvas._29class UserCursor extends SyncedObject {_29 static String type = 'cursor';_29_29 final Offset position;_29 final Color color;_29_29 UserCursor({_29 required super.id,_29 required this.position,_29 }) : color = RandomColor.getRandomFromId(id);_29_29 UserCursor.fromJson(Map<String, dynamic> json)_29 : position = Offset(json['position']['x'], json['position']['y']),_29 color = RandomColor.getRandomFromId(json['id']),_29 super(id: json['id']);_29_29 @override_29 Map<String, dynamic> toJson() {_29 return {_29 'object_type': type,_29 'id': id,_29 'position': {_29 'x': position.dx,_29 'y': position.dy,_29 }_29 };_29 }_29}
There is an additional set of data that we want to sync in real-time, and that is the individual shapes within the canvas. We create the CanvasObject
abstract class, which is the base class for any shapes within the canvas. This class extends the SyncedObject
because we want to sync it to other clients. In addition to the id
property, we have a color
property, because every shape needs a color. We also have a few methods.
intersectsWith()
takes a point within the canvas and returns whether the point intersects with the shape or not. This is used when grabbing the shape on the canvas.copyWith()
is a standard method to create a copy of the instance.move()
is a method to create a version of the instance that is moved bydelta
. This will be used when the shape is being dragged on canvas.
_27/// Base model for any design objects displayed on the canvas._27abstract class CanvasObject extends SyncedObject {_27 final Color color;_27_27 CanvasObject({_27 required super.id,_27 required this.color,_27 });_27_27 factory CanvasObject.fromJson(Map<String, dynamic> json) {_27 if (json['object_type'] == CanvasCircle.type) {_27 return CanvasCircle.fromJson(json);_27 } else if (json['object_type'] == CanvasRectangle.type) {_27 return CanvasRectangle.fromJson(json);_27 } else {_27 throw UnimplementedError('Unknown object_type: ${json['object_type']}');_27 }_27 }_27_27 /// Whether or not the object intersects with the given point._27 bool intersectsWith(Offset point);_27_27 CanvasObject copyWith();_27_27 /// Moves the object to a new position_27 CanvasObject move(Offset delta);_27}
Now that we have the base class for the canvas objects, let’s define the actual shapes we will support in this application. Each object will inherit CanvasObject
and will have additional properties like center
and radius
for the circle.
In this article, we are only supporting circles and rectangles, but you can easily expand this and add support for other shapes.
_141/// Circle displayed on the canvas._141class Circle extends CanvasObject {_141 static String type = 'circle';_141_141 final Offset center;_141 final double radius;_141_141 Circle({_141 required super.id,_141 required super.color,_141 required this.radius,_141 required this.center,_141 });_141_141 Circle.fromJson(Map<String, dynamic> json)_141 : radius = json['radius'],_141 center = Offset(json['center']['x'], json['center']['y']),_141 super(id: json['id'], color: Color(json['color']));_141_141 /// Constructor to be used when first starting to draw the object on the canvas_141 Circle.createNew(this.center)_141 : radius = 0,_141 super(id: const Uuid().v4(), color: RandomColor.getRandom());_141_141 @override_141 Map<String, dynamic> toJson() {_141 return {_141 'object_type': type,_141 'id': id,_141 'color': color.value,_141 'center': {_141 'x': center.dx,_141 'y': center.dy,_141 },_141 'radius': radius,_141 };_141 }_141_141 @override_141 Circle copyWith({_141 double? radius,_141 Offset? center,_141 Color? color,_141 }) {_141 return Circle(_141 radius: radius ?? this.radius,_141 center: center ?? this.center,_141 id: id,_141 color: color ?? this.color,_141 );_141 }_141_141 @override_141 bool intersectsWith(Offset point) {_141 final centerToPointerDistance = (point - center).distance;_141 return radius > centerToPointerDistance;_141 }_141_141 @override_141 Circle move(Offset delta) {_141 return copyWith(center: center + delta);_141 }_141}_141_141/// Rectangle displayed on the canvas._141class Rectangle extends CanvasObject {_141 static String type = 'rectangle';_141_141 final Offset topLeft;_141 final Offset bottomRight;_141_141 Rectangle({_141 required super.id,_141 required super.color,_141 required this.topLeft,_141 required this.bottomRight,_141 });_141_141 Rectangle.fromJson(Map<String, dynamic> json)_141 : bottomRight =_141 Offset(json['bottom_right']['x'], json['bottom_right']['y']),_141 topLeft = Offset(json['top_left']['x'], json['top_left']['y']),_141 super(id: json['id'], color: Color(json['color']));_141_141 /// Constructor to be used when first starting to draw the object on the canvas_141 Rectangle.createNew(Offset startingPoint)_141 : topLeft = startingPoint,_141 bottomRight = startingPoint,_141 super(color: RandomColor.getRandom(), id: const Uuid().v4());_141_141 @override_141 Map<String, dynamic> toJson() {_141 return {_141 'object_type': type,_141 'id': id,_141 'color': color.value,_141 'top_left': {_141 'x': topLeft.dx,_141 'y': topLeft.dy,_141 },_141 'bottom_right': {_141 'x': bottomRight.dx,_141 'y': bottomRight.dy,_141 },_141 };_141 }_141_141 @override_141 Rectangle copyWith({_141 Offset? topLeft,_141 Offset? bottomRight,_141 Color? color,_141 }) {_141 return Rectangle(_141 topLeft: topLeft ?? this.topLeft,_141 id: id,_141 bottomRight: bottomRight ?? this.bottomRight,_141 color: color ?? this.color,_141 );_141 }_141_141 @override_141 bool intersectsWith(Offset point) {_141 final minX = min(topLeft.dx, bottomRight.dx);_141 final maxX = max(topLeft.dx, bottomRight.dx);_141 final minY = min(topLeft.dy, bottomRight.dy);_141 final maxY = max(topLeft.dy, bottomRight.dy);_141 return minX < point.dx &&_141 point.dx < maxX &&_141 minY < point.dy &&_141 point.dy < maxY;_141 }_141_141 @override_141 Rectangle move(Offset delta) {_141 return copyWith(_141 topLeft: topLeft + delta,_141 bottomRight: bottomRight + delta,_141 );_141 }_141}
That is it for the canvas_object.dart
file.
Step 4: Create the custom painter
CustomPainter
is a low-level API to interact with the canvas within a Flutter application. We will create our own CustomPainter
that takes the cursor positions and the objects within the app and draws them on a canvas.
Create lib/canvas/canvas_painter.dart
file and add the following.
_48import 'package:canvas/canvas/canvas_object.dart';_48import 'package:flutter/material.dart';_48_48class CanvasPainter extends CustomPainter {_48 final Map<String, UserCursor> userCursors;_48 final Map<String, CanvasObject> canvasObjects;_48_48 CanvasPainter({_48 required this.userCursors,_48 required this.canvasObjects,_48 });_48_48 @override_48 void paint(Canvas canvas, Size size) {_48 // Draw each canvas objects_48 for (final canvasObject in canvasObjects.values) {_48 if (canvasObject is Circle) {_48 final position = canvasObject.center;_48 final radius = canvasObject.radius;_48 canvas.drawCircle(_48 position, radius, Paint()..color = canvasObject.color);_48 } else if (canvasObject is Rectangle) {_48 final position = canvasObject.topLeft;_48 final bottomRight = canvasObject.bottomRight;_48 canvas.drawRect(_48 Rect.fromLTRB(_48 position.dx, position.dy, bottomRight.dx, bottomRight.dy),_48 Paint()..color = canvasObject.color);_48 }_48 }_48_48 // Draw the cursors_48 for (final userCursor in userCursors.values) {_48 final position = userCursor.position;_48 canvas.drawPath(_48 Path()_48 ..moveTo(position.dx, position.dy)_48 ..lineTo(position.dx + 14.29, position.dy + 44.84)_48 ..lineTo(position.dx + 20.35, position.dy + 25.93)_48 ..lineTo(position.dx + 39.85, position.dy + 24.51)_48 ..lineTo(position.dx, position.dy),_48 Paint()..color = userCursor.color);_48 }_48 }_48_48 @override_48 bool shouldRepaint(oldPainter) => true;_48}
userCursors
and canvasObjects
represent the cursors and the objects within the canvas respectively. The key of the Map
is the UUID unique identifiers.
The paint()
method is where the drawing on the canvas happens. It first loops through the objects and draws them on the canvas. Each shape has its drawing method, so we will check the type of the object in each loop and apply the respective drawing method.
Once we have all the objects drawn, we draw the cursors. The reason why we draw the cursors after the objects is because within a custom painter, whatever is drawn later draws over the previously drawn objects. Because we do not want the cursors to be hidden behind the objects, we draw all the cursors after all of the objects are done being drawn.
shouldRepaint()
defines whether we want the canvas to be repainted when the CustomPainter
receives a new set of properties. In our case, we want to redraw the painter whenever we receive a new set of properties, so we always return true.
Step 5: Create the canvas page
Now that we have the data models and our custom painter ready, it is time to put everything together. We will create a canvas page, the only page of this app, which allows users to draw shapes and move those shapes around while keeping the states in sync with other users.
Create lib/canvas/canvas_page.dart
file. Add all of the code shown within this step into canvas_page.dart
. Start by adding all the necessary imports for this app.
_10import 'dart:math';_10_10import 'package:canvas/canvas/canvas_object.dart';_10import 'package:canvas/canvas/canvas_painter.dart';_10import 'package:canvas/main.dart';_10import 'package:canvas/utils/constants.dart';_10import 'package:flutter/material.dart';_10import 'package:supabase_flutter/supabase_flutter.dart';_10import 'package:uuid/uuid.dart';
We can then create an enum to represent the three different actions we can perform in this app, pointer
for moving objects around, circle
for drawing circles, and rectangle
for drawing rectangles.
_16/// Different input modes users can perform_16enum _DrawMode {_16 /// Mode to move around existing objects_16 pointer(iconData: Icons.pan_tool_alt),_16_16 /// Mode to draw circles_16 circle(iconData: Icons.circle_outlined),_16_16 /// Mode to draw rectangles_16 rectangle(iconData: Icons.rectangle_outlined);_16_16 const _DrawMode({required this.iconData});_16_16 /// Icon used in the IconButton to toggle the mode_16 final IconData iconData;_16}
Finally, we can get to the meat of the app, creating the CanvasPage
widget. Create an empty StatefulWidget
with a blank Scaffold
. We will be adding properties, methods, and widgets to it.
_19_19/// Interactive art board page to draw and collaborate with other users._19class CanvasPage extends StatefulWidget {_19 const CanvasPage({super.key});_19_19 @override_19 State<CanvasPage> createState() => _CanvasPageState();_19}_19_19class _CanvasPageState extends State<CanvasPage> {_19 // TODO: Add properties_19_19 // TODO: Add methods_19_19 @override_19 Widget build(BuildContext context) {_19 return Scaffold();_19 }_19}
First, we can define all of the properties we need for this widget. _userCursors
and _canvasObjects
will hold the cursors and canvas objects the app receives from the real-time listener. _canvasChanel
is the gateway for the client to communicate with other clients using Supabase Realtime. We will later implement the logic to send and receive information about the canvas. Then there are a few states that will be used when we implement the drawing on the canvas.
_32class _CanvasPageState extends State<CanvasPage> {_32 /// Holds the cursor information of other users_32 final Map<String, UserCursor> _userCursors = {};_32_32 /// Holds the list of objects drawn on the canvas_32 final Map<String, CanvasObject> _canvasObjects = {};_32_32 /// Supabase Realtime channel to communicate to other clients_32 late final RealtimeChannel _canvasChanel;_32_32 /// Randomly generated UUID for the user_32 late final String _myId;_32_32 /// Whether the user is using the pointer to move things around, or in drawing mode._32 _DrawMode _currentMode = _DrawMode.pointer;_32_32 /// A single Canvas object that is being drawn by the user if any._32 String? _currentlyDrawingObjectId;_32_32 /// The point where the pan started_32 Offset? _panStartPoint;_32_32 /// Cursor position of the user._32 Offset _cursorPosition = const Offset(0, 0);_32_32 // TODO: Add methods_32_32 @override_32 Widget build(BuildContext context) {_32 return Scaffold();_32 }_32}
Now that we have the properties defined, we can run some initialization code to set up the scene. There are a few things we are doing in this initialization step.
One, assigning a randomly generated UUID to the user. Two, setting up the real-time listener for Supabase. We are listening to Realtime Broadcast events, which are low-latency real-time communication mechanisms that Supabase offers. Within the callback of the broadcast event, we obtain the cursor and object information sent from other clients and set the state accordingly. And three, we load the initial state of the canvas from the database and set it as the initial state of the widget.
Now that the app has been initialized, we are ready to implement the logic of the user drawing and interacting with the canvas.
_48class _CanvasPageState extends State<CanvasPage> {_48 ..._48_48 @override_48 void initState() {_48 super.initState();_48 _initialize();_48 }_48_48 Future<void> _initialize() async {_48 // Generate a random UUID for the user._48 // We could replace this with Supabase auth user ID if we want to make it_48 // more like Figma._48 _myId = const Uuid().v4();_48_48 // Start listening to broadcast messages to display other users' cursors and objects._48 _canvasChanel = supabase_48 .channel(Constants.channelName)_48 .onBroadcast(_48 event: Constants.broadcastEventName,_48 callback: (payload) {_48 final cursor = UserCursor.fromJson(payload['cursor']);_48 _userCursors[cursor.id] = cursor;_48_48 if (payload['object'] != null) {_48 final object = CanvasObject.fromJson(payload['object']);_48 _canvasObjects[object.id] = object;_48 }_48 setState(() {});_48 })_48 .subscribe();_48_48 final initialData = await supabase_48 .from('canvas_objects')_48 .select()_48 .order('created_at', ascending: true);_48 for (final canvasObjectData in initialData) {_48 final canvasObject = CanvasObject.fromJson(canvasObjectData['object']);_48 _canvasObjects[canvasObject.id] = canvasObject;_48 }_48 setState(() {});_48 }_48_48 @override_48 Widget build(BuildContext context) {_48 return Scaffold();_48 }_48}
We have three methods triggered by user actions, _onPanDown()
, _onPanUpdate()
, and _onPanEnd()
, and a method to sync the user action with other clients _syncCanvasObject()
.
What the three pan methods do could be two things, either to draw the object or to move the object.
When drawing an object, on pan down it will add the object to the canvas with size 0, essentially a point. As the user drags the mouse, the pan update method is called which gives the object some size while syncing the object to other clients along the way.
When the user is in pointer
mode, the pan-down method first determines if there is an object under where the user’s pointer currently is located. If there is an object, it holds the object’s id as the widget’s state. As the user drags the screen, the position of the object is moved the same amount the user’s cursor moves, while syncing the object’s information through broadcast along the way.
In both cases, when the user is done dragging, the pan end is called which does some clean-ups of the local state and stores the object information in the database to store the canvas data permanently.
_122class _CanvasPageState extends State<CanvasPage> {_122 ..._122_122 /// Syncs the user's cursor position and the currently drawing object with_122 /// other users._122 Future<void> _syncCanvasObject(Offset cursorPosition) {_122 final myCursor = UserCursor(_122 position: cursorPosition,_122 id: _myId,_122 );_122 return _canvasChanel.sendBroadcastMessage(_122 event: Constants.broadcastEventName,_122 payload: {_122 'cursor': myCursor.toJson(),_122 if (_currentlyDrawingObjectId != null)_122 'object': _canvasObjects[_currentlyDrawingObjectId]?.toJson(),_122 },_122 );_122 }_122_122 /// Called when pan starts._122 ///_122 /// For [_DrawMode.pointer], it will find the first object under the cursor._122 ///_122 /// For other draw modes, it will start drawing the respective canvas objects._122 void _onPanDown(DragDownDetails details) {_122 switch (_currentMode) {_122 case _DrawMode.pointer:_122 // Loop through the canvas objects to find if there are any_122 // that intersects with the current mouse position._122 for (final canvasObject in _canvasObjects.values.toList().reversed) {_122 if (canvasObject.intersectsWith(details.globalPosition)) {_122 _currentlyDrawingObjectId = canvasObject.id;_122 break;_122 }_122 }_122 break;_122 case _DrawMode.circle:_122 final newObject = Circle.createNew(details.globalPosition);_122 _canvasObjects[newObject.id] = newObject;_122 _currentlyDrawingObjectId = newObject.id;_122 break;_122 case _DrawMode.rectangle:_122 final newObject = Rectangle.createNew(details.globalPosition);_122 _canvasObjects[newObject.id] = newObject;_122 _currentlyDrawingObjectId = newObject.id;_122 break;_122 }_122 _cursorPosition = details.globalPosition;_122 _panStartPoint = details.globalPosition;_122 setState(() {});_122 }_122_122 /// Called when the user clicks and drags the canvas._122 ///_122 /// Performs different actions depending on the current mode._122 void _onPanUpdate(DragUpdateDetails details) {_122 switch (_currentMode) {_122 // Moves the object to [details.delta] amount._122 case _DrawMode.pointer:_122 if (_currentlyDrawingObjectId != null) {_122 _canvasObjects[_currentlyDrawingObjectId!] =_122 _canvasObjects[_currentlyDrawingObjectId!]!.move(details.delta);_122 }_122 break;_122_122 // Updates the size of the Circle_122 case _DrawMode.circle:_122 final currentlyDrawingCircle =_122 _canvasObjects[_currentlyDrawingObjectId!]! as Circle;_122 _canvasObjects[_currentlyDrawingObjectId!] =_122 currentlyDrawingCircle.copyWith(_122 center: (details.globalPosition + _panStartPoint!) / 2,_122 radius: min((details.globalPosition.dx - _panStartPoint!.dx).abs(),_122 (details.globalPosition.dy - _panStartPoint!.dy).abs()) /_122 2,_122 );_122 break;_122_122 // Updates the size of the rectangle_122 case _DrawMode.rectangle:_122 _canvasObjects[_currentlyDrawingObjectId!] =_122 (_canvasObjects[_currentlyDrawingObjectId!] as Rectangle).copyWith(_122 bottomRight: details.globalPosition,_122 );_122 break;_122 }_122_122 if (_currentlyDrawingObjectId != null) {_122 setState(() {});_122 }_122 _cursorPosition = details.globalPosition;_122 _syncCanvasObject(_cursorPosition);_122 }_122_122 void onPanEnd(DragEndDetails _) async {_122 if (_currentlyDrawingObjectId != null) {_122 _syncCanvasObject(_cursorPosition);_122 }_122_122 final drawnObjectId = _currentlyDrawingObjectId;_122_122 setState(() {_122 _panStartPoint = null;_122 _currentlyDrawingObjectId = null;_122 });_122_122 // Save whatever was drawn to Supabase DB_122 if (drawnObjectId == null) {_122 return;_122 }_122 await supabase.from('canvas_objects').upsert({_122 'id': drawnObjectId,_122 'object': _canvasObjects[drawnObjectId]!.toJson(),_122 });_122 }_122_122 @override_122 Widget build(BuildContext context) {_122 return Scaffold();_122 }_122}
With all the properties and methods defined, we can proceed to add content to the build method. The entire region is covered in MouseRegion
, which is used to get the cursor position and share it with other clients. Within the mouse region, we have the GestureDetector
and the three buttons representing each action. Because the heavy lifting was done in the methods we have already defined, the build method is fairly simple.
_51class _CanvasPageState extends State<CanvasPage> {_51 ..._51_51 @override_51 Widget build(BuildContext context) {_51 return Scaffold(_51 body: MouseRegion(_51 onHover: (event) {_51 _syncCanvasObject(event.position);_51 },_51 child: Stack(_51 children: [_51 // The main canvas_51 GestureDetector(_51 onPanDown: _onPanDown,_51 onPanUpdate: _onPanUpdate,_51 onPanEnd: onPanEnd,_51 child: CustomPaint(_51 size: MediaQuery.of(context).size,_51 painter: CanvasPainter(_51 userCursors: _userCursors,_51 canvasObjects: _canvasObjects,_51 ),_51 ),_51 ),_51_51 // Buttons to change the current mode._51 Positioned(_51 top: 0,_51 left: 0,_51 child: Row(_51 children: _DrawMode.values_51 .map((mode) => IconButton(_51 iconSize: 48,_51 onPressed: () {_51 setState(() {_51 _currentMode = mode;_51 });_51 },_51 icon: Icon(mode.iconData),_51 color: _currentMode == mode ? Colors.green : null,_51 ))_51 .toList(),_51 ),_51 ),_51 ],_51 ),_51 ),_51 );_51 }_51}
Step 6: Run the application
At this point, we have implemented everything we need to create a collaborative design canvas. Run the app with flutter run
and run it in your browser. There is currently a bug in Flutter where MouseRegion
cannot detect the position of a cursor in two different Chrome windows at the same time, so open it in two different browsers like Chrome and Safari, and enjoy interacting with your design elements in real time.
Conclusion
In this article, we learned how we can combine the Supabase Realtime Broadcast feature with Flutter’s CustomPainter
to create a collaborative design app. We learned how to implement real-time communication between multiple clients using the Broadcast feature, and how we can broadcast the shape and cursor data to other connected clients in real-time.
This article only used circles and rectangles to keep things simple, but you can easily add support for other types of objects like texts or arrows just by extending the CanvasObject
class to make the app more like Figma. Another fun way to expand this app would be to add authentication using Supabase Auth so that we can add proper authorizations. Adding an image upload feature using Supabase Storage would certainly open up more creative options for the app.
Resources
- Add live cursor sharing using Flutter and Supabase | Flutter Figma Clone #1
- Draw and sync canvas in real-time | Flutter Figma Clone #2
- Track online users with Supabase Realtime Presence | Flutter Figma Clone #3
- How to build a real-time multiplayer game with Flutter Flame
- Getting started with Flutter authentication