上周有点事情,所以拖到今天发。
这篇blog是学习 传送门的贴纸效果。
Github在这里:
Demo地址
先上个效果图:
是一个可以做平移、缩放、旋转、删除的一个简单的贴纸效果。
上周因为学习了关于矩阵的映射,所以在这个View里面就会做得相对轻松一些。
这周在运用到矩阵的基础上,也学会了去计算双指同时滑动时产生的向量。
向量与旋转
其实做这个View比较核心的地方就是 平移、缩放和旋转了。
旋转因为之前没有触及到,所以这次学习通过 矩阵Matrix来进行旋转。
而旋转的参考系,就是 两个手指触摸点的连线 与 x轴的夹角为α,通过α的变化去改变图片的旋转系数。
首先我们要创建两个向量,分别是:上次双指之间的向量,和当前双指之间的向量:
//以PointF的形式来记录向量,其实就是触摸的点的 (x1,y1)和(x2,y2)的差值,向量是需要他们进行计算之后才能得出来的
//记录双指之间的向量
private PointF mCurrentVector = new PointF();
//记录上次双指的向量
private PointF mLastVector = new PointF();
接着就是在获取到MotionEvent的时候,对双指的操作进行数据的处理
public void onTouch(MotionEvent event) {
switch (event.getActionMasked()) {
....
case MotionEvent.ACTION_POINTER_DOWN:
//通过getPointCount()来判断几个手指触摸
if (event.getPointerCount() == 2) {
//双指来记录两个point
mFirstPoint.set(event.getX(0), event.getY(0));
mSecondPoint.set(event.getX(1), event.getY(1));
//记录双指间的向量
mLastVector.set(mFirstPoint.x - mSecondPoint.x, mFirstPoint.y - mSecondPoint.y);
}
break;
case MotionEvent.ACTION_MOVE:
if (event.getPointerCount() == 2) {
//记录双指点的位置
mFirstPoint.set(event.getX(0), event.getY(0));
mSecondPoint.set(event.getX(1), event.getY(1));
//操作旋转
mCurrentVector.set(mFirstPoint.x - mSecondPoint.x, mFirstPoint.y - mSecondPoint.y);
float rotate = calculateDegrees(mLastVector, mCurrentVector);
rotate(rotate);
mLastVector.set(mCurrentVector.x, mCurrentVector.y);
}
break;
}
}
然后我们将 之前的向量和当前的向量,通过对比可以知道 旋转角度的偏移量:
/**
* 计算旋转度数,通过tan2()和toDegree()来计算出旋转的角度
*/
private float calculateDegrees(PointF lastVector, PointF currentVector) {
float lastDegrees = (float) Math.atan2(lastVector.y, lastVector.x);
float currentDegrees = (float) Math.atan2(currentVector.y, currentVector.x);
return (float) Math.toDegrees(currentDegrees - lastDegrees);
}
最后通过rotate()来旋转矩阵,其实就是通过View的 矩阵的 postRotate()
方法来旋转一定的度数:
/**
* 旋转操作
* 使用matrix去做偏移
* 完成偏移后还要更新 points坐标
*/
void rotate(float degrees) {
//以中心为轴旋转
mMatrix.postRotate(degrees, mCenterPoint.x, mCenterPoint.y);
updatePoints();
}
旋转完之后,matrix发生了变化,我们要映射其四周的点,便于之后做焦点图片时,会用框 框柱它:
private void updatePoints() {
//更新贴纸坐标,srcPoints是最初时的包裹View的点坐标,dstPoints是matrix变换之后的点坐标
mMatrix.mapPoints(dstPoints, srcPoints);
}
通过这样我们就能完成旋转了。
贴纸类
在本demo中,并没有把贴纸类做成一个“View”,而是单纯做成一个类,它是通过 矩阵 去实现View的变化,它有旋转、平移、缩放的函数。
那么它的绘制是在哪里呢,它的点击事件又是怎么触发的呢?
我们把容纳贴纸的 ViewGroup做成一个View,通过给这个View加“贴纸类”,然后让这个View去绘制出每一个贴纸,并且接受每一个点击事件,通过点击事件去控制 单个“贴纸的行为”,这样,就不用考虑滑动冲突的问题了。onTouchEvent直接传true就是了。
我们来看看贴纸类:
//没有任何接触、与操作的模式
public static final int MODE_NONE = 0;
//单指按下的时的状态,并且可以移动
public static final int MODE_SINGLE = 1;
//双指按下的状态,可以缩放大小
public static final int MODE_POINT = 2;
//设置一个内边距值
private static final int PADDING = 20;
//贴纸图像
private Bitmap mBitmap;
//删除图标图像
private Bitmap mDelBitmap;
//贴纸边界
private RectF mBitmapBound;
//删除图标边界
private RectF mDelBitmapBound;
//图像矩阵
private Matrix mMatrix;
//该贴纸是否获得焦点
private boolean isFocus;
//bitmap的中心点
private PointF mCenterPoint;
//上次双指移动的距离
private float mLastDoubleDistance;
//上次触摸的点
private PointF mLastPoint = new PointF();
//双指触控下 当前触摸的点1
private PointF mFirstPoint = new PointF();
//双指触控下 当前触摸的点2
private PointF mSecondPoint = new PointF();
//记录矩阵的点坐标,矩阵变换后也要变更
private float[] srcPoints;
//因为矩阵变化后 原坐标也会变化,无法变回原来的,所以需要记录一个目标的矩阵
private float[] dstPoints;
//记录双指之间的向量
private PointF mCurrentVector = new PointF();
//记录上次双指的向量
private PointF mLastVector = new PointF();
//当前模式
private int mMode;
构造时,需要确定 这个贴纸的 边框,即srcPoints,它便于我们画边框,并且要构造删除图标:
public RikkaStickerView(Context context, Bitmap bitmap) {
mMatrix = new Matrix();
mCenterPoint = new PointF();
this.mBitmap = bitmap;
mBitmapBound = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight());
srcPoints = new float[]{
//左上
0, 0,
//右上
bitmap.getWidth(), 0,
//左下
0, bitmap.getHeight(),
//右下
bitmap.getWidth(), bitmap.getHeight(),
//中点
bitmap.getWidth() / 2f, bitmap.getHeight() / 2f
};
dstPoints = srcPoints.clone();
//创建删除图标并定义边界,加上padding
mDelBitmap = BitmapFactory.decodeResource(context.getResources(), R.mipmap.icon_delete);
mDelBitmapBound = new RectF(0 - mDelBitmap.getWidth() / 2 - PADDING, 0 - mDelBitmap.getHeight() / 2 - PADDING,
mDelBitmap.getWidth() / 2f + PADDING, mDelBitmap.getHeight() / 2f + PADDING);
//将贴纸默认放在屏幕中间
WindowManager manager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics displayMetrics = new DisplayMetrics();
manager.getDefaultDisplay().getMetrics(displayMetrics);
float dx = displayMetrics.widthPixels / 2f - mBitmap.getWidth() / 2f;
float dy = displayMetrics.heightPixels / 2f - mBitmap.getHeight() / 2f;
translate(dx, dy);
}
接下来是三个操作:
/**
* 平移操作,偏移量为dx,dy
* 使用matrix去做偏移
* 完成偏移后还要更新 points坐标
*/
void translate(float dx, float dy) {
mMatrix.postTranslate(dx, dy);
updatePoints();
}
/**
* 缩放操作,跟平移操作一样
*/
void scale(float scale) {
//以View的中点为轴放大
mMatrix.postScale(scale, scale, mCenterPoint.x, mCenterPoint.y);
updatePoints();
}
/**
* 旋转操作
*/
void rotate(float degrees) {
//以中心为轴旋转
mMatrix.postRotate(degrees, mCenterPoint.x, mCenterPoint.y);
updatePoints();
}
private void updatePoints() {
//更新贴纸坐标
mMatrix.mapPoints(dstPoints, srcPoints);
// 更新贴纸中心坐标
mCenterPoint.set(dstPoints[8], dstPoints[9]);
}
接下来就是绘制,它自己本身当然没有绘制函数,也没有画布,所以这里的onDraw方法是由父View来调用的,Canvas也是父View传过来的:
/**
* 绘制贴纸本身,这个方法需要父View去调用
* Canvas 是父View的canvas,paint也是
*/
public void onDraw(Canvas canvas, Paint paint) {
//绘制贴纸,带上matrix参数
canvas.drawBitmap(mBitmap, mMatrix, paint);
//如果该贴纸是被选中的目标,则要绘制其边框,以及移除按钮
if (isFocus) {
//画点要用points画,因为图片会变化,所以必须要有记录号的点,所以points在这里就派上用场了
canvas.drawLine(dstPoints[0] - PADDING, dstPoints[1] - PADDING, dstPoints[2] + PADDING, dstPoints[3] - PADDING, paint);
canvas.drawLine(dstPoints[2] + PADDING, dstPoints[3] - PADDING, dstPoints[6] + PADDING, dstPoints[7] + PADDING, paint);
canvas.drawLine(dstPoints[6] + PADDING, dstPoints[7] + PADDING, dstPoints[4] - PADDING, dstPoints[5] + PADDING, paint);
canvas.drawLine(dstPoints[4] - PADDING, dstPoints[5] + PADDING, dstPoints[0] - PADDING, dstPoints[1] - PADDING, paint);
//绘制删除按钮
canvas.drawBitmap(mDelBitmap, dstPoints[0] - mDelBitmap.getWidth() / 2f - PADDING, dstPoints[1] - mDelBitmap.getHeight() / 2f - PADDING, paint);
}
}
接下来是onTouchEvent方法,当然本身他本身也是不会调用的,所以TouchEvent是由父View传下来的:
/**
* 自己定义onTouch方法,根据父View传的event来做各种操作
* 既然走到这个方法了,那么就说明 已经触摸到贴纸了
*/
public void onTouch(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mMode = MODE_SINGLE;
//记录按下的位置
mLastPoint.set(event.getX(), event.getY());
break;
case MotionEvent.ACTION_POINTER_DOWN:
if (event.getPointerCount() == 2) {
mMode = MODE_POINT;
//双指来记录两个point
mFirstPoint.set(event.getX(0), event.getY(0));
mSecondPoint.set(event.getX(1), event.getY(1));
//计算双指之间的距离
mLastDoubleDistance = calculateDistance(mFirstPoint, mSecondPoint);
//记录双指间的向量
mLastVector.set(mFirstPoint.x - mSecondPoint.x, mFirstPoint.y - mSecondPoint.y);
}
break;
case MotionEvent.ACTION_MOVE:
//通过模式来确定行为
if (mMode == MODE_SINGLE) {
//如果是单指拖动,则移动到指定的位置
translate(event.getX() - mLastPoint.x, event.getY() - mLastPoint.y);
mLastPoint.set(event.getX(), event.getY());
}
if (mMode == MODE_POINT && event.getPointerCount() == 2) {
//记录双指点的位置
mFirstPoint.set(event.getX(0), event.getY(0));
mSecondPoint.set(event.getX(1), event.getY(1));
//操作自由缩放
float distance = calculateDistance(mFirstPoint, mSecondPoint);
//根据双指移动的距离获取缩放系数
float scale = distance / mLastDoubleDistance;
scale(scale);
mLastDoubleDistance = distance;
//操作旋转
mCurrentVector.set(mFirstPoint.x - mSecondPoint.x, mFirstPoint.y - mSecondPoint.y);
float rotate = calculateDegrees(mLastVector, mCurrentVector);
rotate(rotate);
mLastVector.set(mCurrentVector.x, mCurrentVector.y);
}
break;
case MotionEvent.ACTION_UP:
reset();
break;
}
}
/**
* 通过两个坐标来计算他们的距离
*/
private float calculateDistance(PointF mFirstPoint, PointF mSecondPoint) {
float x = mFirstPoint.x - mSecondPoint.x;
float y = mFirstPoint.y - mSecondPoint.y;
return (float) Math.sqrt(x * x + y * y);
}
贴纸容器View
我们需要一个容器去放这些贴纸,所以容器必须继承自View或者ViewGroup,我这里就继承自View。
它最重要的就是做两件事情:
- 绘制贴纸
- 给贴纸传递点击事件
方法如下:
/**
* 点击事件,处理单指移动,双指缩放
* 单指单击删除
*/
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_POINTER_DOWN:
//先判断是否是点到删除按钮了
stickerView = RikkaStickerManager.getInstance().getDelButton(event.getX(), event.getY());
if (stickerView != null) {
removeSticker(stickerView);
return true;
}
//再判断是否摸到某一个贴纸
stickerView = RikkaStickerManager.getInstance().getSticker(event.getX(), event.getY());
if (stickerView == null) {
//当不是单指的时候,可能会存在第二个手指摸到了贴纸(先按下两个,抬起一个的情况)
if (event.getPointerCount() == 2) {
stickerView = RikkaStickerManager.getInstance().getSticker(event.getX(1), event.getY(1));
}
}
if (stickerView != null) {
RikkaStickerManager.getInstance().setFocusSticker(stickerView);
}
break;
default:
break;
}
if (stickerView != null) {
stickerView.onTouch(event);
} else {
//如果没有点击到,则取消所有贴纸的焦点
RikkaStickerManager.getInstance().clearAllFocus();
}
invalidate();
return true;
}
/**
* 绘制所有的子贴纸,并且根据是否被选中来画
*/
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
List<RikkaStickerView> stickerViews = RikkaStickerManager.getInstance().getmStickerList();
for (int i = 0; i < stickerViews.size(); i++) {
RikkaStickerView stickerView = stickerViews.get(i);
stickerView.onDraw(canvas, mPaint);
}
}
贴纸管理类
看到上面的代码,还有一个专门用来管理 贴纸的Manager单例类,
它其实也很简单,就是维护一个 贴纸列表,每次传入一个坐标,就判断这个坐标在不在 这些贴纸里面。
而这个方法,在上周的 坐标映射里面就已经学会了:
/**
* 贴纸管理类,使用单例模式
* 对每个“贴纸”进行保存、增加、删除
*/
public class RikkaStickerManager {
private static final String TAG = "RikkaStickerManager";
public static RikkaStickerManager instance;
//贴纸List,统一进行管理
private List<RikkaStickerView> mStickerList = new ArrayList<>();
public static RikkaStickerManager getInstance() {
if (instance == null) {
synchronized (RikkaStickerManager.class) {
if (instance == null) {
instance = new RikkaStickerManager();
}
}
}
return instance;
}
/**
* 添加贴纸,就是往List里面添加
*/
void addSticker(RikkaStickerView stickerView) {
mStickerList.add(stickerView);
}
/**
* 移除指定的贴纸
*/
void removeSticker(RikkaStickerView stickerView) {
Bitmap bitmap = stickerView.getmBitmap();
if (bitmap != null && !bitmap.isRecycled()) {
//即使回收
bitmap.recycle();
}
mStickerList.remove(stickerView);
}
/**
* 移除所有贴纸
*/
void removeAllSticker() {
for (int i = 0; i < mStickerList.size(); i++) {
Bitmap bitmap = mStickerList.get(i).getmBitmap();
if (bitmap != null && !bitmap.isRecycled()) {
bitmap.recycle();
}
}
mStickerList.clear();
}
/**
* 设置当前贴纸为选中(焦点)贴纸
*/
void setFocusSticker(RikkaStickerView focusSticker) {
for (int i = 0; i < mStickerList.size(); i++) {
RikkaStickerView sticker = mStickerList.get(i);
if (sticker == focusSticker) {
sticker.setFocus(true);
} else {
sticker.setFocus(false);
}
}
}
/**
* 全部设为没有焦点
*/
void clearAllFocus() {
for (int i = 0; i < mStickerList.size(); i++) {
RikkaStickerView stickerView = mStickerList.get(i);
stickerView.setFocus(false);
}
}
/**
* 根据触摸的点来返回触摸的贴纸
*/
RikkaStickerView getSticker(float x, float y) {
for (int i = mStickerList.size() - 1; i >= 0; i--) {
RikkaStickerView sticker = mStickerList.get(i);
//因为points 映射之后都会改变所以必须每次都重置
float[] points = new float[]{x, y};
//根据invert来做转换
Matrix matrix = new Matrix();
sticker.getmMatrix().invert(matrix);
matrix.mapPoints(points);
//根据边界来判断 点是否在该View中
if (sticker.getmBitmapBound().contains(points[0], points[1])) {
return sticker;
}
}
return null;
}
/**
* 根据触摸的点来判断是否点击到删除按钮,如果点到就会删除
*/
RikkaStickerView getDelButton(float x, float y) {
for (int i = mStickerList.size() - 1; i >= 0; i--) {
RikkaStickerView sticker = mStickerList.get(i);
float[] points = new float[]{x, y};
//根据invert来做转换
Matrix matrix = new Matrix();
sticker.getmMatrix().invert(matrix);
matrix.mapPoints(points);
//根据边界来判断 点是否在该View中
if (sticker.getmDelBitmapBound().contains(points[0], points[1])) {
return sticker;
}
}
return null;
}
public List<RikkaStickerView> getmStickerList() {
return mStickerList;
}
}
Ok,到这里小轮子就做完了,因为上周的拖到了这周了,所以这周还要再多学一个。
不过反正AndroidBus和玩Android的轮子那么多,也不缺着学习哈哈哈哈。