前言
前段时间读到一篇文章,作者通过自定义View实现了一个高仿小米时钟,其中的3D效果很是吸引我,于是抽时间学习了一下,现在总结出来,和大家分享。
正文
- 想要在Android上实现3D效果,其实并没有想象中那么复杂,我们需要运用两样东西:Camera和Matrix,这里的Camera可不是我们平常拍照用的Camera,这里的Camera是位于android.graphics包下的Camera:
Camera的坐标系是左手坐标系。当手机平整的放在桌面上,X轴是手机的水平方向,Y轴是手机的竖直方向,Z轴是垂直于手机向里的那个方向。
Camera位于坐标点(0,0),也就是视图的左上角。
我们再来了解一下Matrix,Android中的Matrix是一个3 x 3的矩阵,其内容如下:
从字面上来看, MSCALE用于处理缩放变换,MTRANS用于处理平移变换,MSKEW用于处理错切变换。最后一行的MPERSP用于处理透视变换,关于透视变换,官方文档中并没有具体的说明,这里也就不再赘述。另外,矩阵是支持旋转变换的,旋转变换是通过同时设置MSCALE和MSKEW来实现的(这里边就是一些数学原理了,笔者也是半壶水,就不在这丢人了,感兴趣的同学可以自己研究一下)。另外有同学可能对错切变换也不是特别理解,笔者当时也是自己查了下才明白,这里简单说明一下,就免得大家再去百度了:
错切变换(skew)在数学上又称为Shear mapping(可译为“剪切变换”)或者Transvection(缩并),它是一种比较特殊的线性变换。错切变换的效果就是让所有点的x坐标(或者y坐标)保持不变,而对应的y坐标(或者x坐标)则按比例发生平移,且平移的大小和该点到x轴(或y轴)的垂直距离成正比。错切变换,属于等面积变换,即一个形状在错切变换的前后,其面积是相等的。
上图中,各点的y坐标保持不变,但其x坐标则按比例发生了平移。
上图中,各点的x坐标保持不变,但其y坐标则按比例发生了平移。
还有一个不容易理解的地方,Matrix针对每种变换,都提供了set、pre和post三种操作方式。
pre方法表示矩阵前乘,如果变换矩阵为A,原始矩阵为M,pre方法即是 M x A
post方法表示矩阵后乘,如果变换矩阵为A,原始矩阵为M,post方法即是 A x M
之所以需要区分前乘和后乘,是因为矩阵的乘法不满足交换率,即 A x M != M x A
另外还有比较重要的一点, 在图像处理中,越靠近右边的矩阵越先执行
调用一系列set、pre、post方法时,可以理解为将这些操作插入一个队列:set是清空队列再添加,pre是在队首插入,post是在队尾插入。
举个栗子:
Matrix m = new Matrix();
m.postTranslate(20, 20);
m.preScale(0.2f, 0.5f);
m.setScale(0.8f, 0.8f);
m.postScale(3f, 3f);
m.preTranslate(0.5f, 0.5f);
执行顺序为:preTranslate(0.5f, 0.5f) → setScale(0.8f, 0.8f) → postScale(3f, 3f)
因为setScale(0.8f, 0.8f)会将前面的postTranslate(20, 20)和preScale(0.2f, 0.5f)清除掉,然后再将postScale(3f, 3f)插入队尾,preTranslate(0.5f, 0.5f)插入队首。
2018.4.25补充---Canvas的几何变换:
Canvas的几何变换方法包括 translate、rotate、scale、skew 几种,其原理也是运用matrix做几何变换。
我们以 canvas.rotate(float degrees) 方法举例:
以下两段代码其实是等价的:
canvas.rotate(-degreeZ); // 1
Matrix matrix = new Matrix(); // 2
matrix.reset();
matrix.preRotate(-degreeZ);
canvas.concat(matrix);
由于是前乘,所以canvas的几何变换方法是倒序的,需要把变换的代码倒着写,举个栗子:
// 如果想要让canvas先移动 (-centerX, -centerY) 距离,再移动 (centerX, centerY) 距离进行恢复
// 代码需要倒着写
canvas.translate(centerX, centerY);
canvas.translate(-centerX, -centerY);
这里再补充一下 canvas.concat(matrix) 方法:
用 Canvas 当前的变换矩阵和 Matrix 相乘,即基于 Canvas 当前的变换,叠加上 Matrix 中的变换。
-
熟悉了基本的工具,我们就可以开工了,我们先来看一下最终的效果:
圆盘跟随手指的移动而变换角度,呈现出3D的效果,看起来还是很不错的,我们看看如何来实现这个效果吧:
首先我们需要做一些初始化的工作:
private Paint mWhitePaint;
private Paint mCirclePaint;
private float mCircleStrokeWidth = 2;
private float mMaxRadius = 300;
/* Camera旋转的最大角度 */
private float mMaxCameraRotate = 15;
/* 我们今天的主角 */
private Matrix mMatrix;
private Camera mCamera;
/* Camera绕X轴旋转的角度 */
private float mCameraRotateX;
/* Camera绕Y轴旋转的角度 */
private float mCameraRotateY;
private void init(){
mMatrix = new Matrix();
mCamera = new Camera();
//白色大圆的画笔
mWhitePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mWhitePaint.setStyle(Paint.Style.FILL_AND_STROKE);
mWhitePaint.setStrokeWidth(mCircleStrokeWidth);
mWhitePaint.setColor(Color.WHITE);
//内部蓝色圆环的画笔
mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mCirclePaint.setStyle(Paint.Style.STROKE);
mCirclePaint.setStrokeWidth(mCircleStrokeWidth);
mCirclePaint.setColor(Color.WHITE);
mCirclePaint.setColor(0xff237EAD);
}
这是重写的onDraw方法,做了两件事情:
1.将canvas传入setCameraRotate方法中
2.再画几个圈圈
@Override
protected void onDraw(Canvas canvas) {
setCameraRotate(canvas);
canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius, mWhitePaint);
canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius / 6 * 5, mCirclePaint);
canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius / 6 * 4, mCirclePaint);
canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius / 6 * 3, mCirclePaint);
canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius / 6 * 2, mCirclePaint);
}
接下来我们看看setCameraRotate方法里面做了什么
private void setCameraRotate(Canvas mCanvas) {
mMatrix.reset();
mCamera.save();
mCamera.rotateX(mCameraRotateX);//绕x轴旋转
mCamera.rotateY(mCameraRotateY);//绕y轴旋转
mCamera.getMatrix(mMatrix);//计算对于当前变换的矩阵,并将其复制到传入的mMatrix中
mCamera.restore();
/**
* Camera默认位于视图的左上角,故生成的矩阵默认也是以其左上角为旋转中心,
* 所以在动作之前调用preTranslate将mMatrix向左移动getWidth()/2个长度,
* 向上移动getHeight()/2个长度,
* 使旋转中心位于矩阵的中心位置,动作之后再post回到原位
*/
mMatrix.preTranslate(-getWidth() / 2, -getHeight() / 2);
mMatrix.postTranslate(getWidth() / 2, getHeight() / 2);
mCanvas.concat(mMatrix);//将mMatrix与canvas中当前的Matrix相关联
}
以上这段代码,除了旋转操作以外,其余的基本属于固定写法,这样写的原因都在注释里写清楚了,可能有点不太好理解,多看几遍或者自己试着写一下就明白了。
上面这段代码中的mCameraRotateX和mCameraRotateY这两个全局变量的值应该与此时手指触摸坐标相关联,所以我们在onTouchEvent方法中动态设置:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//根据手指坐标计算Camera应该旋转的角度
getCameraRotate(event);
invalidate();
break;
case MotionEvent.ACTION_MOVE:
getCameraRotate(event);
invalidate();
break;
}
return true;
}
我们最后来看看getCameraRotate方法中是如何处理的:
private void getCameraRotate(MotionEvent event) {
float rotateX = -(event.getY() - getHeight() / 2);
float rotateY = (event.getX() - getWidth() / 2);
/**
*为什么旋转角度要这样计算:
* 当Camera.rotateX(x)的x为正时,图像围绕X轴,上半部分向里下半部分向外,进行旋转,
* 也就是手指触摸点要往上移。这个x就会与event.getY()的值有关,x越大,绕X轴旋转角度越大,
* 以圆心为基准,手指往上移动,event.getY() - getHeight() / 2的值为负,
* 故 float rotateX = -(event.getY() - getHeight() / 2)
* 同理,
* 当Camera.rotateY(y)的y为正时,图像围绕Y轴,右半部分向里左半部分向外,进行旋转,
* 也就是手指触摸点要往右移。这个y就会与event.getX()的值有关,y越大,绕Y轴旋转角度越大,
* 以圆心为基准,手指往右移动,event.getX() - getWidth() / 2的值为正,
* 故 float rotateY = event.getX() - getWidth() / 2
*/
/**
* 此时得到的rotateX、rotateY 其实是以圆心为基准,手指移动的距离,
* 这个值很大,不能用来作为旋转的角度,
* 所以还需要继续处理
*/
//求出移动距离与半径之比。mMaxRadius为白色大圆的半径
float percentX = rotateX / mMaxRadius;
float percentY = rotateY / mMaxRadius;
if (percentX > 1) {
percentX = 1;
} else if (percentX < -1) {
percentX = -1;
}
if (percentY > 1) {
percentY = 1;
} else if (percentY < -1) {
percentY = -1;
}
//将最终的旋转角度控制在一定的范围内,这里mMaxCameraRotate的值为15,效果比较好
mCameraRotateX = percentX * mMaxCameraRotate;
mCameraRotateY = percentY * mMaxCameraRotate;
}
到这里,我们要的3D效果就已经实现了。也是费了一番功夫。
结语
写这篇文章的起因是读到了猴菇先生的博客高仿小米时钟 - 使用Camera和Matrix实现3D效果对文中实现的3D效果产生了兴趣,但是文中主要的篇幅还是介绍如何自定义View,关于3D效果的实现只有主要代码和简单的注释,所以我又自己从Camera和Matrix的定义开始,将3D效果作为主体重新学习了一遍,便是有了这篇文章。文中的部分代码也是从猴菇先生的代码中借鉴的,将代码进行了简化,只保留了3D效果的部分,将注释和说明进行了丰富,从头开始讲解,更加易于学习。
从开始研究到写完这篇文章,断断续续加起来差不多花了2天时间,发现写文章确实很锻炼人,以前自己遇到问题,上网随便搜搜,看个大概,就完事儿了。现在想要写出来,必须要弄明白、透彻,才敢动手写,也算是对自己的一种监督吧,我可不愿意误人子弟,所以经常写到一半,发现某些地方不是特别清楚,又回过头去弄明白了再继续写,写完之后,收获也是大大的。而且之前写的文章还收到了点赞、关注还有打赏,真的特别开心。
最后,如有错误,欢迎指正。