Supabase 提供了一个低延迟的实时通信功能,称为 Broadcast。通过它,你可以让客户端与其他客户端进行低延迟的通信。这对于创建具有互动体验的应用程序非常有用。Flutter 有一个 CustomPainter 类,它允许开发者与底层的画布 API 进行交互,使我们能够在应用程序上渲染几乎任何东西。结合这两个工具,我们可以创建互动式应用程序。
在这篇文章中,我将结合 Supabase 实时 Broadcast 与 Flutter 的 CustomPainter 来创建一个类似 Figma 的协作设计应用程序。
完整的代码地址在文末。
成品预览
我们将构建一个交互式设计画布应用程序,多个用户可以实时协作。我们将为应用程序添加以下功能:
- 绘制形状,如圆形或矩形
- 移动这些形状
- 实时同步光标位置和设计对象到其他客户端
- 将画布状态持久化到 Postgres 数据库中
好吧,说它是 Figma 的克隆可能有些夸张,这篇文章的目的是展示如何构建一个具有所有基本元素的协作设计画布的协作应用程序。你可以采用这个应用程序的概念,添加功能,完善它,并使其像 Figma 一样复杂。
配置应用
第一步:创建一个空白Flutter应用
css
flutter create canvas --empty --platforms=web
第二步:安装依赖
我们将为这个应用程序使用两个依赖项。
supabase_flutter:用于与 Supabase 实例进行交互,实现实时通信和存储画布数据。 uuid:用于为每个用户和画布对象生成唯一标识符。为了保持这个示例的简单性,我们将不会添加认证功能,只会为每个用户随机分配生成的 UUID。
运行以下命令将这些依赖项添加到你的应用程序中:
csharp
flutter pub add supabase_flutter uuid
第三步:创建Supabase后端服务
Supabase是开源软件,如果你动手能力比较强,可以自己攒一个服务器来用。这里我们直接使用免费的在线服务,MemFire Cloud提供Supabase服务,并且支持了国内的手机短信、微信生态,可以免费使用。
应用创建好之后,你可以打开应用的控制台,在 SQL 编辑器界面运行以下 SQL 来为这个应用程序创建表和行级安全(RLS)策略。为了保持本文的简洁性,我们将不会实现认证功能,所以你看到的策略相对简单。
sql
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);
构建我们的Figma应用
应用的代码结构如下:
ini
lib/
├── canvas/ # A folder containing the main canvas-related files.
│ ├── canvas_object.dart # Data model definitions.
│ ├── canvas_page.dart # The canvas page widget.
│ └── canvas_painter.dart # Custom painter definitions.
├── utils/
│ └── constants.dart # A file to hold Supabase Realtime specific constants
└── main.dart # Entry point of the app
步骤1:初始化Supabase
打开 lib/main.dart
文件,并添加以下代码。你应该用你自己的 Supabase 仪表板中的凭据替换掉其中的占位符,这些凭据可以在设置 > API 部分找到。你会注意到 canvas_page.dart
文件的导入出现了错误,但我们马上就会创建这个文件。
scala
import 'package:canvas/canvas/canvas_page.dart';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
void main() async {
Supabase.initialize(
// TODO: Replace the credentials with your own
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(),
);
}
}
步骤2:创建一些常量
创建 lib/utils/constants.dart
文件并添加以下内容。这些值稍后将在我们设置 Supabase 实时监听器时使用。
typescript
abstract class Constants {
/// Name of the Realtime channel
static const String channelName = 'canvas';
/// Name of the broadcast event
static const String broadcastEventName = 'canvas';
}
步骤3:创建data model
我们需要为以下每个项目创建数据模型:
用户的光标位置。 我们可以在画布上绘制的对象。包括:
- 圆形(Circle)
- 矩形(Rectangle)
创建 lib/canvas/canvas_object.dart
文件。这个文件有点长,所以我将在下面分解每个组件。随着我们逐步构建,将所有代码添加到 canvas_object.dart
文件中。
在文件的顶部,我们有一个扩展方法来生成随机颜色。其中一个方法生成随机颜色,这将用于设置新创建对象的颜色,另一个方法使用 UUID 的种子生成随机颜色,这将用于确定用户的光标颜色。
javascript
import 'dart:convert';
import 'dart:math';
import 'dart:ui';
import 'package:uuid/uuid.dart';
/// Handy extension method to create random colors
extension RandomColor on Color {
static Color getRandom() {
return Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0);
}
/// Quick and dirty method to create a random color from the userID
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);
}
}
接下来我们有 SyncedObject
类。SyncedObject
类是任何将实时同步的事物的基础类,这包括光标和对象。它有一个 id
属性,这将是一个 UUID,并且它有一个 toJson
方法,这是通过 Supabase 的广播功能传递对象信息所必需的。
kotlin
/// Objects that are being synced in realtime over broadcast
///
/// Includes mouse cursor and design objects
abstract class SyncedObject {
/// UUID unique identifier of the object
final String id;
factory SyncedObject.fromJson(Map<String, dynamic> json) {
final objectType = json['object_type'];
if (objectType == UserCursor.type) {
return UserCursor.fromJson(json);
} else {
return CanvasObject.fromJson(json);
}
}
SyncedObject({
required this.id,
});
Map<String, dynamic> toJson();
}
现在为了与其他客户端同步用户的光标,我们有 UserCursor
类。它继承自 SyncedObject
类,并且实现了 JSON 解析。
dart
/// Data model for the cursors displayed on the canvas.
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<String, dynamic> json)
: position = Offset(json['position']['x'], json['position']['y']),
color = RandomColor.getRandomFromId(json['id']),
super(id: json['id']);
@override
Map<String, dynamic> toJson() {
return {
'object_type': type,
'id': id,
'position': {
'x': position.dx,
'y': position.dy,
}
};
}
}
我们希望实时同步的另一组数据是画布内的各个形状。我们创建了 CanvasObject
抽象类,这是画布内任何形状的基础类。这个类扩展了 SyncedObject
,因为我们希望将其与其他客户端同步。除了 id
属性之外,我们还有一个 color
属性,因为每个形状都需要一个颜色。此外,我们还有一些方法。
intersectsWith()
方法接受画布内的一点,并返回该点是否与形状相交。当在画布上抓取形状时会使用这个方法。 copyWith()
是一个标准方法,用于创建实例的一个副本。 move()
方法用于创建一个通过增量移动的实例版本。当形状在画布上被拖动时会使用这个方法。
scala
/// Base model for any design objects displayed on the canvas.
abstract class CanvasObject extends SyncedObject {
final Color color;
CanvasObject({
required super.id,
required this.color,
});
factory CanvasObject.fromJson(Map<String, dynamic> 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']}');
}
}
/// Whether or not the object intersects with the given point.
bool intersectsWith(Offset point);
CanvasObject copyWith();
/// Moves the object to a new position
CanvasObject move(Offset delta);
}
现在我们已经为画布对象定义了基础类,让我们定义一下在这个应用程序中我们将支持的实际形状。每个对象都将继承 CanvasObject
并拥有额外的属性,例如对于圆形有中心点(center)和半径(radius)。
在这篇文章中,我们只支持圆形和矩形,但你可以根据需要轻松扩展并添加对其他形状的支持。
kotlin
/// Circle displayed on the canvas.
class Circle extends CanvasObject {
static String type = 'circle';
final Offset center;
final double radius;
Circle({
required super.id,
required super.color,
required this.radius,
required this.center,
});
Circle.fromJson(Map<String, dynamic> json)
: radius = json['radius'],
center = Offset(json['center']['x'], json['center']['y']),
super(id: json['id'], color: Color(json['color']));
/// Constructor to be used when first starting to draw the object on the canvas
Circle.createNew(this.center)
: radius = 0,
super(id: const Uuid().v4(), color: RandomColor.getRandom());
@override
Map<String, dynamic> toJson() {
return {
'object_type': type,
'id': id,
'color': color.value,
'center': {
'x': center.dx,
'y': center.dy,
},
'radius': radius,
};
}
@override
Circle copyWith({
double? radius,
Offset? center,
Color? color,
}) {
return Circle(
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
Circle move(Offset delta) {
return copyWith(center: center + delta);
}
}
/// Rectangle displayed on the canvas.
class Rectangle extends CanvasObject {
static String type = 'rectangle';
final Offset topLeft;
final Offset bottomRight;
Rectangle({
required super.id,
required super.color,
required this.topLeft,
required this.bottomRight,
});
Rectangle.fromJson(Map<String, dynamic> 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']));
/// Constructor to be used when first starting to draw the object on the canvas
Rectangle.createNew(Offset startingPoint)
: topLeft = startingPoint,
bottomRight = startingPoint,
super(color: RandomColor.getRandom(), id: const Uuid().v4());
@override
Map<String, dynamic> 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
Rectangle copyWith({
Offset? topLeft,
Offset? bottomRight,
Color? color,
}) {
return Rectangle(
topLeft: topLeft ?? this.topLeft,
id: id,
bottomRight: bottomRight ?? this.bottomRight,
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
Rectangle move(Offset delta) {
return copyWith(
topLeft: topLeft + delta,
bottomRight: bottomRight + delta,
);
}
}
上面代码保存在canvas_object.dart
。
步骤4:创建CustomPainter
CustomPainter
是一个与 Flutter 应用程序中的画布进行交互的低级 API。我们将创建我们自己的 CustomPainter
,它将接收光标位置和应用程序中的对象,并将它们绘制在画布上。
创建 lib/canvas/canvas_painter.dart
文件并添加以下内容。
arduino
import 'package:canvas/canvas/canvas_object.dart';
import 'package:flutter/material.dart';
class CanvasPainter extends CustomPainter {
final Map<String, UserCursor> userCursors;
final Map<String, CanvasObject> canvasObjects;
CanvasPainter({
required this.userCursors,
required this.canvasObjects,
});
@override
void paint(Canvas canvas, Size size) {
// Draw each canvas objects
for (final canvasObject in canvasObjects.values) {
if (canvasObject is Circle) {
final position = canvasObject.center;
final radius = canvasObject.radius;
canvas.drawCircle(
position, radius, Paint()..color = canvasObject.color);
} else if (canvasObject is Rectangle) {
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 the 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(oldPainter) => true;
}
userCursors
和 canvasObjects
分别代表画布内的光标和对象。Map
的键是 UUID 唯一标识符。
paint()
方法是画布上绘制发生的地方。它首先遍历对象并将它们绘制在画布上。每种形状都有自己的绘制方法,所以我们将在每次循环中检查对象的类型,并应用相应的绘制方法。
一旦我们绘制了所有对象,我们再绘制光标。我们在对象之后绘制光标的原因是,在自定义画家中,后面绘制的内容会覆盖之前绘制的对象。因为我们不希望光标被对象遮挡,所以我们在所有对象绘制完成后再绘制所有光标。
shouldRepaint()
定义了我们是否希望在 CustomPainter
接收到新的属性集时重新绘制画布。在我们的情况下,我们希望在每次接收到新的属性集时重新绘制画家,所以我们总是返回 true
。
步骤5:创建画布页面
现在我们已经准备好了数据模型和自定义画家,是时候将一切整合起来了。我们将创建一个画布页面,这是这个应用的唯一页面,它允许用户绘制形状并移动这些形状,同时保持与其他用户的状态同步。
创建 lib/canvas/canvas_page.dart
文件。将本步骤中显示的所有代码添加到 canvas_page.dart
中。首先添加这个应用程序所需的所有导入语句。
arduino
import 'dart:math';
import 'package:canvas/canvas/canvas_object.dart';
import 'package:canvas/canvas/canvas_painter.dart';
import 'package:canvas/main.dart';
import 'package:canvas/utils/constants.dart';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
然后我们可以创建一个枚举(enum)来表示在这个应用中可以执行的三种不同操作:指针(pointer)用于移动对象,圆形(circle)用于绘制圆形,矩形(rectangle)用于绘制矩形。
scss
/// Different input modes users can perform
enum _DrawMode {
/// Mode to move around existing objects
pointer(iconData: Icons.pan_tool_alt),
/// Mode to draw circles
circle(iconData: Icons.circle_outlined),
/// Mode to draw rectangles
rectangle(iconData: Icons.rectangle_outlined);
const _DrawMode({required this.iconData});
/// Icon used in the IconButton to toggle the mode
final IconData iconData;
}
最后,我们可以进入到应用的核心部分,创建 CanvasPage
小部件。创建一个带有空白 Scaffold
的空 StatefulWidget
。我们将向其中添加属性、方法和子小部件。
scala
/// Interactive art board page to draw and collaborate with other users.
class CanvasPage extends StatefulWidget {
const CanvasPage({super.key});
@override
State<CanvasPage> createState() => _CanvasPageState();
}
class _CanvasPageState extends State<CanvasPage> {
// TODO: Add properties
// TODO: Add methods
@override
Widget build(BuildContext context) {
return Scaffold();
}
}
首先,我们可以定义这个小部件所需的所有属性。_userCursors
和 _canvasObjects
将保存应用从实时监听器接收到的光标和画布对象。_canvasChannel
是客户端使用 Supabase 实时功能与其他客户端通信的通道。我们将在后面实现发送和接收有关画布信息的逻辑。然后还有一些状态,将在我们实现画布上的绘制时使用。
scala
class _CanvasPageState extends State<CanvasPage> {
/// Holds the cursor information of other users
final Map<String, UserCursor> _userCursors = {};
/// Holds the list of objects drawn on the canvas
final Map<String, CanvasObject> _canvasObjects = {};
/// Supabase Realtime channel to communicate to other clients
late final RealtimeChannel _canvasChanel;
/// Randomly generated UUID for the user
late final String _myId;
/// Whether the user is using the pointer to move things around, or in drawing mode.
_DrawMode _currentMode = _DrawMode.pointer;
/// A single Canvas object that is being drawn by the user if any.
String? _currentlyDrawingObjectId;
/// The point where the pan started
Offset? _panStartPoint;
/// Cursor position of the user.
Offset _cursorPosition = const Offset(0, 0);
// TODO: Add methods
@override
Widget build(BuildContext context) {
return Scaffold();
}
}
现在我们已经定义了属性,我们可以运行一些初始化代码来设置场景。在这个初始化步骤中,我们做了几件事情。
首先,为用户分配一个随机生成的 UUID。其次,设置 Supabase 的实时监听器。我们正在监听实时广播事件,这是 Supabase 提供的低延迟实时通信机制。在广播事件的回调中,我们获取其他客户端发送的光标和对象信息,并相应地设置状态。第三,我们从数据库加载画布的初始状态,并将其设置为小部件的初始状态。
现在应用程序已经初始化,我们准备实现用户绘图和与画布交互的逻辑。
scss
class _CanvasPageState extends State<CanvasPage> {
...
@override
void initState() {
super.initState();
_initialize();
}
Future<void> _initialize() async {
// Generate a random UUID for the user.
// We could replace this with Supabase auth user ID if we want to make it
// more like Figma.
_myId = const Uuid().v4();
// Start listening to broadcast messages to display other users' cursors and objects.
_canvasChanel = 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(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold();
}
}
我们有三个由用户操作触发的方法:_onPanDown()
、_onPanUpdate()
和 _onPanEnd()
,以及一个将用户操作与其他客户端同步的方法 _syncCanvasObject()
。
这三个平移方法所做的可能是两件事,要么绘制对象,要么移动对象。
在绘制对象时,平移按下(pan down)会将对象以大小为 0(实际上是一个点)添加到画布上。当用户拖动鼠标时,平移更新(pan update)方法被调用,这会赋予对象一定的大小,同时在途中将对象同步给其他客户端。
当用户处于指针模式时,平移按下方法首先确定用户指针当前位置下是否有对象。如果有对象,它会将对象的 ID 保存为小部件的状态。当用户拖动屏幕时,对象的位置会随着用户光标的移动而移动相同的量,同时通过广播沿途同步对象信息。
在这两种情况下,当用户完成拖动时,平移结束(pan end)被调用,它会清理本地状态,并将对象信息存储到数据库中,以永久保存画布数据。
php
class _CanvasPageState extends State<CanvasPage> {
...
/// Syncs the user's cursor position and the currently drawing object with
/// other users.
Future<void> _syncCanvasObject(Offset cursorPosition) {
final myCursor = UserCursor(
position: cursorPosition,
id: _myId,
);
return _canvasChanel.sendBroadcastMessage(
event: Constants.broadcastEventName,
payload: {
'cursor': myCursor.toJson(),
if (_currentlyDrawingObjectId != null)
'object': _canvasObjects[_currentlyDrawingObjectId]?.toJson(),
},
);
}
/// Called when pan starts.
///
/// For [_DrawMode.pointer], it will find the first object under the cursor.
///
/// For other draw modes, it will start drawing the respective canvas objects.
void _onPanDown(DragDownDetails details) {
switch (_currentMode) {
case _DrawMode.pointer:
// Loop through the canvas objects to find if there are any
// that intersects with the current mouse position.
for (final canvasObject in _canvasObjects.values.toList().reversed) {
if (canvasObject.intersectsWith(details.globalPosition)) {
_currentlyDrawingObjectId = canvasObject.id;
break;
}
}
break;
case _DrawMode.circle:
final newObject = Circle.createNew(details.globalPosition);
_canvasObjects[newObject.id] = newObject;
_currentlyDrawingObjectId = newObject.id;
break;
case _DrawMode.rectangle:
final newObject = Rectangle.createNew(details.globalPosition);
_canvasObjects[newObject.id] = newObject;
_currentlyDrawingObjectId = newObject.id;
break;
}
_cursorPosition = details.globalPosition;
_panStartPoint = details.globalPosition;
setState(() {});
}
/// Called when the user clicks and drags the canvas.
///
/// Performs different actions depending on the current mode.
void _onPanUpdate(DragUpdateDetails details) {
switch (_currentMode) {
// Moves the object to [details.delta] amount.
case _DrawMode.pointer:
if (_currentlyDrawingObjectId != null) {
_canvasObjects[_currentlyDrawingObjectId!] =
_canvasObjects[_currentlyDrawingObjectId!]!.move(details.delta);
}
break;
// Updates the size of the Circle
case _DrawMode.circle:
final currentlyDrawingCircle =
_canvasObjects[_currentlyDrawingObjectId!]! as Circle;
_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;
// Updates the size of the rectangle
case _DrawMode.rectangle:
_canvasObjects[_currentlyDrawingObjectId!] =
(_canvasObjects[_currentlyDrawingObjectId!] as Rectangle).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;
});
// Save whatever was drawn to Supabase DB
if (drawnObjectId == null) {
return;
}
await supabase.from('canvas_objects').upsert({
'id': drawnObjectId,
'object': _canvasObjects[drawnObjectId]!.toJson(),
});
}
@override
Widget build(BuildContext context) {
return Scaffold();
}
}
所有属性和方法定义完成后,我们可以继续向 build
方法中添加内容。整个区域被 MouseRegion
覆盖,它用于获取光标位置并与其他客户端共享。在 MouseRegion
内部,我们有 GestureDetector
和代表每个操作的三个按钮。因为我们已经定义的方法完成了大部分工作,所以 build
方法相对简单。
less
class _CanvasPageState extends State<CanvasPage> {
...
@override
Widget build(BuildContext context) {
return Scaffold(
body: MouseRegion(
onHover: (event) {
_syncCanvasObject(event.position);
},
child: Stack(
children: [
// The main canvas
GestureDetector(
onPanDown: _onPanDown,
onPanUpdate: _onPanUpdate,
onPanEnd: onPanEnd,
child: CustomPaint(
size: MediaQuery.of(context).size,
painter: CanvasPainter(
userCursors: _userCursors,
canvasObjects: _canvasObjects,
),
),
),
// Buttons to change the current mode.
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(),
),
),
],
),
),
);
}
}
步骤6:运行程序
到此为止,我们已经实现了创建一个协作设计画布所需的一切。使用 flutter run
运行应用程序,并在浏览器中打开它。目前 Flutter 存在一个 bug,MouseRegion
无法同时在两个不同的 Chrome 窗口中检测到光标的位置,所以请在两个不同的浏览器(如 Chrome 和 Safari)中打开它,并享受实时与你的设计元素互动的乐趣。
Supabase应用:MemFire Cloud提供Supabase服务,并且支持了国内的手机短信、微信生态,可以免费使用。