先來看兩張效果圖:


哈哈,就是這樣了。效果差了一些,感興趣的小伙伴們可以運行代碼感受絲滑與彈性。前段時間在競品小紅書上看到了這樣的效果:圖片可以跟隨手指移動,雙指可以(無限)放大,縮小,還可以擠壓,手指抬起后還有一個有趣的效果,圖片回彈。。。一直想擼一個手勢的控件,正好可以模仿小紅書圖片裁剪控件,話不多說,擼起袖子就是干。
本系列共有兩篇,在第二篇會重點講解與RecyclerView的聯(lián)動效果,先放一張效果圖,感興趣的小伙伴們繼續(xù)關(guān)注哦:

初步分析
先來看看小紅書的樣子:



emmmm,從效果上來看呢,其實也只是基本的Translation和Scale組合而已,難點在于縮小態(tài)下的阻尼計算,左下角那個按鈕用來控制留白,填充等狀態(tài)的切換(好像小紅書還有bug,狀態(tài)切換會導(dǎo)致圖片位置不正確,哈哈哈),接下來我們就一步步分析,從而打造出屬于我們的自己的效果。
仔細(xì)觀察,有沒有發(fā)現(xiàn):
- 單指滑動,圖片跟隨手指移動,當(dāng)手指滑動到圖片邊緣繼續(xù)沿同一方向滑動,會出現(xiàn)阻尼效果,滑動的距離越大,阻尼越大,手指抬起后,圖片回彈到控件邊緣;
- 雙指觸摸分兩種情況,一種是雙指向內(nèi)擠壓,圖片縮??;另一種是雙指向外擴(kuò)散,圖片放大;
- 當(dāng)雙指向外擴(kuò)散達(dá)到一定的臨界值,手指抬起后,圖片縮小到臨界值狀態(tài);
- 手指觸摸且有一定的滑動值,會顯示線條九宮格,且線條跟隨圖片的大小動態(tài)改變,始終分割圖片為9等分,如果手指觸摸停止,線條消失,再次滑動,線條則再次出現(xiàn);
那么圖片縮放時,需要一個縮放中心點,也就是PivotX和PivotY,這個點默認(rèn)情況下在View的中心。但很明顯,它這個就不是在中心了,至于在哪里,先看下這張圖:

可以看到,圖片始終是以雙指的中點在縮放,那么縮放中心點就是雙指連線的中點位置上了。又怎么獲取到雙指的中點坐標(biāo)呢?這里涉及到了Android提供的兩個幫助類:GestureDetector、ScaleGestureDetector。接下來讓我們先來了解下這兩個類,揭開它的神秘面紗。神秘?你個糟老頭,壞得很,信你個鬼。。。
手勢幫助類
什么是手勢幫助類?Android手機屏幕上,當(dāng)我們觸摸屏幕的時候,會產(chǎn)生許多手勢事件,如down,up,scroll,filing等等。我們可以在onTouchEvent()方法里面完成各種手勢識別。但是,我們自己去識別各種手勢就比較麻煩了,而且有些情況可能考慮的不是那么的全面。所以,為了方便我們的使用Android就提供了GestureDetector幫助類,先來看看他的構(gòu)造方法:
public GestureDetector(Context context, OnGestureListener listener, Handler handler,
boolean unused) {
}
context表示上下文,listener表示手勢的監(jiān)聽回調(diào),handler可以指定線程(UI線程、非UI線程),unused未被使用的參數(shù)。如果我們的手勢不需要在子線程中處理,我們一般只關(guān)心前兩個參數(shù),context是上下文這個簡單,重點看下listener參數(shù):
GestureDetector給我們提供了三個接口類與一個外部類:
OnGestureListener:接口,用來監(jiān)聽手勢事件(6種);
OnDoubleTapListener:接口,用來監(jiān)聽雙擊事件;
OnContextClickListener:接口,外接設(shè)備,比如外接鼠標(biāo)產(chǎn)生的事件(本文中我們不考慮);
SimpleOnGestureListener:外部類,SimpleOnGestureListener其實是上面三個接口中所有函數(shù)的集成,它包含了這三個接口里所有必須要實現(xiàn)的函數(shù)而且都已經(jīng)重寫,但所有方法體都是空的。需要自己根據(jù)情況去重寫;
OnGestureListener接口方法:
public interface OnGestureListener {
/**
* 按下。返回值表示事件是否處理
*/
boolean onDown(MotionEvent e);
/**
* 短按(手指尚未松開也沒有達(dá)到scroll條件)
*/
void onShowPress(MotionEvent e);
/**
* 輕觸(手指松開)
*/
boolean onSingleTapUp(MotionEvent e);
/**
* 滑動(一次完整的事件可能會多次觸發(fā)該函數(shù))。返回值表示事件是否處理
*/
boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);
/**
* 長按(手指尚未松開也沒有達(dá)到scroll條件)
*/
void onLongPress(MotionEvent e);
/**
* 滑屏(用戶按下觸摸屏、快速滑動后松開,返回值表示事件是否處理)
*/
boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
}
OnDoubleTapListener接口方法:
public interface OnDoubleTapListener {
/**
* 單擊事件(onSingleTapConfirmed,onDoubleTap是兩個互斥的函數(shù))
*/
boolean onSingleTapConfirmed(MotionEvent e);
/**
* 雙擊事件
*/
boolean onDoubleTap(MotionEvent e);
/**
* 雙擊事件產(chǎn)生之后手指還沒有抬起的時候的后續(xù)事件
*/
boolean onDoubleTapEvent(MotionEvent e);
}
GestureDetector的使用:
定義GestureDetector類;
將touch事件交給GestureDetector(onTouchEvent函數(shù)里面調(diào)用GestureDetector的onTouchEvent函數(shù));
處理SimpleOnGestureListener或者OnGestureListener、OnDoubleTapListener、OnContextClickListener三者之一的回調(diào);
GestureDetector使用流程如下(有關(guān)例子會在后文中講到):
public GestureView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public GestureView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 第一步
mGestureDetector = new GestureDetector(context, mOnGestureListener);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// 第三步
return mGestureDetector.onTouchEvent(event);
}
// 第二步
GestureDetector.OnGestureListener mOnGestureListener = new GestureDetector.OnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
return false;
}
這里就不再深入GestureDetector源碼講解,有感興趣的小伙伴可以自行查閱資料,接著了解ScaleGestureDetector縮放手勢類,用法與GestureDetector類似,都是通過onTouchEvent()關(guān)聯(lián)相應(yīng)的MotionEvent事件。
ScaleGestureDetector類給提供了OnScaleGestureListener接口,來告訴我們縮放的過程中的一些回調(diào):
public interface OnScaleGestureListener {
/**
* 縮放進(jìn)行中,返回值表示是否下次縮放需要重置,如果返回ture,那么detector就會重置縮放事件,如果返回false,detector會在之前的縮放上繼續(xù)進(jìn)行計算
*/
public boolean onScale(ScaleGestureDetector detector);
/**
* 縮放開始,返回值表示是否受理后續(xù)的縮放事件
*/
public boolean onScaleBegin(ScaleGestureDetector detector);
/**
* 縮放結(jié)束
*/
public void onScaleEnd(ScaleGestureDetector detector);
}
ScaleGestureDetector類常用函數(shù)介紹,因為在縮放的過程中,要通過ScaleGestureDetector來獲取一些縮放信息:
/**
* 縮放是否正處在進(jìn)行中
*/
public boolean isInProgress();
/**
* 返回組成縮放手勢(兩個手指)中點x的位置
*/
public float getFocusX();
/**
* 返回組成縮放手勢(兩個手指)中點y的位置
*/
public float getFocusY();
/**
* 組成縮放手勢的兩個觸點的跨度(兩個觸點間的距離)
*/
public float getCurrentSpan();
/**
* 同上,x的距離
*/
public float getCurrentSpanX();
/**
* 同上,y的距離
*/
public float getCurrentSpanY();
/**
* 組成縮放手勢的兩個觸點的前一次縮放的跨度(兩個觸點間的距離)
*/
public float getPreviousSpan();
/**
* 同上,x的距離
*/
public float getPreviousSpanX();
/**
* 同上,y的距離
*/
public float getPreviousSpanY();
/**
* 獲取本次縮放事件的縮放因子,縮放事件以onScale()返回值為基準(zhǔn),一旦該方法返回true,代表本次事件結(jié)束,重新開啟下次縮放事件。
*/
public float getScaleFactor();
/**
* 返回上次縮放事件結(jié)束時到當(dāng)前的時間間隔
*/
public long getTimeDelta();
/**
* 獲取當(dāng)前motion事件的時間
*/
public long getEventTime();
ScaleGestureDetector使用方式與GestureDetector類似,這里就不再重復(fù)講解,了解了相關(guān)手勢類,接下來開始代碼構(gòu)思。
構(gòu)思代碼
想一想,圖片有任意尺寸,怎樣才能讓圖片鋪滿控件,那么就需要對圖片進(jìn)行縮放,平移。還有一點是必須考慮的,在加載高分辨率的圖片非常消耗內(nèi)存,在低內(nèi)存的手機上很容易造成OOM,那么針對高分辨率的圖片就必須壓縮。還有一種情況是來回切換相同的兩張圖片,如果每次都加載本地圖片,既消耗內(nèi)存速度還很慢,這時候緩存就很有必要了,第一次加載本地圖片,再次切回到該圖片加載緩存圖片。
顯示圖片,一般有兩種方式,一種是Android提供了ImageView控件來顯示圖片;另一種直接在onDraw()方法里調(diào)用canvas.drawBitmap()方法,通過調(diào)研小紅書顯示方案,發(fā)現(xiàn)他采用了第二種:
(__) 嘻嘻……那我們就用第一種顯示圖片的方式,繼承ImageView來顯示圖片。
通過觀察小紅書,我們會發(fā)現(xiàn):
圖片顯示區(qū)域為寬高相等的矩形,那么在測量onMeasure的時候需要保證寬高一致,左下角小按鈕的狀態(tài)切換先不考慮,后面會重點講解。
圖片默認(rèn)會充滿整個控件并居中對齊,那么怎么保證圖片充滿控件,最常規(guī)的做法就是:取控件的寬高與圖片的寬高比的最大值縮放
Math.max(控件寬度/圖片寬度,控件高度/圖片高度);同理,取控件寬高與圖片寬高的偏移量的一半來平移圖片保證居中對齊。在2的基礎(chǔ)上,非寬高相等的圖片有一部分會顯示在控件區(qū)域之外,可以通過手指滑動來顯示,相信大家都用過PhotoView,效果一致。 移動圖片與移動控件的原理一樣,都是改變setTranslation的值,不過這里用到了圖片矩陣,通過改變Matrix.postTranslate(dx, dy)的值來移動圖片。
-
移動圖片,那就不得不考慮越界問題,請觀察下圖,這里以上邊界為例(左,右,下邊界同理)。注意:這里的越界指的不是數(shù)組越界,而是圖片滑動到邊緣繼續(xù)沿相同方向滑動,圖片未鋪滿控件區(qū)域。 在下圖中你會發(fā)現(xiàn):圖片跟隨手指繼續(xù)滑動,手指滑動的距離越大阻尼越大,手指抬起后圖片會回彈到控件頂部。
在這里插入圖片描述 雙指擠壓圖片縮小,擴(kuò)散圖片放大,縮放中心點是雙指中點坐標(biāo),那么縮放比例怎么計算呢?最開始取的
縮放因子ScaleGestureDetector.getScaleFactor(),出來的效果真的天馬行空(輕微擠壓擴(kuò)散圖片無限放大縮小),接著給縮放因子加一個比例,效果依舊不行,哦豁。沒辦法,打印縮放數(shù)據(jù),觀察數(shù)據(jù),尋找規(guī)律。幾經(jīng)嘗試最后取了縮放因子的偏移量。為了寫好控件,沒什么捷徑,只能多觀察,多嘗試。在縮小至越界的狀態(tài)下,手指抬起,圖片放大到充滿控件;在放大到一定的閾值后放手后,圖片回彈到一定的縮放比例。前文提到了在縮小至越界狀態(tài)下單指滑動圖片,根據(jù)四周滑動的距離,會出現(xiàn)阻尼效果,在后文會講解阻尼算法。圖片在滑動或縮放態(tài)下,會出現(xiàn)九宮格白色線條,線條始終平分控件內(nèi)的圖片為九等分,滑動或縮放停止線條消失,再次滑動或縮放線條出現(xiàn),手指抬起后線條消失。
嗯,整個過程的大致行為就是這樣了。
開工寫代碼咯~
起名字
在開始寫代碼之前,要先給這個自定義控件起一個名字,又哦豁。。。不會起名字,
就叫:裁剪圖片控件(MCropImageView) 吧。不要問我M字母是啥含義,我不會告訴你的。
編寫代碼
寬高相等矩陣測量
測量比較簡單,具體請看相關(guān)代碼:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (widthSize > heightSize) {
// 取高
super.onMeasure(heightMeasureSpec, heightMeasureSpec);
} else {
// 取寬
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
}
鋪滿居中
鋪滿的原理上文已經(jīng)講到了,對應(yīng)的公式如下:
控件寬度/圖片寬度 = a
控件高度/高度高度 = b
mBaseScale = Math.max(a,b)
Matrix.postScale(mBaseScale, mBaseScale, 控件寬度/ 2, 控件高度/ 2)
居中的原理上面也提到過了,來看看代碼怎么寫:
@Override
public void onGlobalLayout() {
mMatrix.reset();
// 獲取控件的寬度和高度
int viewWidth = getWidth();
int viewHeight = getHeight();
// 圖片的固定寬度 高度
// 獲取圖片的寬度和高度
Drawable drawable = getDrawable();
if (null == drawable) {
return;
}
int drawableWidth = drawable.getIntrinsicWidth();
int drawableHeight = drawable.getIntrinsicHeight();
// 將圖片移動到屏幕的中點位置
float dx = (viewWidth - drawableWidth) / 2;
float dy = (viewHeight - drawableHeight) / 2;
// 取最大值
mBaseScale = Math.max((float) viewWidth / drawableWidth, (float) viewHeight / drawableHeight);
// 平移居中
mMatrix.postTranslate(dx, dy);
// 縮放
mMatrix.postScale(mBaseScale, mBaseScale, viewWidth / 2, viewHeight / 2);
setImageMatrix(mMatrix);
}
有關(guān)Matrix的set 、 pre、post方法調(diào)用順序,這里簡單說一下(個人理解,有錯還望指出 ),可以把Matrix的操作看成隊列,post方法添加到隊列的尾部,pre添加到隊列的頭部,而set方法則重置隊列。
看看鋪滿居中的效果:
單指滑動
單指滑動,在上文已經(jīng)講到GestureDetector.SimpleOnGestureListener內(nèi)部接口用來處理手勢滑動,重寫以下接口方法:
// 處理手指滑動
private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
// 消費事件
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
// 限定單指
if (e1.getPointerCount() == e2.getPointerCount() && e1.getPointerCount() == 1) {
// distanceX 左正右負(fù) 所以這里取相反數(shù)
mMatrix.postTranslate(-distanceX, -distanceY);
setImageMatrix(mMatrix);
return true;
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
};
獲取到手指滑動的距離,對圖片矩陣進(jìn)行平移Matrix.postTranslate(),但在x軸方向獲取到的滑動距離右負(fù)左正,y軸方向獲取到的滑動距離上正下負(fù),跟實際平移的值相反,那么平移值Matrix.postTranslate(-distanceX, -distanceY)取滑動距離的負(fù)數(shù)。
單指滑動還有一個效果,越界下的阻尼效果,看看效果圖:

很明顯圖片跟隨手指滑動,距離控件邊緣越近,阻尼越大。那么很明顯需要獲取圖片邊緣距離控件的距離,然后根據(jù)滑動偏移量進(jìn)行計算。為了獲取圖片邊緣距離控件的距離,就需要獲取圖片的位置信息。那么怎樣才能獲取圖片位置信息呢?
在ViewGroup的transformPointToViewLocal方法中有這樣一段代碼:
if (!child.hasIdentityMatrix()) {
child.getInverseMatrix().mapPoints(point);
}
如果child所對應(yīng)的矩陣發(fā)生過旋轉(zhuǎn)、縮放等變化的話(補間動畫不算,因為是臨時的),會通過矩陣的mapPoints方法來將觸摸點轉(zhuǎn)換到矩陣變換后的坐標(biāo)。
沒錯,我們也可以用矩陣的mapRect方法來將圖片的坐標(biāo)及尺寸轉(zhuǎn)換一下,就像這樣:

這樣就可以獲取到圖片的矩形區(qū)域,相關(guān)方法如下:
// 獲取圖片矩陣區(qū)域
private RectF getMatrixRectF() {
RectF rectF = new RectF();
Drawable drawable = getDrawable();
if (drawable != null) {
// 注意set
rectF.set(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
mMatrix.mapRect(rectF);
}
return rectF;
}
獲取到了圖片矩陣,那么圖片越界就很容易判定了,先看下面兩張越界圖:
圖片上邊緣距離控件頂部變量為topEdgeDistanceTop,左邊緣距離控件左邊變量為leftEdgeDistanceLeft,右邊緣距離控件右邊變量為rightEdgeDistanceRight,下邊緣距離控件底部變量為bottomEdgeDistanceBottom,分別對應(yīng)的代碼如下:
// 獲取圖片矩陣
RectF rectF = getMatrixRectF();
float leftEdgeDistanceLeft = rectF.left;
float topEdgeDistanceTop = rectF.top;
//位移 rectF.right - rectF.left 圖片寬度
float rightEdgeDistanceRight = leftEdgeDistanceLeft + rectF.right - rectF.left - getWidth();
// rectF.bottom - rectF.top 圖片高度
float bottomEdgeDistanceBottom = topEdgeDistanceTop + rectF.bottom - rectF.top - getHeight();
好了,這樣就可以準(zhǔn)確判定圖片是否越界。接下來我們看看越界狀態(tài)下的阻尼算法是怎么計算的,有什么規(guī)律:
先來觀察圖片左右越界的情況(上下越界同理),左右越界又分為三種情況,左越界&右不越界(簡稱左越界),右越界&左不越界(簡稱右越界),左越界&右越界(簡稱左右越界) 左越界的情況與右越界類似,那么就只有兩種情況:
-
左越界
在這里插入圖片描述
可以看到在向左滑動的情況下,圖片左側(cè)距離控件左側(cè)距離越大,阻力越大。通俗一點,手指滑動的距離越大,圖片跟隨手指滑動的距離就越小,那么可以根據(jù)以下公式獲取阻尼系數(shù):
最大阻尼數(shù) / 最大偏移量 * leftEdgeDistanceLeft
最大阻尼數(shù)默認(rèn)取值為9,最大偏移量為控件寬度的三分之一,對應(yīng)的代碼如下:
// 獲取圖片矩陣
RectF rectF = getMatrixRectF();
float leftEdgeDistanceLeft = rectF.left;
float rightEdgeDistanceRight = leftEdgeDistanceLeft + rectF.right - rectF.left - getWidth();
// MAX_SCROLL_FACTOR = 3
int maxOffsetWidth = getWidth() / MAX_SCROLL_FACTOR;
int maxOffsetHeight = getHeight() / MAX_SCROLL_FACTOR;
// 圖片左側(cè)越界并且圖片右側(cè)未越界
if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight > 0) {
// distanceX < 0 表示繼續(xù)向右滑動
if (distanceX < 0) {
if (leftEdgeDistanceLeft < maxOffsetWidth) {
// DAMP_FACTOR = 9 系數(shù)越大阻尼越大 +1防止ratio為0
int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * leftEdgeDistanceLeft) + 1;
distanceX /= ratio;
} else {
// 圖片向右滑動超過了最大偏移量 圖片則不平移
distanceX = 0;
}
}
// 向左滑動不做處理 默認(rèn)取值distanceX
}
-
左右越界
在這里插入圖片描述
左右越界的情況與左越界的情況正好相反,距離控件邊緣越近,圖片阻力越大。那么怎么判定圖片距離控件邊緣越近,這里分兩種情況,圖片中點在控件中點左側(cè)以及圖片中點在控件中點右側(cè)。第一種情況圖片中點在控件中點左側(cè),向左滑動阻力越大,向右滑動阻力為0;第二種情況圖片中點在控件中點的右側(cè),向右滑動阻力越大,向左滑動阻力為0。
來看看代碼怎么寫:
// 圖片左側(cè)越界并且圖片右側(cè)越界
if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight < 0) {
// 控件寬度的一半
int halfWidth = getWidth() / 2;
// 獲取圖片中點x坐標(biāo)
float centerX = (rectF.right - rectF.left) / 2 + rectF.left;
// 圖片中點x坐標(biāo)是否右側(cè)偏移
boolean rightOffsetCenterX = centerX >= halfWidth;
// 右側(cè)偏移并且向右滑動
if (distanceX < 0 && rightOffsetCenterX) {
// centerX - halfWidth 圖片右側(cè)偏移量
int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (centerX - halfWidth)) + 1;
distanceX /= ratio;
}
// 左側(cè)偏移并且向左滑動
else if (distanceX > 0 && !rightOffsetCenterX) {
// halfWidth - centerX 左側(cè)的偏移量
int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (halfWidth - centerX)) + 1;
distanceX /= ratio;
}
}
好了,左右越界就講到這里,上下越界同理,越界的整體代碼如下:
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (e1.getPointerCount() == e2.getPointerCount() && e1.getPointerCount() == 1) {
// 獲取圖片矩陣
RectF rectF = getMatrixRectF();
float leftEdgeDistanceLeft = rectF.left;
float topEdgeDistanceTop = rectF.top;
float rightEdgeDistanceRight = leftEdgeDistanceLeft + rectF.right - rectF.left - getWidth();
float bottomEdgeDistanceBottom = topEdgeDistanceTop + rectF.bottom - rectF.top - getHeight();
// MAX_SCROLL_FACTOR = 3
int maxOffsetWidth = getWidth() / MAX_SCROLL_FACTOR;
int maxOffsetHeight = getHeight() / MAX_SCROLL_FACTOR;
// 圖片左側(cè)越界并且圖片右側(cè)未越界
if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight > 0) {
// distanceX < 0 表示繼續(xù)向右滑動
if (distanceX < 0) {
if (leftEdgeDistanceLeft < maxOffsetWidth) {
// DAMP_FACTOR = 9 系數(shù)越大阻尼越大 +1防止ratio為0
int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * leftEdgeDistanceLeft) + 1;
distanceX /= ratio;
} else {
// 圖片向右滑動超過了最大偏移量 圖片則不平移
distanceX = 0;
}
}
// 向左滑動不做處理 默認(rèn)取值distanceX
}
// 圖片右側(cè)越界并且圖片左側(cè)未越界 (同上處理)
else if (rightEdgeDistanceRight < 0 && leftEdgeDistanceLeft < 0) {
// distanceX > 0 表示繼續(xù)向左滑動
if (distanceX > 0) {
if (rightEdgeDistanceRight > -maxOffsetWidth) {
int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * -rightEdgeDistanceRight) + 1;
distanceX /= ratio;
} else {
// 圖片右側(cè)距離控件右側(cè)超過最大偏移量 圖片則不平移
distanceX = 0;
}
}
}
// 圖片左側(cè)越界并且圖片右側(cè)越界
else if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight < 0) {
// 控件寬度的一半
int halfWidth = getWidth() / 2;
// 獲取圖片中點x坐標(biāo)
float centerX = (rectF.right - rectF.left) / 2 + rectF.left;
// 圖片中點x坐標(biāo)是否右側(cè)偏移
boolean rightOffsetCenterX = centerX >= halfWidth;
// 右側(cè)偏移并且向右滑動
if (distanceX < 0 && rightOffsetCenterX) {
// centerX - halfWidth 圖片右側(cè)偏移量
int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (centerX - halfWidth)) + 1;
distanceX /= ratio;
}
// 左側(cè)偏移并且向左滑動
else if (distanceX > 0 && !rightOffsetCenterX) {
// halfWidth - centerX 左側(cè)的偏移量
int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (halfWidth - centerX)) + 1;
distanceX /= ratio;
}
}
// 上下越界 處理方式同左右處理方式一樣 本可以提成一個方法但為了方便理解先這樣了
// 圖片上側(cè)越界并且圖片下側(cè)未越界
if (topEdgeDistanceTop > 0 && bottomEdgeDistanceBottom > 0) {
// distanceY < 0 表示圖片繼續(xù)向下滑動
if (distanceY < 0) {
if (topEdgeDistanceTop < maxOffsetHeight) {
// 獲取阻尼比例
int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * topEdgeDistanceTop) + 1;
distanceY /= ratio;
} else {
// 向下滑動超過了最大偏移量 則圖片不滑動
distanceY = 0;
}
}
}
// 圖片下側(cè)越界并且圖片上側(cè)未越界
else if (bottomEdgeDistanceBottom < 0 && topEdgeDistanceTop < 0) {
if (distanceY > 0) {
if (bottomEdgeDistanceBottom > -maxOffsetHeight) {
int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * -bottomEdgeDistanceBottom) + 1;
distanceY /= ratio;
} else {
// 向上滑動超過了最大偏移量 則圖片不滑動
distanceY = 0;
}
}
} else if (topEdgeDistanceTop > 0 && bottomEdgeDistanceBottom < 0) {
int halfHeight = getHeight() / 2;
// 獲取圖片中點y坐標(biāo)
float centerY = (rectF.bottom - rectF.top) / 2 + rectF.top;
// 圖片中點y坐標(biāo)是否向下偏移
boolean bottomOffsetCenterY = centerY >= halfHeight;
// 向下偏移并且向下移動
if (distanceY < 0 && bottomOffsetCenterY) {
// centerY - halfHeight 圖片偏移量
int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * (centerY - halfHeight)) + 1;
distanceY /= ratio;
} else if (distanceY > 0 && !bottomOffsetCenterY) { // 向上偏移并且向上移動
int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * (halfHeight - centerY)) + 1;
distanceY /= ratio;
}
}
mMatrix.postTranslate(-distanceX, -distanceY);
setImageMatrix(mMatrix);
return true;
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
雙指縮放
雙指縮放的原理在上文已經(jīng)提及過了,重寫ScaleGestureDetector.OnScaleGestureListener縮放手勢類接口方法:
// 處理雙指的縮放
private ScaleGestureDetector.OnScaleGestureListener mOnScaleGestureListener = new ScaleGestureDetector.OnScaleGestureListener() {
@Override
public boolean onScale(ScaleGestureDetector detector) {
if (null == getDrawable() || mMatrix == null) {
// 如果返回true那么detector就會重置縮放事件
return true;
}
// 縮放因子,縮小小于1,放大大于1
float scaleFactor = mScaleGestureDetector.getScaleFactor();
// 縮放因子偏移量
float deltaFactor = scaleFactor - mPreScaleFactor;
if (scaleFactor != 1.0F && deltaFactor != 0F) {
mMatrix.postScale(deltaFactor + 1F, deltaFactor + 1F, mScaleGestureDetector.getFocusX(),
mScaleGestureDetector.getFocusY());
setImageMatrix(mMatrix);
}
mPreScaleFactor = scaleFactor;
return false;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
// 注意返回true
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
}
};
回彈
在手指抬起時,圖片在某種狀態(tài)下會出現(xiàn)回彈動效,這里某種狀態(tài)指的是越界&圖片的縮放比例大于一定的閾值&圖片的縮放比例小于一定的閾值三種狀態(tài),回彈無非改變圖片矩陣的setTranslation,setScale值。當(dāng)我們需要監(jiān)聽手指抬起的狀態(tài)時,都是直接重寫onTouchEvent去實現(xiàn):
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 防止父類攔截事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
float scale = getScale();
if (scale > mMaxScale) {
// 縮小
} else if (scale < mBaseScale) {
// 放大
} else {
// 平移
}
getParent().requestDisallowInterceptTouchEvent(false);
break;
}
return true;
}
為了防止父類攔截事件,一般會在手指按下,抬起調(diào)用requestDisallowInterceptTouchEvent方法來避免事件沖突。 getScale方法如下,獲取圖片矩陣的縮放比例:
private float getScale() {
float[] values = new float[9];
mMatrix.getValues(values);
return values[Matrix.MSCALE_X];
}
縮小放大的動畫怎么實現(xiàn)呢?知道了開始與結(jié)束的縮放比例,在動畫回調(diào)接口中動態(tài)設(shè)置 mMatrix.setValues(values)來實現(xiàn)縮小放大的效果,可現(xiàn)實很骨感,效果相去甚遠(yuǎn),縮放中心點PivotX和PivotY始終在圖片原點,同時Matrix并沒有提供設(shè)置縮放中心點的方法??磥碇荒芾侠蠈崒嵉氖褂肕atrix.postScale(float sx, float sy, float px, float py)方法,同時設(shè)置縮放中心點為雙指的中點坐標(biāo)ScaleGestureDetector.getFocusX()。注意:sx,sx是相對值,相對上一個終點的縮放值。
相對值,多縮放一次與少縮放一次圖片的狀態(tài)完全不一樣,那么必須控制縮放次數(shù),由于ValueAnimator回調(diào)次數(shù)在不同的機型上并不一樣,那么就不能用ValueAnimator的回調(diào)來實現(xiàn)動畫,那么怎么做呢?
emmmm,你一定會想到Handler,既可以控制次數(shù)還可以控制消息延時。知道了開始與結(jié)束縮放點,也知道了縮放次數(shù),那么怎么獲取縮放相對值呢,利用Math.pow數(shù)學(xué)公式:
/**
* 計算d的1/count次冪
*
* @param d
* @param count 開根的次數(shù)
* @return 相對值
*/
private static float getRelativeValue(double d, double count) {
if (count == 0) {
return 1F;
}
count = 1 / count;
return (float) Math.pow(d, count);
}
接下來就是發(fā)送消息與接收消息:
/**
* 發(fā)送消息
*
* @param relativeScale
* @param what
* @param delayMillis
*/
private void sendMessage(float relativeScale, int what, long delayMillis) {
Message mes = new Message();
mes.obj = relativeScale;
mes.what = what;
mHandler.sendMessageDelayed(mes, delayMillis);
}
// 調(diào)用 省略前面 ...
case MotionEvent.ACTION_UP:
float scale = getScale();
if (scale > mMaxScale) {
// 縮小 SCALE_ANIM_COUNT = 10 ZOOM_OUT_ANIM_WHIT = 0
sendMessage(getRelativeValue(mMaxScale / scale, SCALE_ANIM_COUNT), ZOOM_OUT_ANIM_WHIT, 0);
} else if (scale < mBaseScale) {
// 放大 ZOOM_ANIM_WHIT = 1
sendMessage(getRelativeValue(mMaxScale / scale, SCALE_ANIM_COUNT), ZOOM_ANIM_WHIT, 0);
} else {
// 平移
boundCheck();
}
接收并處理消息:
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
if (msg != null) {
if (mCurrentScaleAnimCount < SCALE_ANIM_COUNT) {
float obj = (float) msg.obj;
mMatrix.postScale(obj, obj, mLastFocusX, mLastFocusY);
setImageMatrix(mMatrix);
mCurrentScaleAnimCount++;
// what scale > mMaxScale 取0 不然取 1
sendScaleMessage(obj, msg.what, SCALE_ANIM_COUNT);
} else if (mCurrentScaleAnimCount >= SCALE_ANIM_COUNT) {
float[] values = new float[9];
mMatrix.getValues(values);
if (msg.what == ZOOM_OUT_ANIM_WHIT) {
values[Matrix.MSCALE_X] = mMaxScale;
values[Matrix.MSCALE_Y] = mMaxScale;
} else if (msg.what == ZOOM_ANIM_WHIT) {
values[Matrix.MSCALE_X] = mBaseScale;
values[Matrix.MSCALE_Y] = mBaseScale;
}
mMatrix.setValues(values);
setImageMatrix(mMatrix);
// 邊界檢測
boundCheck();
}
}
}
};
縮小放大的效果如下:


為了防止Handler泄露,清空隊列:
@Override
protected void onDetachedFromWindow() {
if (mHandler != null) {
// 防止內(nèi)存泄露
mHandler.removeCallbacksAndMessages(null);
}
super.onDetachedFromWindow();
}
回彈還剩最后一種情況越界,在上文中已經(jīng)提到了越界的四種(上下左右)情況,手指抬起后圖片平移到控件邊緣。所謂的平移,就是從一點平移到另一點,那么怎么獲取起點與結(jié)束點呢?
首先需要判定越界,根據(jù)getMatrixRectF圖片矩陣,代碼已經(jīng)很清晰:
// 邊界檢測
private void boundCheck() {
// 獲取圖片矩陣
RectF rectF = getMatrixRectF();
if (rectF.left >= 0) {
// 左越界
}
if (rectF.top >= 0) {
// 上越界
}
if (rectF.right <= getWidth()) {
// 右越界
}
if (rectF.bottom <= getHeight()) {
// 下越界
}
}
在左越界的情況下,起點為rectF.left,結(jié)束點為0;同理上越界的起點rectF.top,結(jié)束點0;那么右越界起點與結(jié)束點呢?有小伙伴會說那還不簡單,不就是rectF.right,getWidth()嗎?
很遺憾,你又哦豁了,不得不提一下,圖片的矩陣的平移是以左上角為基點,那么右越界的起點同樣為rectF.left,結(jié)束點為:
起點 + 圖片右側(cè)距離控件右側(cè)的距離
圖片右側(cè)距離控件右側(cè)的距離為getWidth() - rectF.right,那么結(jié)束點的坐標(biāo)為rectF.left + getWidth() - rectF.right;同理下越界的起點為rectF.top,結(jié)束點getHeight() - rectF.bottom + rectF.top。有了起點與結(jié)束點,那么平移就很容易了:
/**
* 開始越界動畫
*
* @param start 開始點坐標(biāo)
* @param end 結(jié)束點坐標(biāo)
* @param horizontal 是否水平動畫 true 水平動畫 false 垂直動畫
*/
private void startBoundAnimator(float start, float end, final boolean horizontal) {
boundAnimator = ValueAnimator.ofFloat(start, end);
boundAnimator.setDuration(200);
boundAnimator.setInterpolator(new LinearInterpolator());
boundAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float v = (float) animation.getAnimatedValue();
float[] values = new float[9];
mMatrix.getValues(values);
values[horizontal ? Matrix.MTRANS_X : Matrix.MTRANS_Y] = v;
mMatrix.setValues(values);
setImageMatrix(mMatrix);
}
});
boundAnimator.start();
}
好了,看看效果:

九宮線條
在上文已經(jīng)提到九宮線條的規(guī)律: 圖片在滑動或縮放態(tài)下,會出現(xiàn)九宮格白色線條,線條始終平分控件內(nèi)的圖片為九等分,滑動或縮放停止線條消失,再次滑動或縮放線條出現(xiàn),手指抬起后線條消失。那么從這句話中我們可以得出以下結(jié)論:
有關(guān)繪制涉及到onDraw()方法的重寫
線條的顯示區(qū)域為圖片與控件的交集
控制線條的顯示與消失(是否繪制)
怎么取交集記住一個原則:上左取大,右下取小 八字真言,就像這樣:
// 開始點
float startX = 0;
float startY = 0;
// 結(jié)束點
float endX = 0;
float endY = 0;
RectF rectF = getMatrixRectF();
// 上左取大 右下取小
startX = rectF.left <= 0 ? 0 : rectF.left;
startY = rectF.top <= 0 ? 0 : rectF.top;
endX = rectF.right >= getWidth() ? getWidth() : rectF.right;
endY = rectF.bottom >= getHeight() ? getHeight() : rectF.bottom;
獲取到線條繪制的區(qū)域,那么怎么繪制線條?繪制多少線條?就比較容易了:
float lineWidth = 0;
float lineHeight = 0;
lineWidth = endX - startX;
lineHeight = endY - startY;
// LINE_ROW_NUMBER = 3 表示多少行
for (int i = 1; i < LINE_ROW_NUMBER; i++) {
canvas.drawLine(startX + 0, startY + lineHeight / LINE_ROW_NUMBER * i, endX, startY + lineHeight / LINE_ROW_NUMBER * i, mLinePaint);
}
// LINE_COLUMN_NUMBER = 3 表示多少列
for (int i = 1; i < LINE_COLUMN_NUMBER; i++) {
canvas.drawLine(startX + lineWidth / LINE_COLUMN_NUMBER * i, startY, startX + lineWidth / LINE_COLUMN_NUMBER * i, endY, mLinePaint);
}
怎么控制線條的顯示消失,注意顯示消失的規(guī)則,縮放或滑動停止線條消失,再次滑動或縮放線條顯示,以此類推,絕大部分人會想到怎么判定滑動或縮放停止?
寫控件很多時候就是這樣,不知不覺就入坑了,一頭扎進(jìn)里面,茶不思飯不想。。。然而這一切并沒有什么用,最后還得換方案。
說下為什么不行,你會在手勢MotionEvent.ACTION_MOVE事件判定滑動或縮放停止,但同時GestureDetector與ScaleGestureDetector也在消費滑動事件,導(dǎo)致判定不準(zhǔn)確。那么怎么解決呢?
還記得Android源碼長按事件的處理方式嗎?相關(guān)代碼如下:
case MotionEvent.ACTION_DOWN:
......省略代碼
if (mIsLongpressEnabled) {
mHandler.removeMessages(LONG_PRESS);
// 延遲時長為500毫秒
mHandler.sendEmptyMessageAtTime(LONG_PRESS,
mCurrentDownEvent.getDownTime() + LONGPRESS_TIMEOUT);
}
case MotionEvent.ACTION_MOVE:
int distance = (deltaX * deltaX) + (deltaY * deltaY);
int slopSquare = isGeneratedGesture ? 0 : mTouchSlopSquare;
if (distance > slopSquare) {
......省略代碼
mHandler.removeMessages(LONG_PRESS);
}
在事件ACTION_DOWN延時發(fā)送長按事件,在延遲周期內(nèi),如果發(fā)生滑動,則移除長按事件,反之未發(fā)生滑動則觸發(fā)長按事件。
借鑒長按事件的處理方式:
// 繪制九宮線條
private void drawLine(Canvas canvas) {
// 省略中間代碼
mHandler.removeCallbacks(lineRunnable);
mHandler.postDelayed(lineRunnable, 400);
}
private Runnable lineRunnable = new Runnable() {
@Override
public void run() {
mIsDragging = false;
invalidate();
}
};
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mIsDragging) {
canvas.save();
drawLine(canvas);
canvas.restore();
}
}
效果就像這樣:

哈哈哈~,小紅書的圖片裁剪控件喜歡嗎?想看更多炫酷控件,請搜索關(guān)注公眾號:控件人生

你可以留言,告訴小編想實現(xiàn)什么樣的炫酷控件?小編會每周選取炫酷的控件進(jìn)行講解。
由于篇幅原因,文章到這里就差不多了,有關(guān)左下角留白,填充效果,以及聯(lián)動效果,將在下一篇講解,打造屬于你自己的CoordinatorLayout效果,喜歡的小伙伴被忘記關(guān)注控件人生(新公眾號),同大家一起成長。


