之前写过一篇关于图像变换处理的文章《Android开发之图像处理那点事——变换》,学以致用,这次我们来实现仿微博的贴纸效果,我打算分成两部分来写:
基础篇:单图贴纸效果,利用矩阵变化+手势识别实现贴纸的自由缩放、旋转、平移,以面向过程的代码让你知道每一步操作的实现原理。
强化篇:仿美图秀秀的多图贴纸效果,以面向对象的思维告诉你如何将图像、矩阵封装,包括贴纸的聚焦处理、重叠场景的交互分析、图像的二次采样、合成等知识点。
关于矩阵的基础操作这里就不再重复阐述,不了解矩阵的朋友可以看一下《Android开发之图像处理那点事——变换》。
先来看下本篇文章要实现的效果图:
实现思路:
我们可以将上述效果大致分成2部分理解,贴图的展示、手势的操作:
贴图的展示:
首先它是一个可以显示图像的自定义View,且它的大小,角度,扭曲程度都是由Matrix矩阵来维护的,所以我们很自然的可以想到 Canvas.drawBitmap(Bitmap, Matrix, Paint)
这个绘制方法。
手势的操作:
关于手势的操作,我们大致可以分成这三种,单指的拖动平移,双指的放大缩小,双指的旋转,这里我们需要先了解onTouch中MotionEvent给我们返回的几种事件:
ACTION_DOWN:当手指触摸屏幕的时候触发。
ACTION_MOVE:当手指滑动屏幕的时候触发。
ACTION_UP:当手指抬起的时候触发(此时屏幕无手指触摸)。
ACTION_POINTER_DOWN:当多根手指触摸屏幕的时候触发。
ACTION_POINTER_UP:在多根手机触摸屏幕的情况下,抬起其中一根手指的时候触发。
根据以上的触发事件,我们就可以得到一些我们想要的场景了,比如当单指触摸贴纸的时候,我们将贴纸的属性设为可拖动(不可缩放、旋转),当双指触摸贴纸的时候,我们将贴纸的属性设为不可拖动(可缩放、旋转),而缩放因子我们可以通过双指间距离的改变得到,旋转角度我们可以通过双指移动形成的夹角得到,这些下文会具体分析,先大致有个思路就行。
好了,既然有了思路,我们就开始撸码吧~
编码实现:
首先我们需要将图片加载成Bitmap,并用矩阵Matrix去维护它,在自定义View中画出来:
canvas.drawBitmap(mBitmap, mMatrix, null);
这里定义三种标志,分别表示当前贴纸处于可移动、可缩放、可旋转状态:
private boolean mCanTranslate;//标志是否可移动
private boolean mCanScale;//标志是否可缩放
private boolean mCanRotate;//标志是否可旋转
下面我们来分别实现贴纸的移动,缩放,旋转效果:
贴纸的移动:
思路:当用户手指(单指)按下屏幕的时候,需要判断手指的触摸点是否在贴纸上,如果在,将贴纸的状态标记为可移动并记录下当前坐标,当用户手指滑动屏幕的时候,需要计算出当前手指所在坐标与刚才按下屏幕坐标的相对距离,通过维护贴纸的矩阵来做平移操作。
代码实现:在onTouch的ACTION_DOWN中去记录触摸点并判断手指触摸点是否在贴纸上,如果在,把状态标记为可移动:
case MotionEvent.ACTION_DOWN:
mLastSinglePoint.set(event.getX(), event.getY());
if (isInStickerView(event)) {
//触摸点是否在贴纸范围内
mCanTranslate = true;
}
mCanScale = false;
mCanRotate = false;
break;
检测触摸点的方法,这里简单介绍下Matrix类中的两个方法:
invert:Matrix类中给我们提供了invert方法用来反转矩阵,举个例子,一个向左旋转30°的矩阵。通过invert可以得到一个基于当前(左旋转30°的矩阵)向右旋转30°的矩阵。
mapPoints:Matrix类中给我们提供了mapPoints方法用来映射所有坐标点经过矩阵变化后的新坐标点位置。
有了上面的2个方法,我们就可以根据当前的矩阵得到它变换之前的原矩阵,然后再把当前触摸的点通过原矩阵映射回原来触摸的点,再判断触摸点是否在原来贴纸的矩形框范围内即可。
/**
* 检测当前触摸是否在贴纸上
*
* @return
*/
private boolean isInStickerView(MotionEvent motionEvent) {
if (motionEvent.getPointerCount() == 1) {
float[] dstPoints = new float[2];
float[] srcPoints = new float[]{motionEvent.getX(), motionEvent.getY()};
Matrix matrix = new Matrix();
mMatrix.invert(matrix);
matrix.mapPoints(dstPoints, srcPoints);
if (mBitmapBound.contains(dstPoints[0], dstPoints[1])) {
return true;
}
}
if (motionEvent.getPointerCount() == 2) {
float[] dstPoints = new float[4];
float[] srcPoints = new float[]{motionEvent.getX(0), motionEvent.getY(0), motionEvent.getX(1), motionEvent.getY(1)};
Matrix matrix = new Matrix();
mMatrix.invert(matrix);
matrix.mapPoints(dstPoints, srcPoints);
if (mBitmapBound.contains(dstPoints[0], dstPoints[1]) || mBitmapBound.contains(dstPoints[2], dstPoints[3])) {
return true;
}
}
return false;
}
在onTouch的ACTION_MOVE中去计算x,y相对移动的坐标,然后调用矩阵的平移方法即可:
case MotionEvent.ACTION_MOVE:
if (mCanTranslate) {
translate(event.getX() - mLastSinglePoint.x, event.getY() - mLastSinglePoint.y);
mLastSinglePoint.set(event.getX(), event.getY());
}
break;
/**
* 平移操作
*
* @param dx
* @param dy
*/
private void translate(float dx, float dy) {
mMatrix.postTranslate(dx, dy);
mMatrix.mapPoints(mDstPoints, mScrPoints);
}
贴纸的缩放:
思路:当用户手指(双指)按下屏幕的时候,需要判断手指的触摸点是否在贴纸上,如果在,将贴纸的状态标记为可缩放并记录下手指之间的距离,当用户手指滑动屏幕的时候,我们可以计算出当前手指之间的距离与刚才按下屏幕手指间距离的比值,这个比值就是贴纸的缩放因子,大于1表示放大,小于1表示缩小,缩放中心为贴纸中心。
代码实现:在onTouch的ACTION_POINTER_DOWN中去判断手指触摸点的个数和触摸点位置,如果触摸点为2且在贴纸上,将状态标记为可缩放,并记录下手指间的距离:
case MotionEvent.ACTION_POINTER_DOWN:
if (event.getPointerCount() == 2 && isInStickerView(event)) {
mCanTranslate = false;
mCanScale = true;
mCanRotate = true;
//计算双指之间向量
mLastDistancePoint.set(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1));
//计算双指之间距离
mLastDistance = calculateDistance(event);
}
break;
根据直角三角形勾股定理可以得到手指间的距离:
/**
* 计算两点之间的距离
*/
private float calculateDistance(MotionEvent event) {
float x = event.getX(0) - event.getX(1);
float y = event.getY(0) - event.getY(1);
return (float) Math.sqrt(x * x + y * y);
}
在onTouch的ACTION_MOVE中得到新的手指间的距离,与刚才手指按下屏幕时记录的距离做对比得到缩放因子,然后调用矩阵的缩放方法即可,缩放中心为贴纸中点:
case MotionEvent.ACTION_MOVE:
if (mCanScale && event.getPointerCount() == 2) {
//操作自由缩放
//手指间距离
float distance = calculateDistance(event);
//根据双指移动的距离获取缩放因子
float scale = distance / mLastDistance;
scale(scale, scale, getMidPoint().x, getMidPoint().y);
mLastDistance = distance;
}
break;
/**
* 缩放操作
*
* @param sx
* @param sy
* @param px
* @param py
*/
private void scale(float sx, float sy, float px, float py) {
mMatrix.postScale(sx, sy, px, py);
mMatrix.mapPoints(mDstPoints, mScrPoints);
}
贴纸的旋转:
思路:当用户手指(双指)按下屏幕的时候,需要判断手指的触摸点是否在贴纸上,如果在,将贴纸的状态标记为可旋转并记录下手指所形成的向量,当用户手指滑动屏幕的时,需要计算出当前手指所形成的向量与刚才按下屏幕手指所形成的向量的角度差,这个角度差就是贴纸应该旋转的角度了,旋转中心为贴纸中点:
代码实现:在onTouch的ACTION_POINTER_DOWN中去判断手指触摸点的个数和触摸点位置,如果触摸点在贴纸上,将状态标记为可旋转并记录下手指间的所形成的向量:
case MotionEvent.ACTION_POINTER_DOWN:
if (event.getPointerCount() == 2 && isInStickerView(event)) {
mCanTranslate = false;
mCanScale = true;
mCanRotate = true;
//计算双指之间向量
mLastDistancePoint.set(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1));
//计算双指之间距离
mLastDistance = calculateDistance(event);
}
break;
在onTouch的ACTION_MOVE中得到新的手指间所形成的向量,然后去计算它们之间所形成的夹角值:
case MotionEvent.ACTION_MOVE:
if (mCanRotate && event.getPointerCount() == 2) {
//操作自由旋转
mDistancePoint.set(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1));
rotate(calculateDegrees(mLastDistancePoint, mDistancePoint), getMidPoint().x, getMidPoint().y);
mLastDistancePoint.set(mDistancePoint.x, mDistancePoint.y);
}
break;
我们在这里可以通过计算向量的斜率差来获取手指间的旋转角度:
/**
* 计算旋转角度
*
* @param lastPoint
* @param pointF
* @return
*/
private float calculateDegrees(PointF lastPoint, PointF pointF) {
float lastDegrees = (float) Math.atan2(lastPoint.y, lastPoint.x);
float currentDegrees = (float) Math.atan2(pointF.y, pointF.x);
return (float) Math.toDegrees(currentDegrees - lastDegrees);
}
/**
* 旋转操作
*
* @param degrees
* @param px
* @param py
*/
private void rotate(float degrees, float px, float py) {
mMatrix.postRotate(degrees, px, py);
mMatrix.mapPoints(mDstPoints, mScrPoints);
}
以上就是实现贴纸效果的核心代码了,很简单吧,其实就只是矩阵、手势、三角函数的综合运用。
补充说明:
1、细心的朋友会发现在上面的代码中,平移、缩放、旋转操作都伴随着一行代码mMatrix.mapPoints(mDstPoints, mScrPoints);
,这句话是做什么用的呢?其实一开始图片加载成Bitmap对象的时候,我就记录下了一些特殊点:
//初始化图像
mBitmap = BitmapFactory.decodeResource(context.getResources(), R.mipmap.icon);
//记录图像一些点位置
mScrPoints = new float[]{
0, 0,//左上
mBitmap.getWidth(), 0,//右上
mBitmap.getWidth(), mBitmap.getHeight(),//右下
0, mBitmap.getHeight(),//左下
mBitmap.getWidth() / 2, mBitmap.getHeight() / 2//中间点
};
//拷贝点位置
mDstPoints = mScrPoints.clone();
然后根据上文介绍mapPoints方法,将每一次矩阵变化所影响的点位置都做了映射,这样我们就可以很方便的得到任一时刻的最新点位置,比如我们要知道某一时刻图片的中点位置,我们就可以这样做:
/**
* 获取图像中心点
*
* @return
*/
private PointF getMidPoint() {
mMidPoint.set(mDstPoints[8], mDstPoints[9]);
return mMidPoint;
}
2、关于旋转角度的计算,这边可以有很多方法,上文我采用的是计算出手指间的向量,然后求出他们的斜率差,然后转换成角度,这里额外多介绍一种求角度的方法:
通过余弦定理求夹角:我们以图片的中点为旋转中心,加上我们双指的触碰点,我们就可以知道三角形的三个点坐标,就可以知道三边的距离,通过余弦定理我们很轻松的可以得到cos值,再将其转换成角度即可。
如果不清楚余弦定理的朋友请戳:余弦定理视频讲解
这里需要注意象限问题,也就是cos值的正负,因为旋转有正时针方向和逆时针方向,这里我们可以通过向量积来判断:
3、在做完一些列手势操作,手指抬起的时候,我们把状态重置:
/**
* 重置状态
*/
private void reset() {
mCanTranslate = false;
mCanScale = false;
mCanRotate = false;
mLastDistance = 0f;
mMidPoint.set(0f, 0f);
mLastSinglePoint.set(0f, 0f);
mLastDistancePoint.set(0f, 0f);
mDistancePoint.set(0f, 0f);
}
好了,到这里文章就结束啦,这里给出完整代码(启蒙思路,优化版请见下一篇文章):
package com.lcw.view;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PointF;
import android.graphics.RectF;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import com.lcw.view.R;
/**
* 自定义贴纸View
* Create by: chenWei.li
* Date: 2018/11/22
* Time: 下午11:02
* Email: lichenwei.me@foxmail.com
*/
/**
* @Deprecated 基础贴纸类,废弃不再使用
*/
public class StickerView extends View implements View.OnTouchListener {
private Bitmap mBitmap;//贴纸图片
private Matrix mMatrix;//维护图像变化的矩阵
private float[] mScrPoints;//矩阵变换前的点坐标
private float[] mDstPoints;//矩阵变换后的点坐标
private RectF mBitmapBound;//图片的外围边框的点坐标
private boolean mCanTranslate;//标志是否可移动
private boolean mCanScale;//标志是否可缩放
private boolean mCanRotate;//标志是否可旋转
private float mLastDistance;//记录上一次双指之间的距离
private PointF mMidPoint = new PointF();//记录图片中心点
private PointF mLastSinglePoint = new PointF();//记录上一次单指触摸屏幕的点坐标
private PointF mLastDistancePoint = new PointF();//记录上一次双指触摸屏幕的点坐标
private PointF mDistancePoint = new PointF();//记录当前双指触摸屏幕的点坐标
private Paint mPaint;
public StickerView(Context context) {
super(context);
init(context);
}
public StickerView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
public StickerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
/**
* 完成一些初始化操作
*
* @param context
*/
private void init(Context context) {
//初始化画笔
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setColor(Color.GRAY);
//初始化图像
mBitmap = BitmapFactory.decodeResource(context.getResources(), R.mipmap.icon);
//记录图像一些点位置
mScrPoints = new float[]{
0, 0,//左上
mBitmap.getWidth(), 0,//右上
mBitmap.getWidth(), mBitmap.getHeight(),//右下
0, mBitmap.getHeight(),//左下
mBitmap.getWidth() / 2, mBitmap.getHeight() / 2//中间点
};
//拷贝点位置
mDstPoints = mScrPoints.clone();
mBitmapBound = new RectF(0, 0, mBitmap.getWidth(), mBitmap.getHeight());
//初始化矩阵
mMatrix = new Matrix();
//移动图像到屏幕中心
// WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
// DisplayMetrics displayMetrics = new DisplayMetrics();
// windowManager.getDefaultDisplay().getMetrics(displayMetrics);
// float dx = displayMetrics.widthPixels / 2 - mBitmap.getWidth() / 2;
// float dy = displayMetrics.heightPixels / 2 - mBitmap.getHeight() / 2;
// translate(dx, dy);
//设置触摸监听
setOnTouchListener(this);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(mBitmap, mMatrix, null);
}
@Override
public boolean onTouch(View v, MotionEvent event) {
int action = event.getAction();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
mLastSinglePoint.set(event.getX(), event.getY());
if (isInStickerView(event)) {
//触摸点是否在贴纸范围内
mCanTranslate = true;
}
mCanScale = false;
mCanRotate = false;
break;
case MotionEvent.ACTION_POINTER_DOWN:
if (event.getPointerCount() == 2 && isInStickerView(event)) {
mCanTranslate = false;
mCanScale = true;
mCanRotate = true;
//计算双指之间向量
mLastDistancePoint.set(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1));
//计算双指之间距离
mLastDistance = calculateDistance(event);
}
break;
case MotionEvent.ACTION_MOVE:
if (mCanTranslate) {
translate(event.getX() - mLastSinglePoint.x, event.getY() - mLastSinglePoint.y);
mLastSinglePoint.set(event.getX(), event.getY());
}
if ((mCanScale || mCanRotate) && event.getPointerCount() == 2) {
//操作自由缩放
float distance = calculateDistance(event);
//根据双指移动的距离获取缩放因子
float scale = distance / mLastDistance;
scale(scale, scale, getMidPoint().x, getMidPoint().y);
mLastDistance = distance;
//操作自由旋转
mDistancePoint.set(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1));
rotate(calculateDegrees(mLastDistancePoint, mDistancePoint), getMidPoint().x, getMidPoint().y);
mLastDistancePoint.set(mDistancePoint.x, mDistancePoint.y);
}
break;
case MotionEvent.ACTION_UP:
reset();
break;
}
invalidate();
return true;
}
/**
* 平移操作
*
* @param dx
* @param dy
*/
private void translate(float dx, float dy) {
mMatrix.postTranslate(dx, dy);
mMatrix.mapPoints(mDstPoints, mScrPoints);
}
/**
* 缩放操作
*
* @param sx
* @param sy
* @param px
* @param py
*/
private void scale(float sx, float sy, float px, float py) {
mMatrix.postScale(sx, sy, px, py);
mMatrix.mapPoints(mDstPoints, mScrPoints);
}
/**
* 旋转操作
*
* @param degrees
* @param px
* @param py
*/
private void rotate(float degrees, float px, float py) {
mMatrix.postRotate(degrees, px, py);
mMatrix.mapPoints(mDstPoints, mScrPoints);
}
/**
* 检测当前触摸是否在贴纸上
*
* @return
*/
private boolean isInStickerView(MotionEvent motionEvent) {
if (motionEvent.getPointerCount() == 1) {
float[] dstPoints = new float[2];
float[] srcPoints = new float[]{motionEvent.getX(), motionEvent.getY()};
Matrix matrix = new Matrix();
mMatrix.invert(matrix);
matrix.mapPoints(dstPoints, srcPoints);
if (mBitmapBound.contains(dstPoints[0], dstPoints[1])) {
return true;
}
}
if (motionEvent.getPointerCount() == 2) {
float[] dstPoints = new float[4];
float[] srcPoints = new float[]{motionEvent.getX(0), motionEvent.getY(0), motionEvent.getX(1), motionEvent.getY(1)};
Matrix matrix = new Matrix();
mMatrix.invert(matrix);
matrix.mapPoints(dstPoints, srcPoints);
if (mBitmapBound.contains(dstPoints[0], dstPoints[1]) || mBitmapBound.contains(dstPoints[2], dstPoints[3])) {
return true;
}
}
return false;
}
/**
* 获取图像中心点
*
* @return
*/
private PointF getMidPoint() {
mMidPoint.set(mDstPoints[8], mDstPoints[9]);
return mMidPoint;
}
/**
* 计算两点之间的距离
*/
private float calculateDistance(MotionEvent event) {
float x = event.getX(0) - event.getX(1);
float y = event.getY(0) - event.getY(1);
return (float) Math.sqrt(x * x + y * y);
}
/**
* 计算旋转角度
*
* @param lastPoint
* @param pointF
* @return
*/
private float calculateDegrees(PointF lastPoint, PointF pointF) {
float lastDegrees = (float) Math.atan2(lastPoint.y, lastPoint.x);
float currentDegrees = (float) Math.atan2(pointF.y, pointF.x);
return (float) Math.toDegrees(currentDegrees - lastDegrees);
}
/**
* 重置状态
*/
private void reset() {
mCanTranslate = false;
mCanScale = false;
mCanRotate = false;
mLastDistance = 0f;
mMidPoint.set(0f, 0f);
mLastSinglePoint.set(0f, 0f);
mLastDistancePoint.set(0f, 0f);
mDistancePoint.set(0f, 0f);
}
}
下一篇:《Android开发之仿微博贴纸效果实现——进阶篇》
源码下载:
这里附上源码地址(欢迎Star,欢迎Fork):StickerView