源碼地址
實現原理概覽
我們要實現手指控制圖片的平移、旋轉、縮放,首先得知道手指做了什么動作,比如用戶兩指間距離是變大還是變小,兩指是否做了移動,只有獲取到了用戶的手勢才可以根據手勢執行相應了變換,這部分內容下文的多點觸控原理與獲取觸摸事件兩部分會進行介紹。獲取到手勢后我們就可以通過Matrix對圖片進行相應的變換了。
Matrix原理
如果對Matrix不了解可以看這篇文章,深入講解了Matrix的原理及API使用深入理解 Android 中的 Matrix
如果沒有通過Matrix類的API而是直接對其中的3×3矩陣進行操作需要注意下面兩點
使用Matrix需要注意的是縮放操作不僅會影響MatrixValue中MSCALE_X和MSCALE_Y的值,還會影響到MTRANS_X和MTRANS_Y兩個位置的值。
旋轉操作則3×3矩陣的上面兩行6個位置的值都會受到影響
postXXX()為M' = other * M而preXXX()為M' = M * other,需要注意兩者的計算順序不同,結果也不同
多點觸控原理
安卓自定義View進階-多點觸控詳解這篇文章詳細的分析了Android的多點觸控原理
多點觸控需要注意一下幾點
通過getActionMasked()獲取事件類型
各事件的觸發條件
事件 | 簡介 |
---|---|
ACTION_DOWN | 第一個手指初次接觸到屏幕時觸發,后續的手指按下只會觸發ACTION_POINTER_DOWN |
ACTION_MOVE | 手指在屏幕上滑動時觸發,會多次觸發 |
ACTION_UP | 最后一個手指離開屏幕時觸發 |
ACTION_POINTER_DOWN | 有非主要的手指按下(即按下之前已經有手指在屏幕上),如果從始至終只有一個手指則不會觸發此事件 |
ACTION_POINTER_UP | 有非主要的手指抬起(即抬起之后仍然有手指在屏幕上),如果從始至終只有一個手指則不會觸發此事件 |
ACTION_CANCEL | 當我們手指還在屏幕上時,但是卻因為某種原因導致事件流中斷,如鎖屏等,就會觸發該事件,應將該事件作為一個UP事件對待 |
- Index 會變化,pointId 始終不變
自定義可旋轉、平移、縮放的View的基本思路
由于是要自定義一個對圖片進行操作的View,所以既可以使用View作為父類,也可以使用ImageView作為父類,由于ImageView提供了很多圖片處理相關方法,還有各種圖片加載庫支持直接load圖片進入ImageView,也不用自己處理View的測量布局繪制,可以少做很多事情,所以這里選擇繼承ImageView
接著就是判斷當前的手勢是否符合預先設置的旋轉、平移、縮放手勢,此處可以使用ScaleGestureDetector、GestureDetector兩個系統提供的手勢幫助類,也可以選則自己在onTouchEvent()方法中對觸摸事件進行判斷,最初我是使用上面兩個類進行實現的,后來發現有一些坑不好解決,也不像自己判斷手勢那么自由,所以后來選擇了自己對手勢進行處理
獲取到用戶的手勢后即可對圖片進行操作,ImageView有提供方法setImageMatrix()非常方便,所以此處選擇使用Matrix直接對圖片進行操作
接下來是實際代碼
獲取觸摸事件
這邊因為需要在控件的長按事件與點擊事件做一些操作,在onTouch中進行判斷直接返回true的話會導致這兩個事件無法觸發,所以選擇在onTouchEvent()中對觸摸事件進行判斷
方法中主要是判斷當前觸摸的手指數為2的時候可以縮放、旋轉,為1的時候可以平移,手指全部抬起后進行回彈操作,然后對一些成員變量進行維護,不用太關注具體變量什么意思,等下會和其他具體的手勢判斷方法在下文一起進行介紹
此處需要注意具體縮放、平移、旋轉方法中只改變mMatrix的值,而將mMatrix應用到圖片上只在onTouchEvent()或者動畫中進行
onTouchEvent()方法
@Override
public boolean onTouchEvent(MotionEvent event) {
// 獲取所有觸點的中點
PointF midPoint = getMidPointOfFinger(event);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_POINTER_DOWN:
// 每次觸摸事件開始都初始化mLastMidPonit
mLastMidPoint.set(midPoint);
isTransforming = false;
mRevertAnimator.cancel();
// 新手指落下則需要重新判斷是否可以對圖片進行變換
mCanRotate = false;
mCanScale = false;
mCanDrag = false;
if (event.getPointerCount() == 2) {
// 旋轉、平移、縮放分別使用三個判斷變量,避免后期某個操作執行條件改變
mCanScale = true;
mLastPoint1.set(event.getX(0), event.getY(0));
mLastPoint2.set(event.getX(1), event.getY(1));
mCanRotate = true;
mLastVector.set(event.getX(1) - event.getX(0),
event.getY(1) - event.getY(0));
} else if(event.getPointerCount() == 1) {
mCanDrag = true;
}
break;
case MotionEvent.ACTION_MOVE:
if (mCanDrag) translate(midPoint);
if (mCanScale) scale(event);
if (mCanRotate) rotate(event);
// 判斷圖片是否發生了變換
if (!getImageMatrix().equals(mMatrix)) isTransforming = true;
if (mCanDrag || mCanScale || mCanRotate) applyMatrix();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
// 檢測是否需要回彈,該變量可從xml文件中配置,默認開啟
if(mRevert) {
mMatrix.getValues(mFromMatrixValue);/*設置矩陣動畫初始值*/
/* 旋轉和縮放都會影響矩陣,進而影響后續需要使用到ImageRect的地方,
* 所以檢測順序不能改變
*/
checkRotation();
checkScale();
checkBorder();
mMatrix.getValues(mToMatrixValue);/*設置矩陣動畫結束值*/
// 啟動回彈動畫
mRevertAnimator.setMatrixValue(mFromMatrixValue, mToMatrixValue);
mRevertAnimator.cancel();
mRevertAnimator.start();
}
case MotionEvent.ACTION_POINTER_UP:
mCanScale = false;
mCanDrag = false;
mCanRotate = false;
break;
}
// 調用父類的onTouchEvent()方法,方便點擊和長按的調用
super.onTouchEvent(event);
return true;
}
平移、旋轉、縮放三個操作的具體手勢判斷及實現
這部分會介紹如何根據手勢獲取平移的距離、旋轉的角度、縮放的比例,相關成員變量的意義也會在此處進行說明,這邊需要注意的是由于都是調用Matrix的postXXX()方法,所以雖然每次觸摸只平移、旋轉、縮放一點點,但是所有變化會在Matrix中累積。還有一點因為觸摸事件會頻繁觸發,所以一些方法中本可以使用局部變量的卻使用了全局變量,避免頻繁創建對象
平移
直接計算當前觸摸事件所有觸點的中點與上次觸摸事件中點的距離
private void translate(PointF midPoint) {
/* 分別計算在x軸與y軸上需要平移的距離*/
float dx = midPoint.x - mLastMidPoint.x;
float dy = midPoint.y - mLastMidPoint.y;
mMatrix.postTranslate(dx, dy);
// 更新最后一次觸摸事件中點為本次事件中點
mLastMidPoint.set(midPoint);
}
/**
* 計算所有觸點的中點
* @param event 當前觸摸事件
* @return 本次觸摸事件所有觸點的中點
*/
private PointF getMidPointOfFinger(MotionEvent event) {
/* 初始化當前觸摸事件中點mCurrentMidPoint,由于觸摸事件頻繁觸發,所以此處選擇使用一個成員變量,避免頻繁創建對象*/
mCurrentMidPoint.set(0f, 0f);
// 計算當前中點坐標
int pointerCount = event.getPointerCount();
for (int i = 0; i < pointerCount; i++) {
mCurrentMidPoint.x += event.getX(i);
mCurrentMidPoint.y += event.getY(i);
}
mCurrentMidPoint.x /= pointerCount;
mCurrentMidPoint.y /= pointerCount;
return mCurrentMidPoint;
}
縮放
以當前兩指間距離與上次觸摸事件的兩指間距離之比作為圖片的縮放比例
/**
* 獲取圖片的縮放中心,mScaleBy可在外部設置,或通過xml文件設置
* 默認中心點為圖片中心
* @return 圖片的縮放中心點
*/
private PointF getScaleCenter() {
// 使用全局變量避免頻繁創建變量
switch (mScaleBy) {
case SCALE_BY_IMAGE_CENTER:
// mImageRect為保存圖片位置的RectF矩形
scaleCenter.set(mImageRect.centerX(), mImageRect.centerY());
break;
case SCALE_BY_FINGER_MID_POINT:
// mLastMidPoint 為最后一次觸摸事件的中點
scaleCenter.set(mLastMidPoint.x, mLastMidPoint.y);
break;
}
return scaleCenter;
}
private void scale(MotionEvent event) {
// 獲取縮放中心
PointF scaleCenter = getScaleCenter();
// 初始化當前兩指觸點
mCurrentPoint1.set(event.getX(0), event.getY(0));
mCurrentPoint2.set(event.getX(1), event.getY(1));
// 計算縮放比例
float scaleFactor = distance(mCurrentPoint1, mCurrentPoint2)
/ distance(mLastPoint1, mLastPoint2);
/* mScaleFactor中保存了當前圖片總的縮放比例更新當前圖片的縮放比例*/
mScaleFactor *= scaleFactor;
// 更新矩陣的值
mMatrix.postScale(scaleFactor, scaleFactor,
scaleCenter.x, scaleCenter.y);
// 更新最后一次觸摸事件的兩個觸點為當前事件的兩個觸點
mLastPoint1.set(mCurrentPoint1);
mLastPoint2.set(mCurrentPoint2);
}
/**
* 獲取兩點間距離
*/
private float distance(PointF point1, PointF point2) {
float dx = point2.x - point1.x;
float dy = point2.y - point1.y;
return (float) Math.sqrt(dx * dx + dy * dy);
}
旋轉
首先需要保存上一次觸摸事件兩個手指觸點連線所表示的向量,然后獲取當前兩指在屏幕上觸點所表示的向量。通過向量的叉乘公式sinAB = |A×B|/|A|*|B|獲取兩向量夾角,若arcsin(sinAB)為正則為順時針旋轉,否則為逆時針旋轉
之前是使用上面這個方法求旋轉角度的,后來發現有個問題,由于每次ACTINON_MOVE觸發間隔較短,轉過的角度較小所以使用該方法不會出錯,但是若在一次事件內旋轉超過90或者-90度則會導致旋轉錯位。所以更改為使用如下方法
使用Math.atan2(y, x), 可以很方便的求出某個點(x,y)與x軸的夾角,于是我們先求出上次兩指連線所表示的向量與x軸夾角與當前兩指連線所表示的向量與x軸夾角之差,即為當前需要轉過的角度
看下圖應該會比較形象(原諒我捉急的畫技)
private void rotate(MotionEvent event) {
// 計算當前兩指觸點所表示的向量
mCurrentVector.set(event.getX(1) - event.getX(0),
event.getY(1) - event.getY(0));
// 獲取旋轉角度
float degree = getRotateDegree(mLastVector, mCurrentVector);
// 更新矩陣的值
mMatrix.postRotate(degree, mImageRect.centerX(), mImageRect.centerY());
// 設置當前向量為最后一次事件的向量
mLastVector.set(mCurrentVector);
}
/**
* 使用Math#atan2(double y, double x)方法求上次觸摸事件兩指所示向量與x軸的夾角,
* 再求出本次觸摸事件兩指所示向量與x軸夾角,最后求出兩角之差即為圖片需要轉過的角度
*
* @param lastVector 上次觸摸事件兩指間連線所表示的向量
* @param currentVector 本次觸摸事件兩指間連線所表示的向量
* @return 兩向量夾角,單位“度”,順時針旋轉時為正數,逆時針旋轉時返回負數
*/
private float getRotateDegree(PointF lastVector, PointF currentVector) {
//上次觸摸事件向量與x軸夾角
double lastRad = Math.atan2(lastVector.y, lastVector.x);
//當前觸摸事件向量與x軸夾角
double currentRad = Math.atan2(currentVector.y, currentVector.x);
// 兩向量與x軸夾角之差即為需要旋轉的角度
double rad = currentRad - lastRad;
//“弧度”轉“度”
return (float) Math.toDegrees(rad);
}
}
設置回彈及動畫
為了控制圖片保持在某個區域、某個角度,以及控制圖片的縮放大小不能超過我們設置的上限和下限,就需要設置回彈,直接設置超過了某個臨界值就不能操作也能達到目的,但是體驗不好,會有卡頓的感覺,而使用動畫加上回彈可以使操作更加順滑
這邊有個點需要注意的是由于旋轉會改變mMatrix的3×3矩陣上兩行的值,縮放會改變矩陣和平移相關的兩個值,所以在回彈的時候應先檢測角度是否需要回彈,再檢測縮放,最后檢查平移回彈,順序不能亂。
旋轉回彈
此處以讓角度只能為0、90、180、270四個值為例,介紹一下如何判斷是否需要對角度進行回彈,及如何回彈
此處還有個坑,原本我是使用一個float類型的全局變量mDegree保存當前圖片已轉過的角度,但是回彈的時候會因為計算過程中浮點數的精度損失而導致圖片不能完全轉回去,和控件之間還是有一點微小的夾角,于是之后改為即時計算當前圖片轉過的角度的方式就不會再出現這個問題了
/**
* 根據當前圖片旋轉的角度,判斷是否回彈
*/
private void checkRotation() {
// 獲取當前圖片已經轉過的角度
float currentDegree = getCurrentRotateDegree();
float degree = currentDegree;
// 根據當前圖片旋轉的角度值所在區間,判斷要轉到幾度
// 取絕對值可以使(-180, 0]與(0,180]兩個區間的角度統一判斷
degree = Math.abs(degree);
if (degree > 45 && degree <= 135) {
degree = 90;
} else if(degree > 135 && degree <= 225) {
degree = 180;
} else if(degree > 225 && degree <= 315) {
degree = 270;
} else {
degree = 0;
}
// 判斷順時針還是逆時針旋轉
degree = currentDegree < 0 ? -degree : degree;
// 更新矩陣的值
mMatrix.postRotate(degree - currentDegree, mImageRect.centerX(), mImageRect.centerY());
}
private float[] xAxis = new float[]{1f, 0f}; // 表示與x軸同方向的向量
/**
* 獲取當前圖片旋轉角度
* @return 圖片當前的旋轉角度
*/
private float getCurrentRotateDegree() {
// 每次重置初始向量的值為與x軸同向
xAxis[0] = 1f;
xAxis[1] = 0f;
// 初始向量通過矩陣變換后的向量
mMatrix.mapVectors(xAxis);
// 變換后向量與x軸夾角即為當前圖片已經轉過的角度
double rad = Math.atan2(xAxis[1], xAxis[0]);
return (float) Math.toDegrees(rad);
}
縮放回彈
首先需要判斷圖片當前是否與初始時的時候角度相同(或者轉過了180度,將這個位置定義為水平),或者當前的旋轉角度為90或者-90度(定義此時為垂直狀態),
根據判斷結果決定使用水平時的最小縮放比例或者垂直時的最小縮放比例
接著判斷當前圖片的縮放比例是否小于最小縮放比例,或者大于最大縮放比例,若超過了則進行回彈
最大縮放比例可通過外部設置,最小縮放比例即為適應控件大小時的縮放比例
/**
* 檢查圖片縮放比例是否超過設置的大小
*/
private void checkScale() {
// 獲取縮放中心
PointF scaleCenter = getScaleCenter();
// 默認不進行回彈
float scaleFactor = 1.0f;
// 獲取圖片當前是水平還是垂直
int imgOrientation = imgOrientation();
// 超過設置的上限或下限則回彈到設置的最大或最小值
// 除以當前圖片縮放比例mScaleFactor,postScale()方法執行后的圖片的縮放比例即為被除數大小
if (imgOrientation == HORIZONTAL
&& mScaleFactor < mHorizontalMinScaleFactor) {
scaleFactor = mHorizontalMinScaleFactor / mScaleFactor;
} else if (imgOrientation == VERTICAL
&& mScaleFactor < mVerticalMinScaleFactor) {
scaleFactor = mVerticalMinScaleFactor / mScaleFactor;
}else if(mScaleFactor > mMaxScaleFactor) {
scaleFactor = mMaxScaleFactor / mScaleFactor;
}
// 更新矩陣的值
mMatrix.postScale(scaleFactor, scaleFactor, scaleCenter.x, scaleCenter.y);
// 更新圖片當前的縮放比例
mScaleFactor *= scaleFactor;
}
private static final int HORIZONTAL = 0;
private static final int VERTICAL = 1;
/**
* 判斷圖片當前是水平還是垂直
* @return 水平則返回 {@code HORIZONTAL},垂直則返回 {@code VERTICAL}
*/
private int imgOrientation() {
// 獲取圖片當前的旋轉角度的絕對值
float degree = Math.abs(getCurrentRotateDegree());
int orientation = HORIZONTAL;
// 當前圖片旋轉角度在[-135, -45)或(45, 135]之間即為垂直狀態
if (degree > 45f && degree <= 135f) {
orientation = VERTICAL;
}
return orientation;
}
平移回彈
圖片寬或高小于控件時回彈到控件中心,大于控件時則與控件之間不能有空隙,不符合條件則進行回彈
此處需要注意mImageRect所使用的坐標系與View的坐標系不一致
/**
* 將圖片移回控件中心
*/
private void checkBorder() {
// 由于旋轉回彈與縮放回彈會影響圖片所在位置,所以此處需要更新ImageRect的值
refreshImageRect();
// 默認不移動
float dx = 0f;
float dy = 0f;
// mImageRect中的坐標值為相對View的值
// 圖片寬大于控件時圖片與控件之間不能有白邊
if (mImageRect.width() > getWidth()) {
if (mImageRect.left > 0) {/*判斷圖片左邊界與控件之間是否有空隙*/
dx = -mImageRect.left;
} else if(mImageRect.right < getWidth()) {/*判斷圖片右邊界與控件之間是否有空隙*/
dx = getWidth() - mImageRect.right;
}
} else {/*寬小于控件則移動到中心*/
dx = getWidth() / 2 - mImageRect.centerX();
}
// 圖片高大于控件時圖片與控件之間不能有白邊
if (mImageRect.height() > getHeight()) {
if (mImageRect.top > 0) {/*判斷圖片上邊界與控件之間是否有空隙*/
dy = -mImageRect.top;
} else if(mImageRect.bottom < getHeight()) {/*判斷圖片下邊界與控件之間是否有空隙*/
dy = getHeight() - mImageRect.bottom;
}
} else {/*高小于控件則移動到中心*/
dy = getHeight() / 2 - mImageRect.centerY();
}
mMatrix.postTranslate(dx, dy);
}
動畫技巧
一開始一直想著直接通過矩陣的postXXX()或者preXXX()或者直接把MatrixValue拿出來設置,弄的很復雜。后來參考了PinchImageView,其實只需要設置好初始狀態的矩陣和最終狀態的矩陣,就可以根據動畫當前執行的進度對矩陣的值進行更新,簡單粗暴
@Override
public void onAnimationUpdate(ValueAnimator animation) {
/*mFromMatrixValue為初始矩陣, mToMatrixValue為最終矩陣,mInterpolateMatrixValue為保存動畫過程中中間值的矩陣*/
if (mFromMatrixValue != null
&& mToMatrixValue != null
&& mInterpolateMatrixValue != null) {
// 根據動畫當前進度設置矩陣的值
for (int i = 0; i < 9; i++) {
float animatedValue = (float) animation.getAnimatedValue();
mInterpolateMatrixValue[i] = mFromMatrixValue[i]
+ (mToMatrixValue[i] - mFromMatrixValue[i]) * animatedValue;
}
mMatrix.setValues(mInterpolateMatrixValue);
applyMatrix();
}
}
一些輔助方法
/**
* 更新圖片所在區域,并將矩陣應用到圖片
*/
protected void applyMatrix() {
refreshImageRect(); /*將矩陣映射到ImageRect*/
setImageMatrix(mMatrix);
}
/**
* 圖片使用矩陣變換后,刷新圖片所對應的mImageRect所指示的區域
*/
private void refreshImageRect() {
if (getDrawable() != null) {
mImageRect.set(getDrawable().getBounds());
mMatrix.mapRect(mImageRect, mImageRect);
}
}
總結
自定義可平移、旋轉、縮放的ImageView,整個過程不會特別復雜,主要是對手勢的識別,計算旋轉角度、平移距離、縮放比例三個值,還有對矩陣的操作,過程中要注意矩陣三個操作執行后內部3×3矩陣的變化,及三個操作如何互相影響,需要注意先后順序,最后將矩陣應用到圖片上就可以對圖片進行變換了
一些問題
旋轉的時候圖片邊緣會出現鋸齒,需要關閉硬件加速并加上如下代碼
private PaintFlagsDrawFilter mDrawFilter =
new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
@Override
protected void onDraw(Canvas canvas) {
canvas.setDrawFilter(mDrawFilter);
super.onDraw(canvas);
}