Building a Real-Time Collaborative Design App with Flutter and Supabase
Building a real-time collaborative application offers exciting potential to create engaging and interactive experiences for users. In this article, we will walk you through the process of creating a real-time collaborative design app using Flutter and Supabase. This app will allow multiple users to draw and interact with design elements on a shared canvas, similar to tools like Figma.
Overview of the Figma Clone App
We will build an interactive design canvas app where users can collaborate in real-time. Key features include:
- Drawing shapes (circles and rectangles)
- Moving shapes around
- Syncing cursor positions and design objects in real-time
- Persisting the canvas state in a Postgres database
Although we might not build a complete Figma clone, the principles and functionality will form a strong foundation for a collaborative design canvas.
Setting Up the App
Create a Blank Flutter Application
Begin by creating a blank Flutter app. We will focus on web support for this example since it involves cursor interactions.
flutter create canvas --empty --platforms=web
Install the Dependencies
We need two dependencies:
supabase_flutter
: for real-time communication and storing canvas data.uuid
: for generating unique identifiers.
Add the dependencies:
flutter pub add supabase_flutter uuid
Setup the Supabase Project
Create a new Supabase project by visiting database.new. Once the project is ready, run the following SQL from the SQL editor to set up the table and RLS policies:
create table canvas_objects (
id uuid primary key default gen_random_uuid() not null,
"object" jsonb not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table canvas_objects enable row level security;
create policy select_canvas_objects on canvas_objects as permissive for select to anon using (true);
create policy insert_canvas_objects on canvas_objects as permissive for insert to anon with check (true);
create policy update_canvas_objects on canvas_objects as permissive for update to anon using (true);
Building the Figma Clone App
Step 1: Initialize Supabase
Initialize Supabase in lib/main.dart
. Replace YOUR_SUPABASE_URL
and YOUR_SUPABASE_ANON_KEY
with your Supabase project credentials:
import 'package:canvas/canvas/canvas_page.dart';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
void main() async {
Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
anonKey: 'YOUR_SUPABASE_ANON_KEY',
);
runApp(const MyApp());
}
final supabase = Supabase.instance.client;
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Figma Clone',
debugShowCheckedModeBanner: false,
home: CanvasPage(),
);
}
}
Step 2: Create the Constants File
Create lib/utils/constants.dart
to organize the app’s constants:
abstract class Constants {
/// Name of the Realtime channel
static const String channelName = 'canvas';
/// Name of the broadcast event
static const String broadcastEventName = 'canvas';
}
Step 3: Create the Data Model
Create lib/canvas/canvas_object.dart
and add the following to define the data models for cursors and shapes:
import 'dart:convert';
import 'dart:math';
import 'dart:ui';
import 'package:uuid/uuid.dart';
/// Extension method to create random colors
extension RandomColor on Color {
static Color getRandom() {
return Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0);
}
static Color getRandomFromId(String id) {
final seed = utf8.encode(id).reduce((value, element) => value + element);
return Color((Random(seed).nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0);
}
}
/// Base class for synchronized objects
abstract class SyncedObject {
final String id;
SyncedObject({required this.id});
factory SyncedObject.fromJson(Map json) {
final objectType = json['object_type'];
if (objectType == UserCursor.type) {
return UserCursor.fromJson(json);
} else {
return CanvasObject.fromJson(json);
}
}
Map toJson();
}
/// Model for user cursors
class UserCursor extends SyncedObject {
static String type = 'cursor';
final Offset position;
final Color color;
UserCursor({required super.id, required this.position}) : color = RandomColor.getRandomFromId(id);
UserCursor.fromJson(Map json)
: position = Offset(json['position']['x'], json['position']['y']),
color = RandomColor.getRandomFromId(json['id']),
super(id: json['id']);
@override
Map toJson() {
return {
'object_type': type,
'id': id,
'position': {'x': position.dx, 'y': position.dy},
};
}
}
/// Base model for design objects
abstract class CanvasObject extends SyncedObject {
final Color color;
CanvasObject({required super.id, required this.color});
factory CanvasObject.fromJson(Map json) {
if (json['object_type'] == CanvasCircle.type) {
return CanvasCircle.fromJson(json);
} else if (json['object_type'] == CanvasRectangle.type) {
return CanvasRectangle.fromJson(json);
} else {
throw UnimplementedError('Unknown object_type: ${json['object_type']}');
}
}
bool intersectsWith(Offset point);
CanvasObject copyWith();
CanvasObject move(Offset delta);
}
/// Model for circle shapes
class CanvasCircle extends CanvasObject {
static String type = 'circle';
final Offset center;
final double radius;
CanvasCircle({required super.id, required super.color, required this.radius, required this.center});
CanvasCircle.fromJson(Map json)
: radius = json['radius'],
center = Offset(json['center']['x'], json['center']['y']),
super(id: json['id'], color: Color(json['color']));
CanvasCircle.createNew(this.center) : radius = 0, super(id: const Uuid().v4(), color: RandomColor.getRandom());
@override
Map toJson() {
return {
'object_type': type,
'id': id,
'color': color.value,
'center': {'x': center.dx, 'y': center.dy},
'radius': radius,
};
}
@override
CanvasCircle copyWith({double? radius, Offset? center, Color? color}) {
return CanvasCircle(
radius: radius ?? this.radius,
center: center ?? this.center,
id: id,
color: color ?? this.color,
);
}
@override
bool intersectsWith(Offset point) {
final centerToPointerDistance = (point - center).distance;
return radius > centerToPointerDistance;
}
@override
CanvasCircle move(Offset delta) {
return copyWith(center: center + delta);
}
}
/// Model for rectangle shapes
class CanvasRectangle extends CanvasObject {
static String type = 'rectangle';
final Offset topLeft;
final Offset bottomRight;
CanvasRectangle({required super.id, required super.color, required this.topLeft, required this.bottomRight});
CanvasRectangle.fromJson(Map json)
: bottomRight = Offset(json['bottom_right']['x'], json['bottom_right']['y']),
topLeft = Offset(json['top_left']['x'], json['top_left']['y']),
super(id: json['id'], color: Color(json['color']));
CanvasRectangle.createNew(Offset startingPoint)
: topLeft = startingPoint,
bottomRight = startingPoint,
super(color: RandomColor.getRandom(), id: const Uuid().v4());
@override
Map toJson() {
return {
'object_type': type,
'id': id,
'color': color.value,
'top_left': {'x': topLeft.dx, 'y': topLeft.dy},
'bottom_right': {'x': bottomRight.dx, 'y': bottomRight.dy},
};
}
@override
CanvasRectangle copyWith({Offset? topLeft, Offset? bottomRight, Color? color}) {
return CanvasRectangle(
topLeft: topLeft ?? this.topLeft,
bottomRight: bottomRight ?? this.bottomRight,
id: id,
color: color ?? this.color,
);
}
@override
bool intersectsWith(Offset point) {
final minX = min(topLeft.dx, bottomRight.dx);
final maxX = max(topLeft.dx, bottomRight.dx);
final minY = min(topLeft.dy, bottomRight.dy);
final maxY = max(topLeft.dy, bottomRight.dy);
return minX < point.dx && point.dx < maxX && minY < point.dy && point.dy < maxY;
}
@override
CanvasRectangle move(Offset delta) {
return copyWith(
topLeft: topLeft + delta,
bottomRight: bottomRight + delta,
);
}
}
Step 4: Create the Custom Painter
Create lib/canvas/canvas_painter.dart
to handle custom painting:
import 'package:canvas/canvas/canvas_object.dart';
import 'package:flutter/material.dart';
class CanvasPainter extends CustomPainter {
final Map userCursors;
final Map canvasObjects;
CanvasPainter({required this.userCursors, required this.canvasObjects});
@override
void paint(Canvas canvas, Size size) {
// Draw canvas objects
for (final canvasObject in canvasObjects.values) {
if (canvasObject is CanvasCircle) {
final position = canvasObject.center;
final radius = canvasObject.radius;
canvas.drawCircle(position, radius, Paint()..color = canvasObject.color);
} else if (canvasObject is CanvasRectangle) {
final position = canvasObject.topLeft;
final bottomRight = canvasObject.bottomRight;
canvas.drawRect(
Rect.fromLTRB(position.dx, position.dy, bottomRight.dx, bottomRight.dy),
Paint()..color = canvasObject.color,
);
}
}
// Draw cursors
for (final userCursor in userCursors.values) {
final position = userCursor.position;
canvas.drawPath(
Path()
..moveTo(position.dx, position.dy)
..lineTo(position.dx + 14.29, position.dy + 44.84)
..lineTo(position.dx + 20.35, position.dy + 25.93)
..lineTo(position.dx + 39.85, position.dy + 24.51)
..lineTo(position.dx, position.dy),
Paint()..color = userCursor.color,
);
}
}
@override
bool shouldRepaint(CanvasPainter oldPainter) => true;
}
Step 5: Create the Canvas Page
In lib/canvas/canvas_page.dart
, implement the logic for the canvas page:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:canvas/utils/constants.dart';
import 'package:canvas/canvas/canvas_object.dart';
import 'package:canvas/canvas/canvas_painter.dart';
import 'package:canvas/main.dart';
/// Different input modes users can perform
enum _DrawMode {
pointer(iconData: Icons.pan_tool_alt),
circle(iconData: Icons.circle_outlined),
rectangle(iconData: Icons.rectangle_outlined);
const _DrawMode({required this.iconData});
final IconData iconData;
}
/// Interactive art board page to draw and collaborate with other users.
class CanvasPage extends StatefulWidget {
const CanvasPage({super.key});
@override
State createState() => _CanvasPageState();
}
class _CanvasPageState extends State {
final Map _userCursors = {};
final Map _canvasObjects = {};
late final RealtimeChannel _canvasChannel;
late final String _myId;
_DrawMode _currentMode = _DrawMode.pointer;
String? _currentlyDrawingObjectId;
Offset? _panStartPoint;
Offset _cursorPosition = const Offset(0, 0);
@override
void initState() {
super.initState();
_initialize();
}
Future _initialize() async {
_myId = const Uuid().v4();
_canvasChannel = supabase
.channel(Constants.channelName)
.onBroadcast(
event: Constants.broadcastEventName,
callback: (payload) {
final cursor = UserCursor.fromJson(payload['cursor']);
_userCursors[cursor.id] = cursor;
if (payload['object'] != null) {
final object = CanvasObject.fromJson(payload['object']);
_canvasObjects[object.id] = object;
}
setState(() {});
},
)
.subscribe();
final initialData = await supabase
.from('canvas_objects')
.select()
.order('created_at', ascending: true);
for (final canvasObjectData in initialData) {
final canvasObject = CanvasObject.fromJson(canvasObjectData['object']);
_canvasObjects[canvasObject.id] = canvasObject;
}
setState(() {});
}
Future _syncCanvasObject(Offset cursorPosition) {
final myCursor = UserCursor(
position: cursorPosition,
id: _myId,
);
return _canvasChannel.sendBroadcastMessage(
event: Constants.broadcastEventName,
payload: {
'cursor': myCursor.toJson(),
if (_currentlyDrawingObjectId != null)
'object': _canvasObjects[_currentlyDrawingObjectId]?.toJson(),
},
);
}
void _onPanDown(DragDownDetails details) {
switch (_currentMode) {
case _DrawMode.pointer:
for (final canvasObject in _canvasObjects.values.toList().reversed) {
if (canvasObject.intersectsWith(details.globalPosition)) {
_currentlyDrawingObjectId = canvasObject.id;
break;
}
}
break;
case _DrawMode.circle:
final newObject = CanvasCircle.createNew(details.globalPosition);
_canvasObjects[newObject.id] = newObject;
_currentlyDrawingObjectId = newObject.id;
break;
case _DrawMode.rectangle:
final newObject = CanvasRectangle.createNew(details.globalPosition);
_canvasObjects[newObject.id] = newObject;
_currentlyDrawingObjectId = newObject.id;
break;
}
_cursorPosition = details.globalPosition;
_panStartPoint = details.globalPosition;
setState(() {});
}
void _onPanUpdate(DragUpdateDetails details) {
switch (_currentMode) {
case _DrawMode.pointer:
if (_currentlyDrawingObjectId != null) {
_canvasObjects[_currentlyDrawingObjectId!] =
_canvasObjects[_currentlyDrawingObjectId!]!.move(details.delta);
}
break;
case _DrawMode.circle:
final currentlyDrawingCircle = _canvasObjects[_currentlyDrawingObjectId!]! as CanvasCircle;
_canvasObjects[_currentlyDrawingObjectId!] = currentlyDrawingCircle.copyWith(
center: (details.globalPosition + _panStartPoint!) / 2,
radius: min((details.globalPosition.dx - _panStartPoint!.dx).abs(),
(details.globalPosition.dy - _panStartPoint!.dy).abs()) / 2,
);
break;
case _DrawMode.rectangle:
_canvasObjects[_currentlyDrawingObjectId!] =
(_canvasObjects[_currentlyDrawingObjectId!] as CanvasRectangle).copyWith(
bottomRight: details.globalPosition,
);
break;
}
if (_currentlyDrawingObjectId != null) {
setState(() {});
}
_cursorPosition = details.globalPosition;
_syncCanvasObject(_cursorPosition);
}
void onPanEnd(DragEndDetails _) async {
if (_currentlyDrawingObjectId != null) {
_syncCanvasObject(_cursorPosition);
}
final drawnObjectId = _currentlyDrawingObjectId;
setState(() {
_panStartPoint = null;
_currentlyDrawingObjectId = null;
});
if (drawnObjectId != null) {
await supabase.from('canvas_objects').upsert({
'id': drawnObjectId,
'object': _canvasObjects[drawnObjectId]!.toJson(),
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: MouseRegion(
onHover: (event) {
_syncCanvasObject(event.position);
},
child: Stack(
children: [
GestureDetector(
onPanDown: _onPanDown,
onPanUpdate: _onPanUpdate,
onPanEnd: onPanEnd,
child: CustomPaint(
size: MediaQuery.of(context).size,
painter: CanvasPainter(
userCursors: _userCursors,
canvasObjects: _canvasObjects,
),
),
),
Positioned(
top: 0,
left: 0,
child: Row(
children: _DrawMode.values.map((mode) => IconButton(
iconSize: 48,
onPressed: () {
setState(() {
_currentMode = mode;
});
},
icon: Icon(mode.iconData),
color: _currentMode == mode ? Colors.green : null,
)).toList(),
),
),
],
),
),
);
}
}
Step 6: Run the Application
Execute flutter run
and run the app in your browser. Open the app in different browsers to test real-time interactions.
Conclusion
In this article, we demonstrated how to build a collaborative design app using Flutter and Supabase. We explored real-time communication with Supabase’s Realtime Broadcast and used Flutter’s CustomPainter to render design elements on a canvas. While this setup covers the basics, it provides a strong foundation for creating more advanced collaborative applications.
Resources
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.