之前寫過一篇關于圖像變換處理的文章《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