第一站小紅書圖片裁剪控件,深度解析大廠炫酷控件

先來看兩張效果圖:


在這里插入圖片描述
在這里插入圖片描述

哈哈,就是這樣了。效果差了一些,感興趣的小伙伴們可以運行代碼感受絲滑與彈性。前段時間在競品小紅書上看到了這樣的效果:圖片可以跟隨手指移動,雙指可以(無限)放大,縮小,還可以擠壓,手指抬起后還有一個有趣的效果,圖片回彈。。。一直想擼一個手勢的控件,正好可以模仿小紅書圖片裁剪控件,話不多說,擼起袖子就是干。

本系列共有兩篇,在第二篇會重點講解與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):

  1. 圖片顯示區(qū)域為寬高相等的矩形,那么在測量onMeasure的時候需要保證寬高一致,左下角小按鈕的狀態(tài)切換先不考慮,后面會重點講解。

  2. 圖片默認(rèn)會充滿整個控件并居中對齊,那么怎么保證圖片充滿控件,最常規(guī)的做法就是:取控件的寬高與圖片的寬高比的最大值縮放Math.max(控件寬度/圖片寬度,控件高度/圖片高度);同理,取控件寬高與圖片寬高的偏移量的一半來平移圖片保證居中對齊。

  3. 在2的基礎(chǔ)上,非寬高相等的圖片有一部分會顯示在控件區(qū)域之外,可以通過手指滑動來顯示,相信大家都用過PhotoView,效果一致。 移動圖片與移動控件的原理一樣,都是改變setTranslation的值,不過這里用到了圖片矩陣,通過改變Matrix.postTranslate(dx, dy)的值來移動圖片。

  4. 移動圖片,那就不得不考慮越界問題,請觀察下圖,這里以上邊界為例(左,右,下邊界同理)。注意:這里的越界指的不是數(shù)組越界,而是圖片滑動到邊緣繼續(xù)沿相同方向滑動,圖片未鋪滿控件區(qū)域。 在下圖中你會發(fā)現(xiàn):圖片跟隨手指繼續(xù)滑動,手指滑動的距離越大阻尼越大,手指抬起后圖片會回彈到控件頂部。

    在這里插入圖片描述

  5. 雙指擠壓圖片縮小,擴(kuò)散圖片放大,縮放中心點是雙指中點坐標(biāo),那么縮放比例怎么計算呢?最開始取的縮放因子ScaleGestureDetector.getScaleFactor() ,出來的效果真的天馬行空(輕微擠壓擴(kuò)散圖片無限放大縮小 ),接著給縮放因子加一個比例,效果依舊不行,哦豁。沒辦法,打印縮放數(shù)據(jù),觀察數(shù)據(jù),尋找規(guī)律。幾經(jīng)嘗試最后取了縮放因子的偏移量。為了寫好控件,沒什么捷徑,只能多觀察,多嘗試。 在縮小至越界的狀態(tài)下,手指抬起,圖片放大到充滿控件;在放大到一定的閾值后放手后,圖片回彈到一定的縮放比例。前文提到了在縮小至越界狀態(tài)下單指滑動圖片,根據(jù)四周滑動的距離,會出現(xiàn)阻尼效果,在后文會講解阻尼算法。

  6. 圖片在滑動或縮放態(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ī)律:

先來觀察圖片左右越界的情況(上下越界同理),左右越界又分為三種情況,左越界&右不越界(簡稱左越界),右越界&左不越界(簡稱右越界),左越界&右越界(簡稱左右越界) 左越界的情況與右越界類似,那么就只有兩種情況:

  1. 左越界


    在這里插入圖片描述

可以看到在向左滑動的情況下,圖片左側(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
   }
  1. 左右越界


    在這里插入圖片描述

左右越界的情況與左越界的情況正好相反,距離控件邊緣越近,圖片阻力越大。那么怎么判定圖片距離控件邊緣越近,這里分兩種情況,圖片中點在控件中點左側(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é)論:

  1. 有關(guān)繪制涉及到onDraw()方法的重寫

  2. 線條的顯示區(qū)域為圖片與控件的交集

  3. 控制線條的顯示與消失(是否繪制)

怎么取交集記住一個原則:上左取大,右下取小 八字真言,就像這樣:

    // 開始點
    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)注控件人生(新公眾號),同大家一起成長。

Github地址:https://github.com/HpWens/MCropImageView 歡迎Star

炫酷控件集:https://github.com/HpWens/MeiWidgetView 歡迎Star

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容