Flutter 绘制探索 | 绘制中的动画变换
前言:
这篇文章来通过一个有趣的案例,介绍一下 绘制中的动画变换 ,以及如何在当前的变换基础上,叠加变换。如下所示,小车在界面上呈现的任何变动,都是变换矩阵作用的效果: 注: gif 图片为 15fps ,有些卡顿,非实际动画运行效果
1. 图片的绘制
首先看一下如何在 Flutter 中绘制一张资源图片。如下所示,在 assets/images
中有一张小车的图片:
要使用资源,需要在 pubspec.yaml
中配置文件夹的逻辑:
flutter:
assets:
- assets/images/
在 Flutter 的 Canvas 绘制中,drawImage 方法可以绘制图片,其中的入参 Image
不是 material
包的图片组件,而是 dart:ui
中的 Image
图片数据:
可以通过 Flutter 框架中 decodeImageFromList
方法,通过字节数组获取 ui.Image
对象;其中字节数组可以通过文件读取、资源加载、网络下载等形式获取,比如这里获取本地资源中的字节数据可以使用 rootBundle.load
方法:
//读取 assets 中的图片
Future<ui.Image>? loadImageFromAssets(String path) async {
ByteData data = await rootBundle.load(path);
return decodeImageFromList(data.buffer.asUint8List());
}
下面 Playground
类继承自 CustomPainter
, 表示它是画板的实现类。画板只需要专注于绘制即可,像图片数据加载这种活,画板不应该操心。所以其中持有 ui.Image
对象,并在构造函数中进行初始化。在 paint
方法中使用图像进行绘制。
绘制的内容包括: 画板区域的边线示意矩形框; 小车图像及橙色边线示意框:
class Playground extends CustomPainter {
final ui.Image? image;
Playground(this.image);
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()..style = PaintingStyle.stroke;
canvas.drawRect(Offset.zero & size, paint);
if (image != null) {
drawCarWithRange(canvas, paint);
}
}
void drawCarWithRange(Canvas canvas, Paint paint) {
Rect zone = Rect.fromLTRB(0, 0, image!.width.toDouble(), image!.width.toDouble());
paint.color = Colors.orange;
canvas.drawRect(zone, paint);
// 绘制图片
canvas.drawImage(image!, Offset.zero, paint);
}
@override
bool shouldRepaint(covariant Playground oldDelegate) {
return oldDelegate.image!=image;
}
}
2.界面中的组件布局
案例中的布局也很简单:左边是画板区域,右侧是三个控制按钮,分别用于 恢复原位
、顺时针旋转 90°
;动画移动
。
由于控制按钮的布局相对独立,它与界面其他元素的关系只有回调事件。以后可能会增加其他的按钮,或者修改样式,所以这里将其封装为一个 ControlTools
组件来独立维护,并暴露三个回调给外界来监听事件的触发:
import 'package:flutter/material.dart';
class ControlTools extends StatelessWidget {
final VoidCallback onReset;
final VoidCallback onRotate;
final VoidCallback onMove;
const ControlTools({
Key? key,
required this.onReset,
required this.onRotate,
required this.onMove,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
GestureDetector(
onTap: onReset,
child: const Icon(Icons.refresh, color: Colors.blue,),
),
const SizedBox(width: 16),
GestureDetector(
onTap: onRotate,
child: const Icon(Icons.rotate_90_degrees_ccw, color: Colors.blue),
),
const SizedBox(width: 16),
GestureDetector(
onTap: onMove,
child: const Icon(Icons.run_circle_outlined, color: Colors.blue),
)
],
),
);
}
}
这样也能在一定程度上,缓解主布局界面中的代码混乱程度。下面的 RunCar
组件是当前的主界面,在其状态类的 initState
回调中加载图片资源,为 ui.Image
数据赋值和触发更新。Playground
换班可以通过 CustomPaint
组件呈现在界面上,左右通过 Row
组件进行横向布局:
import 'dart:math';
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
class RunCar extends StatefulWidget {
const RunCar({Key? key}) : super(key: key);
@override
State<RunCar> createState() => _RunCarState();
}
class _RunCarState extends State<RunCar> {
ui.Image? _image;
@override
void initState() {
super.initState();
_loadImage();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CustomPaint(
size: const Size(400, 400),
painter: Playground(_image),
),
ControlTools(
onReset: _onReset,
onMove: _onMove,
onRotate: _onRotate,
),
],
),
),
);
}
//读取 assets 中的图片
Future<ui.Image>? loadImageFromAssets(String path) async {
ByteData data = await rootBundle.load(path);
return decodeImageFromList(data.buffer.asUint8List());
}
void _loadImage() async {
_image = await loadImageFromAssets('assets/images/car.png');
setState(() {});
}
}
3.如何对绘制区域进行变换操作
下面来看一下,如何对一部分的绘制内容进行变换,对于移动、平移、缩放等简单的变换 Canvas 中提供了相关的方法。但我们现在要做的,需要基于多个变换进行叠加,比如 移动、旋转、移动、移动
,如果每个动作都通过 Canvas 的相关方法进行变换处理,需要很多无谓的计算,也会把过程搞得非常复杂。
Canvas 中有一个 transform
方法,可以通过 Matrix4 矩阵进行变换。而矩阵可以通过乘法进行变换的叠加,下面一个小例子说明一下:
---->[playground.dart#绘制方法]----
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()..style = PaintingStyle.stroke;
canvas.drawRect(Offset.zero & size, paint);
if (image != null) {
// 操作矩阵
Matrix4 m4 = Matrix4.identity();
Matrix4 moveMatrix = Matrix4.translationValues(100, 0, 0);
m4.multiply(moveMatrix);
canvas.save();
canvas.transform(m4.storage);
drawCarWithRange(canvas, paint);
canvas.restore();
}
}
案例中 m4
矩阵是在绘制图片时施加的变换,moveMatrix
表示移动变换的矩阵。m4.multiply(moveMatrix)
矩阵表示在 m4
上叠加 moveMatrix
变换,本质上是两个 4X4 矩阵的乘法。 触发 multiply
方法后会, m4
矩阵的值会被改变。使用它的数据作为 canvas.transform
的参数,会产生移动的变换效果:
下面再来看下旋转变换,默认情况下 Canvas 在进行变换时是以画布左上角为变换中心的。当叠加顺时针 90° 的旋转变换时,效果如下所示:
Matrix4 m4 = Matrix4.identity();
Matrix4 rotate90 = Matrix4.rotationZ(pi/2);
m4.multiply(rotate90);
// 略同...
其实对于旋转而言,很多时候我们期望旋转中心是在被变换者的中心,这就要对变换中心进行处理。关于这方面,之前出过一个视频,感兴趣的可以看一下 : 《Flutter 绘制实践 | 路径篇 · 变换中心》 。这里就不卖关子了,平移变换可以影响变换中心, 为了抵消平移变换带来的后果,在旋转之后,反向平移即可。矩阵的 multiplied
方法本质上使用的是 multiply
,只不过 multiplied
会生成新的矩阵,不会改变调用者的数据。 代码如下:
Matrix4 m4 = Matrix4.identity();
Matrix4 moveCenter = Matrix4.translationValues(50, 50, 0);
Matrix4 moveBack = Matrix4.translationValues(-50, -50, 0);
Matrix4 rotate90 = Matrix4.rotationZ(pi/2);
rotate90 = moveCenter.multiplied(rotate90).multiplied(moveBack);
m4.multiply(rotate90);
这样就可以达到以中心为旋转中心,旋转 90° 的效果:
最后,来看一下多个矩阵的叠加效果。大家可以先想想一想,如果在上面的旋转变换之后,再叠加 moveMatrix 沿 x 轴移动 100 ,会是什么效果?
// 略同...
m4.multiply(rotate90); // 叠加旋转变换
m4.multiply(moveMatrix); // 叠加移动变换
答案是向下平移了 100
, 这时可能很多人比较疑惑, moveMatrix 不是沿 x 轴平移的吗,怎么会往下跑。其实矩阵的变换,是图形的相对坐标系统的变换,在当前的视角中,坐标系也被旋转了 90°,在当前变换之下,沿 X 轴移动是下方没有任何问题。
这样的话,名称对 m4
叠加一次 rotate90
变换,它就会以图片中心为原点旋转 90°,每次叠加一次 moveMatrix
就会以车头为正方向平移 100。
// 略同...
m4.multiply(rotate90);
m4.multiply(moveMatrix);
m4.multiply(rotate90);
m4.multiply(rotate90);
m4.multiply(rotate90);
m4.multiply(moveMatrix);
4. 控制矩阵变换
到这里,变换操作就介绍完了,我们只要在点击按钮时通过 multiply
叠加对应的矩阵,就可以完成转动和移动的效果。比如可以通过构造函数将 Matrix4
矩阵作为入参,有界面的交互来更新数据和重绘。如下所示,在画板构造时通过可监听对象来提供矩阵数据:
状态类中维护 _matrix
可监听对象,在点击按钮时,修改变换矩阵值即可。比如移动按钮每点击一次,叠加一个变换移动变换。这样就完成了一个简单版的图像旋转、平移的控制效果。
class _RunCarState extends State<RunCar> with SingleTickerProviderStateMixin {
//...
ValueNotifier<Matrix4> _matrix = ValueNotifier(Matrix4.identity());
late Matrix4 rotate90;
late Matrix4 moveMatrix;
@override
void initState() {
super.initState();
//...
_initMatrix();
}
void _initMatrix() {
// 初始化变换矩阵
Matrix4 moveCenter = Matrix4.translationValues(50, 50, 0);
Matrix4 moveBack = Matrix4.translationValues(-50, -50, 0);
rotate90 = Matrix4.rotationZ(pi/2);
rotate90 = moveCenter.multiplied(rotate90).multiplied(moveBack);
moveMatrix = Matrix4.translationValues(100, 0, 0);
}
@override
void dispose() {
_matrix.dispose();
super.dispose();
}
//...
void _onRotate() {
_matrix.value = _matrix.value.multiplied(rotate90);
}
void _onMove() {
_matrix.value = _matrix.value.multiplied(moveMatrix);
}
void _onReset() {
_matrix.value = Matrix4.identity();
}
}
5. 矩阵补间动画
上面是直接叠加矩阵,点一下动一下,接下来看一下如何为矩阵变换添加动画效果。也就是说在一段时间内会不断对矩阵数据进行更新,从起始矩阵到结束矩阵,在界面上就会呈现动画效果。需要获取动画的驱动力,最简单的方式是让状态类混入 SingleTickerProviderStateMixin
,让状态类拥有创建动画控制器的能力:
下面要让动画运动过程中,每帧叠加的矩阵进行动画过渡。矩阵的补间计算可以通过 Matrix4Tween
指定起止矩阵进行计算,下面定义了两个 Matrix4Tween
分别用于处理移动和旋转矩阵的补间:
late Matrix4Tween moveTween;
late Matrix4Tween rotateTween;
void _initTween() {
rotateTween = Matrix4Tween(begin: Matrix4.rotationZ(0), end: Matrix4.rotationZ(pi/2));
moveTween = Matrix4Tween(begin: Matrix4.translationValues(0, 0, 0), end: Matrix4.translationValues(100, 0, 0));
}
在移动方法中,监听动画帧的变化,叠加对应的矩阵值即可,如下所示:
void _onMove() {
Matrix4 start = _matrix.value.clone();
Animation<Matrix4> m4Anima = moveTween.animate(_controller);
m4Anima.addListener(() => _matrix.value = start.multiplied(m4Anima.value));
_controller.forward(from: 0);
}
旋转也是同理:这样就实现了一开始的效果:
final Matrix4 moveCenter = Matrix4.translationValues(50, 50, 0);
final Matrix4 moveBack = Matrix4.translationValues(-50, -50, 0);
void _onRotate() {
Matrix4 start = _matrix.value.clone();
Animation<Matrix4> m4Tween = rotateTween.animate(_controller);
m4Tween.addListener(() {
Matrix4 rotate = moveCenter.multiplied(m4Tween.value).multiplied(moveBack);
_matrix.value = start.multiplied(rotate);
});
_controller.forward(from: 0);
}
到这里,关于绘制中的矩阵变换就介绍的差不多了,也知道了如何对矩阵变换进行动画处理,希望可以对你有所帮助。那本文就到这里,谢谢观看 ~
转载自:https://juejin.cn/post/7218204815121776697