关于 multi-touch-canvas-with-flutter
这是一份在 Flutter 中实现可平移、缩放和交互操作画布的教程,面向 Flutter 开发者。示例通过画布控制器处理触屏、触控板和键盘输入。
主要功能
- 01多指平移画布
- 02双指捏合缩放
- 03选择并拖动画布对象
- 04键盘平移与缩放快捷键
- 05触控板输入支持
- 06框选与多选
适用场景
- 01在 Flutter 应用中构建交互式画布
- 02为触屏设备创建画布编辑器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rodydavis/ skills --skill multi-touch-canvas-with-flutter安装后在 Claude Code 中输入/multi-touch-canvas-with-flutter确认已加载。
详细介绍
使用 Flutter 实现多点触控画布
如果你曾想在 Flutter 中创建一个画布,使其可以向任意方向平移并支持缩放,那么你可能也尝试过创建一个 MultiGestureRecognizer,或者在 GestureDetector 下添加 onPanUpdate 和 onScaleUpdate,但会收到错误,因为这两者无法同时工作。即使使用两个 GestureDetector,你也会发现它们无法按预期工作,并且其中一个总会优先响应。
这是 https://widget.studio 中使用的画布渲染逻辑。
多点触控目标
- 用两根或更多手指平移画布
- 仅用两根手指缩放画布(捏合/缩放)
- 用单指与画布对象交互并检测选中状态
- 额外支持触控板,实现类似效果
要实现这些功能,我们需要使用 Listener 处理触控板事件和原始触摸交互,并使用 RawKeyboardListener 处理键盘快捷键。
第 1 部分 - 项目设置
打开终端并输入以下内容:
mkdir flutter_multi_touch
cd flutter_multi_touch
flutter create .
code .
最后一行是可选的,仅在你安装了 VSCode 时需要。该命令会在 VSCode 中打开此目录。
第 2 部分 - 基础代码
- 移除所有注释
- 移除多余的空行
- 更新 UI
目前运行项目时,你会看到以下 UI。
新建文件 ui/home/screen.dart,并添加以下内容:
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container();
}
}
用以下内容更新 main.dart:
import 'package:flutter/material.dart';
import 'ui/home/screen.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
darkTheme: ThemeData.dark().copyWith(
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: HomeScreen(),
);
}
}
现在运行应用时,你会看到一个黑色屏幕。
第 3 部分 - 创建控制器
现在,我们要创建一个类,作为画布的控制器。
在 src/controllers/canvas.dart 中新建文件,并添加以下内容作为起点:
import 'dart:async';
/// Control the canvas and the objects on it
class CanvasController {
// Controller for the stream output
final _controller = StreamController<CanvasController>();
// Reference to the stream to update the UI
Stream<CanvasController> get stream => _controller.stream;
// Emit a new event to rebuild the UI
void add([CanvasController val]) => _controller.add(val ?? this);
// Stop the stream and finish
void close() => _controller.close();
// Start the stream
void init() => add();
}
用以下内容更新主页:
import 'package:flutter/material.dart';
import '../../src/controllers/canvas.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _controller = CanvasController();
@override
void initState() {
_controller.init();
super.initState();
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<CanvasController>(
stream: _controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: Center(child: CircularProgressIndicator()),
);
}
final instance = snapshot.data;
return Scaffold(
appBar: AppBar(),
body: Stack(
children: [
Positioned(
top: 20,
left: 20,
width: 100,
height: 100,
child: Container(color: Colors.red),
)
],
),
);
});
}
}
这里我们只是添加基础功能,以便在控制器发生变化或屏幕构建完成时重新构建。这里使用有状态组件,是因为我们要释放控制器,并且只加载一次。我们还使用了 Stack,因为底层只需要它。快速热重启后,你应该会看到以下视图。
第 4 部分 - 添加画布对象
现在,我们需要创建用于表示存储在画布上的对象的类。在 src/classes/canvas_object.dart 中新建文件,并添加以下内容:
import 'dart:ui';
class CanvasObject<T> {
final double dx;
final double dy;
final double width;
final double height;
final T child;
CanvasObject({
this.dx = 0,
this.dy = 0,
this.width = 100,
this.height = 100,
this.child,
});
CanvasObject<T> copyWith({
double dx,
double dy,
double width,
double height,
T child,
}) {
return CanvasObject<T>(
dx: dx ?? this.dx,
dy: dy ?? this.dy,
width: width ?? this.width,
height: height ?? this.height,
child: child ?? this.child,
);
}
Size get size => Size(width, height);
Offset get offset => Offset(dx, dy);
Rect get rect => offset & size;
}
这里使用泛型,这样该类就不必依赖 flutter 或 material。用以下内容更新控制器:
import 'dart:async';
import 'package:flutter/material.dart';
import '../classes/canvas_object.dart';
/// Control the canvas and the objects on it
class CanvasController {
/// Controller for the stream output
final _controller = StreamController<CanvasController>();
/// Reference to the stream to update the UI
Stream<CanvasController> get stream => _controller.stream;
/// Emit a new event to rebuild the UI
void add([CanvasController val]) => _controller.add(val ?? this);
/// Stop the stream and finish
void close() => _controller.close();
/// Start the stream
void init() => add();
// -- Canvas Objects --
final List<CanvasObject<Widget>> _objects = [];
/// Current Objects on the canvas
List<CanvasObject<Widget>> get objects => _objects;
/// Add an object to the canvas
void addObject(CanvasObject<Widget> value) => _update(() {
_objects.add(value);
});
/// Add an object to the canvas
void updateObject(int i, CanvasObject<Widget> value) => _update(() {
_objects[i] = value;
});
/// Remove an object from the canvas
void removeObject(int i) => _update(() {
_objects.removeAt(i);
});
void _update(void Function() action) {
action();
add(this);
}
}
我们只是在画布上添加对象,并在需要时将其移除。用以下内容更新主页,以使用这些新对象:
import 'package:flutter/material.dart';
import '../../src/classes/canvas_object.dart';
import '../../src/controllers/canvas.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _controller = CanvasController();
@override
void initState() {
_controller.init();
_dummyData();
super.initState();
}
void _dummyData() {
_controller.addObject(
CanvasObject(
dx: 20,
dy: 20,
width: 100,
height: 100,
child: Container(color: Colors.red),
),
);
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<CanvasController>(
stream: _controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: Center(child: CircularProgressIndicator()),
);
}
final instance = snapshot.data;
return Scaffold(
appBar: AppBar(),
body: Stack(
children: [
for (final object in instance.objects)
Positioned(
top: object.dy,
left: object.dx,
width: object.width,
height: object.height,
child: object.child,
)
],
),
);
});
}
}
UI 和之前相同,但现在是动态的,我们可以访问 Stack 的子项以及每个子项的位置。
第 5 部分 - 捕获输入
我们需要捕获 MultiGestureRecognizer、GestureDetector 和 RawKeyboardListener 的输入。用以下内容更新画布控制器:
import 'dart:async';
import 'package:flutter/material.dart';
import '../classes/canvas_object.dart';
/// Control the canvas and the objects on it
class CanvasController {
/// Controller for the stream output
final _controller = StreamController<CanvasController>();
/// Reference to the stream to update the UI
Stream<CanvasController> get stream => _controller.stream;
/// Emit a new event to rebuild the UI
void add([CanvasController val]) => _controller.add(val ?? this);
/// Stop the stream and finish
void close() {
_controller.close();
focusNode.dispose();
}
/// Start the stream
void init() => add();
// -- Canvas Objects --
final List<CanvasObject<Widget>> _objects = [];
/// Current Objects on the canvas
List<CanvasObject<Widget>> get objects => _objects;
/// Add an object to the canvas
void addObject(CanvasObject<Widget> value) => _update(() {
_objects.add(value);
});
/// Add an object to the canvas
void updateObject(int i, CanvasObject<Widget> value) => _update(() {
_objects[i] = value;
});
/// Remove an object from the canvas
void removeObject(int i) => _update(() {
_objects.removeAt(i);
});
/// Focus node for listening for keyboard shortcuts
final focusNode = FocusNode();
/// Raw events from keys pressed
void rawKeyEvent(BuildContext context, RawKeyEvent key) {}
/// Called every time a new finger touches the screen
void addTouch(int pointer, Offset offsetVal, Offset globalVal) {}
/// Called when any of the fingers update position
void updateTouch(int pointer, Offset offsetVal, Offset globalVal) {}
/// Called when a finger is removed from the screen
void removeTouch(int pointer) {}
/// Checks if the shift key on the keyboard is pressed
bool shiftPressed = false;
/// Scale of the canvas
double get scale => _scale;
double _scale = 1;
set scale(double value) => _update(() {
_scale = value;
});
/// Max possible scale
static const double maxScale = 3.0;
/// Min possible scale
static const double minScale = 0.2;
/// How much to scale the canvas in increments
static const double scaleAdjust = 0.05;
/// Current offset of the canvas
Offset get offset => _offset;
Offset _offset = Offset.zero;
set offset(Offset value) => _update(() {
_offset = value;
});
void _update(void Function() action) {
action();
add(this);
}
}
用以下内容更新主页:
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../src/classes/canvas_object.dart';
import '../../src/controllers/canvas.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _controller = CanvasController();
@override
void initState() {
_controller.init();
_dummyData();
super.initState();
}
void _dummyData() {
_controller.addObject(
CanvasObject(
dx: 20,
dy: 20,
width: 100,
height: 100,
child: Container(color: Colors.red),
),
);
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<CanvasController>(
stream: _controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: Center(child: CircularProgressIndicator()),
);
}
final instance = snapshot.data;
return Scaffold(
appBar: AppBar(),
body: Listener(
behavior: HitTestBehavior.opaque,
onPointerSignal: (details) {
if (details is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(details, (event) {
if (event is PointerScrollEvent) {
if (_controller.shiftPressed) {
double zoomDelta = (-event.scrollDelta.dy / 300);
_controller.scale = _controller.scale + zoomDelta;
} else {
_controller.offset =
_controller.offset - event.scrollDelta;
}
}
});
}
},
onPointerMove: (details) {
_controller.updateTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerDown: (details) {
_controller.addTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerUp: (details) {
_controller.removeTouch(details.pointer);
},
onPointerCancel: (details) {
_controller.removeTouch(details.pointer);
},
child: RawKeyboardListener(
autofocus: true,
focusNode: _controller.focusNode,
onKey: (key) => _controller.rawKeyEvent(context, key),
child: Stack(
children: [
for (final object in instance.objects)
Positioned(
top: object.dy,
left: object.dx,
width: object.width,
height: object.height,
child: object.child,
)
],
),
),
),
);
});
}
}
现在我们所做的只是将 UI 的输入映射到控制器中的操作。如果你想了解每项操作的工作方式,可以看看注释。运行应用后,仍然只会显示红色方块。
第 5 部分 - 画布偏移和缩放
现在,我们要开始移动画布。先处理偏移,因为缩放需要采用不同的方法。使用以下内容更新主页:
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../src/classes/canvas_object.dart';
import '../../src/controllers/canvas.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _controller = CanvasController();
@override
void initState() {
_controller.init();
_dummyData();
super.initState();
}
void _dummyData() {
_controller.addObject(
CanvasObject(
dx: 20,
dy: 20,
width: 100,
height: 100,
child: Container(color: Colors.red),
),
);
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<CanvasController>(
stream: _controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: Center(child: CircularProgressIndicator()),
);
}
final instance = snapshot.data;
return Scaffold(
appBar: AppBar(),
body: Listener(
behavior: HitTestBehavior.opaque,
onPointerSignal: (details) {
if (details is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(details, (event) {
if (event is PointerScrollEvent) {
if (_controller.shiftPressed) {
double zoomDelta = (-event.scrollDelta.dy / 300);
_controller.scale = _controller.scale + zoomDelta;
} else {
_controller.offset =
_controller.offset - event.scrollDelta;
}
}
});
}
},
onPointerMove: (details) {
_controller.updateTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerDown: (details) {
_controller.addTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerUp: (details) {
_controller.removeTouch(details.pointer);
},
onPointerCancel: (details) {
_controller.removeTouch(details.pointer);
},
child: RawKeyboardListener(
autofocus: true,
focusNode: _controller.focusNode,
onKey: (key) => _controller.rawKeyEvent(context, key),
child: SizedBox.expand(
child: Stack(
children: [
for (final object in instance.objects)
AnimatedPositioned.fromRect(
duration: const Duration(milliseconds: 50),
rect: object.rect.adjusted(
_controller.offset,
_controller.scale,
),
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox.fromSize(
size: object.size,
child: object.child,
),
),
)
],
),
),
),
),
);
});
}
}
extension RectUtils on Rect {
Rect adjusted(Offset offset, double scale) {
final left = (this.left + offset.dx) * scale;
final top = (this.top + offset.dy) * scale;
final width = this.width * scale;
final height = this.height * scale;
return Rect.fromLTWH(left, top, width, height);
}
}
现在,用触控板双指平移时,你会看到红色方块移动。接下来还需要添加手指触控支持。你可能注意到了 FittedBox,添加缩放后就会用到它。
如果我们把方块移出屏幕,可能需要把它移回来。我们可以在 AppBar 中添加一个重置按钮。将以下内容添加到画布控制器:
static const double _scaleDefault = 1;
static const Offset _offsetDefault = Offset.zero;
void reset() {
scale = _scaleDefault;
offset = _offsetDefault;
}
使用以下内容更新主页:
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../src/classes/canvas_object.dart';
import '../../src/controllers/canvas.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _controller = CanvasController();
@override
void initState() {
_controller.init();
_dummyData();
super.initState();
}
void _dummyData() {
_controller.addObject(
CanvasObject(
dx: 20,
dy: 20,
width: 100,
height: 100,
child: Container(color: Colors.red),
),
);
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<CanvasController>(
stream: _controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: Center(child: CircularProgressIndicator()),
);
}
final instance = snapshot.data;
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
tooltip: 'Reset the Scale and Offset',
icon: Icon(Icons.restore),
onPressed: _controller.reset,
),
],
),
body: Listener(
behavior: HitTestBehavior.opaque,
onPointerSignal: (details) {
if (details is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(details, (event) {
if (event is PointerScrollEvent) {
if (_controller.shiftPressed) {
double zoomDelta = (-event.scrollDelta.dy / 300);
_controller.scale = _controller.scale + zoomDelta;
} else {
_controller.offset =
_controller.offset - event.scrollDelta;
}
}
});
}
},
onPointerMove: (details) {
_controller.updateTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerDown: (details) {
_controller.addTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerUp: (details) {
_controller.removeTouch(details.pointer);
},
onPointerCancel: (details) {
_controller.removeTouch(details.pointer);
},
child: RawKeyboardListener(
autofocus: true,
focusNode: _controller.focusNode,
onKey: (key) => _controller.rawKeyEvent(context, key),
child: SizedBox.expand(
child: Stack(
children: [
for (final object in instance.objects)
AnimatedPositioned.fromRect(
duration: const Duration(milliseconds: 50),
rect: object.rect.adjusted(
_controller.offset,
_controller.scale,
),
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox.fromSize(
size: object.size,
child: object.child,
),
),
)
],
),
),
),
),
);
});
}
}
extension RectUtils on Rect {
Rect adjusted(Offset offset, double scale) {
final left = (this.left + offset.dx) * scale;
final top = (this.top + offset.dy) * scale;
final width = this.width * scale;
final height = this.height * scale;
return Rect.fromLTWH(left, top, width, height);
}
}
现在,按下重置按钮时,画布会以动画方式恢复到默认偏移和缩放比例。
趁此机会,我们还可以添加放大/缩小操作,并将其连接到控制器。将以下内容添加到画布控制器:
void zoomIn() {
scale += scaleAdjust;
}
void zoomOut() {
scale -= scaleAdjust;
}
将以下内容添加到 AppBar 的操作中:
IconButton(
tooltip: 'Zoom In',
icon: Icon(Icons.zoom_in),
onPressed: _controller.zoomIn,
),
IconButton(
tooltip: 'Zoom Out',
icon: Icon(Icons.zoom_out),
onPressed: _controller.zoomOut,
),
现在运行应用程序,就可以轻松放大/缩小了。
第 6 部分 - 键盘快捷键
现在需要捕获键盘事件,这样就可以用方向键移动画布,并用 +/- 键缩放。使用以下内容更新控制器:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../classes/canvas_object.dart';
/// Control the canvas and the objects on it
class CanvasController {
/// Controller for the stream output
final _controller = StreamController<CanvasController>();
/// Reference to the stream to update the UI
Stream<CanvasController> get stream => _controller.stream;
/// Emit a new event to rebuild the UI
void add([CanvasController val]) => _controller.add(val ?? this);
/// Stop the stream and finish
void close() {
_controller.close();
focusNode.dispose();
}
/// Start the stream
void init() => add();
// -- Canvas Objects --
final List<CanvasObject<Widget>> _objects = [];
/// Current Objects on the canvas
List<CanvasObject<Widget>> get objects => _objects;
/// Add an object to the canvas
void addObject(CanvasObject<Widget> value) => _update(() {
_objects.add(value);
});
/// Add an object to the canvas
void updateObject(int i, CanvasObject<Widget> value) => _update(() {
_objects[i] = value;
});
/// Remove an object from the canvas
void removeObject(int i) => _update(() {
_objects.removeAt(i);
});
/// Focus node for listening for keyboard shortcuts
final focusNode = FocusNode();
/// Raw events from keys pressed
void rawKeyEvent(BuildContext context, RawKeyEvent key) {
// Scale keys
if (key.isKeyPressed(LogicalKeyboardKey.minus)) {
zoomOut();
}
if (key.isKeyPressed(LogicalKeyboardKey.equal)) {
zoomIn();
}
// Directional Keys
if (key.isKeyPressed(LogicalKeyboardKey.arrowLeft)) {
offset = offset + Offset(offsetAdjust, 0.0);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowRight)) {
offset = offset + Offset(-offsetAdjust, 0.0);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowUp)) {
offset = offset + Offset(0.0, offsetAdjust);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
offset = offset + Offset(0.0, -offsetAdjust);
}
_shiftPressed = key.isShiftPressed;
/// Update Controller Instance
add(this);
}
/// Called every time a new finger touches the screen
void addTouch(int pointer, Offset offsetVal, Offset globalVal) {}
/// Called when any of the fingers update position
void updateTouch(int pointer, Offset offsetVal, Offset globalVal) {}
/// Called when a finger is removed from the screen
void removeTouch(int pointer) {}
/// Checks if the shift key on the keyboard is pressed
bool get shiftPressed => _shiftPressed;
bool _shiftPressed = false;
/// Scale of the canvas
double get scale => _scale;
double _scale = 1;
set scale(double value) => _update(() {
_scale = value;
});
/// Max possible scale
static const double maxScale = 3.0;
/// Min possible scale
static const double minScale = 0.2;
/// How much to scale the canvas in increments
static const double scaleAdjust = 0.05;
/// How much to shift the canvas in increments
static const double offsetAdjust = 15;
/// Current offset of the canvas
Offset get offset => _offset;
Offset _offset = Offset.zero;
set offset(Offset value) => _update(() {
_offset = value;
});
static const double _scaleDefault = 1;
static const Offset _offsetDefault = Offset.zero;
/// Reset the canvas zoom and offset
void reset() {
scale = _scaleDefault;
offset = _offsetDefault;
}
/// Zoom in the canvas
void zoomIn() {
scale += scaleAdjust;
}
/// Zoom out the canvas
void zoomOut() {
scale -= scaleAdjust;
}
void _update(void Function() action) {
action();
add(this);
}
}
现在运行应用程序,就可以只用键盘控制缩放和平移了。例如,这可以作为电视上可用的备用输入方式……
如果想看看缩放是否确实按比例进行,可以将以下内容添加到主页:
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../src/classes/canvas_object.dart';
import '../../src/controllers/canvas.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _controller = CanvasController();
@override
void initState() {
_controller.init();
_dummyData();
super.initState();
}
void _dummyData() {
_controller.addObject(
CanvasObject(
dx: 20,
dy: 20,
width: 100,
height: 100,
child: Container(color: Colors.red),
),
);
_controller.addObject(
CanvasObject(
dx: 80,
dy: 60,
width: 100,
height: 200,
child: Container(color: Colors.green),
),
);
_controller.addObject(
CanvasObject(
dx: 100,
dy: 40,
width: 100,
height: 50,
child: Container(color: Colors.blue),
),
);
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<CanvasController>(
stream: _controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: Center(child: CircularProgressIndicator()),
);
}
final instance = snapshot.data;
return Scaffold(
appBar: AppBar(
actions: [
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Zoom In',
icon: Icon(Icons.zoom_in),
onPressed: _controller.zoomIn,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Zoom Out',
icon: Icon(Icons.zoom_out),
onPressed: _controller.zoomOut,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Reset the Scale and Offset',
icon: Icon(Icons.restore),
onPressed: _controller.reset,
),
),
],
),
body: Listener(
behavior: HitTestBehavior.opaque,
onPointerSignal: (details) {
if (details is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(details, (event) {
if (event is PointerScrollEvent) {
if (_controller.shiftPressed) {
double zoomDelta = (-event.scrollDelta.dy / 300);
_controller.scale = _controller.scale + zoomDelta;
} else {
_controller.offset =
_controller.offset - event.scrollDelta;
}
}
});
}
},
onPointerMove: (details) {
_controller.updateTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerDown: (details) {
_controller.addTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerUp: (details) {
_controller.removeTouch(details.pointer);
},
onPointerCancel: (details) {
_controller.removeTouch(details.pointer);
},
child: RawKeyboardListener(
autofocus: true,
focusNode: _controller.focusNode,
onKey: (key) => _controller.rawKeyEvent(context, key),
child: SizedBox.expand(
child: Stack(
children: [
for (final object in instance.objects)
AnimatedPositioned.fromRect(
duration: const Duration(milliseconds: 50),
rect: object.rect.adjusted(
_controller.offset,
_controller.scale,
),
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox.fromSize(
size: object.size,
child: object.child,
),
),
)
],
),
),
),
),
);
});
}
}
extension RectUtils on Rect {
Rect adjusted(Offset offset, double scale) {
final left = (this.left + offset.dx) * scale;
final top = (this.top + offset.dy) * scale;
final width = this.width * scale;
final height = this.height * scale;
return Rect.fromLTWH(left, top, width, height);
}
}
你可以进行缩放,所有区块都会正确缩放并随之平移。
按下重置按钮即可重新开始。
第 7 部分 - 多点触控输入
现在来处理手指触控。为此,你需要一台触摸屏设备进行测试。你可以连接手机,或者在有触摸屏的电脑上运行 Web 版本。使用以下内容更新控制器:
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../classes/canvas_object.dart';
import '../classes/rect_points.dart';
/// Control the canvas and the objects on it
class CanvasController {
/// Controller for the stream output
final _controller = StreamController<CanvasController>();
/// Reference to the stream to update the UI
Stream<CanvasController> get stream => _controller.stream;
/// Emit a new event to rebuild the UI
void add([CanvasController val]) => _controller.add(val ?? this);
/// Stop the stream and finish
void close() {
_controller.close();
focusNode.dispose();
}
/// Start the stream
void init() => add();
// -- Canvas Objects --
final List<CanvasObject<Widget>> _objects = [];
/// Current Objects on the canvas
List<CanvasObject<Widget>> get objects => _objects;
/// Add an object to the canvas
void addObject(CanvasObject<Widget> value) => _update(() {
_objects.add(value);
});
/// Add an object to the canvas
void updateObject(int i, CanvasObject<Widget> value) => _update(() {
_objects[i] = value;
});
/// Remove an object from the canvas
void removeObject(int i) => _update(() {
_objects.removeAt(i);
});
/// Focus node for listening for keyboard shortcuts
final focusNode = FocusNode();
/// Raw events from keys pressed
void rawKeyEvent(BuildContext context, RawKeyEvent key) {
// Scale keys
if (key.isKeyPressed(LogicalKeyboardKey.minus)) {
zoomOut();
}
if (key.isKeyPressed(LogicalKeyboardKey.equal)) {
zoomIn();
}
// Directional Keys
if (key.isKeyPressed(LogicalKeyboardKey.arrowLeft)) {
offset = offset + Offset(offsetAdjust, 0.0);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowRight)) {
offset = offset + Offset(-offsetAdjust, 0.0);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowUp)) {
offset = offset + Offset(0.0, offsetAdjust);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
offset = offset + Offset(0.0, -offsetAdjust);
}
_shiftPressed = key.isShiftPressed;
_metaPressed = key.isMetaPressed;
/// Update Controller Instance
add(this);
}
/// Trigger Shift Press
void shiftSelect() {
_shiftPressed = true;
}
/// Trigger Meta Press
void metaSelect() {
_metaPressed = true;
}
final Map<int, Offset> _pointerMap = {};
/// Number of inputs currently on the screen
int get touchCount => _pointerMap.values.length;
/// Marquee selection on the canvas
RectPoints get marquee => _marquee;
RectPoints _marquee;
/// Dragging a canvas object
bool get isMovingCanvasObject => _isMovingCanvasObject;
bool _isMovingCanvasObject = false;
final List<int> _selectedObjects = [];
List<int> get selectedObjectsIndices => _selectedObjects;
List<CanvasObject<Widget>> get selectedObjects =>
_selectedObjects.map((i) => _objects[i]).toList();
bool isObjectSelected(int i) => _selectedObjects.contains(i);
/// Called every time a new input touches the screen
void addTouch(int pointer, Offset offsetVal, Offset globalVal) {
_pointerMap[pointer] = offsetVal;
if (shiftPressed) {
final pt = (offsetVal / scale) - (offset);
_marquee = RectPoints(pt, pt);
}
/// Update Controller Instance
add(this);
}
/// Called when any of the inputs update position
void updateTouch(int pointer, Offset offsetVal, Offset globalVal) {
if (_marquee != null) {
// Update New Widget Rect
final _pts = _marquee;
final a = _pointerMap.values.first;
_pointerMap[pointer] = offsetVal;
final b = _pointerMap.values.first;
final delta = (b - a) / scale;
_pts.end = _pts.end + delta;
_marquee = _pts;
final _rect = Rect.fromPoints(_pts.start, _pts.end);
_selectedObjects.clear();
for (var i = 0; i < _objects.length; i++) {
if (_rect.overlaps(_objects[i].rect)) {
_selectedObjects.add(i);
}
}
} else if (touchCount == 1) {
// Widget Move
_isMovingCanvasObject = true;
final a = _pointerMap.values.first;
_pointerMap[pointer] = offsetVal;
final b = _pointerMap.values.first;
if (_selectedObjects.isEmpty) return;
for (final idx in _selectedObjects) {
final widget = _objects[idx];
final delta = (b - a) / scale;
final _newOffset = widget.offset + delta;
_objects[idx] = widget.copyWith(dx: _newOffset.dx, dy: _newOffset.dy);
}
} else if (touchCount == 2) {
// Scale and Rotate Update
_isMovingCanvasObject = false;
final _rectA = _getRectFromPoints(_pointerMap.values.toList());
_pointerMap[pointer] = offsetVal;
final _rectB = _getRectFromPoints(_pointerMap.values.toList());
final _delta = _rectB.center - _rectA.center;
final _newOffset = offset + (_delta / scale);
offset = _newOffset;
final aDistance = (_rectA.topLeft - _rectA.bottomRight).distance;
final bDistance = (_rectB.topLeft - _rectB.bottomRight).distance;
final change = (bDistance / aDistance);
scale = scale * change;
} else {
// Pan Update
_isMovingCanvasObject = false;
final _rectA = _getRectFromPoints(_pointerMap.values.toList());
_pointerMap[pointer] = offsetVal;
final _rectB = _getRectFromPoints(_pointerMap.values.toList());
final _delta = _rectB.center - _rectA.center;
offset = offset + (_delta / scale);
}
_pointerMap[pointer] = offsetVal;
/// Update Controller Instance
add(this);
}
/// Called when a input is removed from the screen
void removeTouch(int pointer) {
_pointerMap.remove(pointer);
if (touchCount < 1) {
_isMovingCanvasObject = false;
}
if (_marquee != null) {
_marquee = null;
_shiftPressed = false;
}
/// Update Controller Instance
add(this);
}
void selectObject(int i) => _update(() {
if (!_metaPressed) {
_selectedObjects.clear();
}
_selectedObjects.add(0);
final item = _objects.removeAt(i);
_objects.insert(0, item);
});
/// Checks if the shift key on the keyboard is pressed
bool get shiftPressed => _shiftPressed;
bool _shiftPressed = false;
/// Checks if the meta key on the keyboard is pressed
bool get metaPressed => _metaPressed;
bool _metaPressed = false;
/// Scale of the canvas
double get scale => _scale;
double _scale = 1;
set scale(double value) => _update(() {
if (value <= minScale) {
value = minScale;
} else if (value >= maxScale) {
value = maxScale;
}
_scale = value;
});
/// Max possible scale
static const double maxScale = 3.0;
/// Min possible scale
static const double minScale = 0.2;
/// How much to scale the canvas in increments
static const double scaleAdjust = 0.05;
/// How much to shift the canvas in increments
static const double offsetAdjust = 15;
/// Current offset of the canvas
Offset get offset => _offset;
Offset _offset = Offset.zero;
set offset(Offset value) => _update(() {
_offset = value;
});
static const double _scaleDefault = 1;
static const Offset _offsetDefault = Offset.zero;
/// Reset the canvas zoom and offset
void reset() {
scale = _scaleDefault;
offset = _offsetDefault;
}
/// Zoom in the canvas
void zoomIn() {
scale += scaleAdjust;
}
/// Zoom out the canvas
void zoomOut() {
scale -= scaleAdjust;
}
void _update(void Function() action) {
action();
add(this);
}
Rect _getRectFromPoints(List<Offset> offsets) {
if (offsets.length == 2) {
return Rect.fromPoints(offsets.first, offsets.last);
}
final dxs = offsets.map((e) => e.dx).toList();
final dys = offsets.map((e) => e.dy).toList();
double left = _minFromList(dxs);
double top = _minFromList(dys);
double bottom = _maxFromList(dys);
double right = _maxFromList(dxs);
return Rect.fromLTRB(left, top, right, bottom);
}
double _minFromList(List<double> values) {
double value = double.infinity;
for (final item in values) {
value = math.min(item, value);
}
return value;
}
double _maxFromList(List<double> values) {
double value = -double.infinity;
for (final item in values) {
value = math.max(item, value);
}
return value;
}
}
新建文件 src/classes/rect_points.dart,并添加以下内容:
import 'dart:ui';
class RectPoints {
RectPoints(this.start, this.end);
Offset start, end;
Rect get rect => Rect.fromPoints(start, end);
}
使用以下内容更新 main.dart:
import 'package:flutter/material.dart';
import 'ui/home/screen.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
accentColor: Colors.red,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
darkTheme: ThemeData.dark().copyWith(
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: HomeScreen(),
);
}
}
使用以下内容更新主页:
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../src/classes/canvas_object.dart';
import '../../src/controllers/canvas.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _controller = CanvasController();
@override
void initState() {
_controller.init();
_dummyData();
super.initState();
}
void _dummyData() {
_controller.addObject(
CanvasObject(
dx: 20,
dy: 20,
width: 100,
height: 100,
child: Container(color: Colors.red),
),
);
_controller.addObject(
CanvasObject(
dx: 80,
dy: 60,
width: 100,
height: 200,
child: Container(color: Colors.green),
),
);
_controller.addObject(
CanvasObject(
dx: 100,
dy: 40,
width: 100,
height: 50,
child: Container(color: Colors.blue),
),
);
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<CanvasController>(
stream: _controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: Center(child: CircularProgressIndicator()),
);
}
final instance = snapshot.data;
return Scaffold(
appBar: AppBar(
actions: [
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Selection',
icon: Icon(Icons.select_all),
color: instance.shiftPressed
? Theme.of(context).accentColor
: null,
onPressed: _controller.shiftSelect,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Meta Key',
color: instance.metaPressed
? Theme.of(context).accentColor
: null,
icon: Icon(Icons.category),
onPressed: _controller.metaSelect,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Zoom In',
icon: Icon(Icons.zoom_in),
onPressed: _controller.zoomIn,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Zoom Out',
icon: Icon(Icons.zoom_out),
onPressed: _controller.zoomOut,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Reset the Scale and Offset',
icon: Icon(Icons.restore),
onPressed: _controller.reset,
),
),
],
),
body: Listener(
behavior: HitTestBehavior.opaque,
onPointerSignal: (details) {
if (details is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(details, (event) {
if (event is PointerScrollEvent) {
if (_controller.shiftPressed) {
double zoomDelta = (-event.scrollDelta.dy / 300);
_controller.scale = _controller.scale + zoomDelta;
} else {
_controller.offset =
_controller.offset - event.scrollDelta;
}
}
});
}
},
onPointerMove: (details) {
_controller.updateTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerDown: (details) {
_controller.addTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerUp: (details) {
_controller.removeTouch(details.pointer);
},
onPointerCancel: (details) {
_controller.removeTouch(details.pointer);
},
child: RawKeyboardListener(
autofocus: true,
focusNode: _controller.focusNode,
onKey: (key) => _controller.rawKeyEvent(context, key),
child: SizedBox.expand(
child: Stack(
children: [
for (var i = 0; i < instance.objects.length; i++)
Positioned.fromRect(
rect: instance.objects[i].rect.adjusted(
_controller.offset,
_controller.scale,
),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: instance.isObjectSelected(i)
? Colors.grey
: Colors.transparent,
)),
child: GestureDetector(
onTapDown: (_) => _controller.selectObject(i),
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox.fromSize(
size: instance.objects[i].size,
child: instance.objects[i].child,
),
),
),
),
),
if (instance?.marquee != null)
Positioned.fromRect(
rect: instance.marquee.rect
.adjusted(instance.offset, instance.scale),
child: Container(
color: Colors.blueAccent.withOpacity(0.3),
),
),
],
),
),
),
),
);
});
}
}
extension RectUtils on Rect {
Rect adjusted(Offset offset, double scale) {
final left = (this.left + offset.dx) * scale;
final top = (this.top + offset.dy) * scale;
final width = this.width * scale;
final height = this.height * scale;
return Rect.fromLTWH(left, top, width, height);
}
}
现在,只需点击并拖动,就可以移动画布上的任意对象。你可以用两根手指缩放,用两根或三根手指平移。按住 Shift 键可以使用选框选择多个对象;按住 Meta/Command 键则可以逐个点击来选择多个对象。
结语
如果你的设备没有键盘,可以点击新增的图标来启用键盘按键操作。选中对象时,它周围会显示灰色边框。
现在,你可以向画布添加任意组件,并进行平移和缩放!