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

文淑發表於2019-03-04

先來看兩張效果圖:

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

本系列共有兩篇,在第二篇會重點講解與RecyclerView的聯動效果,先放一張效果圖,感興趣的小夥伴們繼續關注哦:

在這裡插入圖片描述

初步分析

先來看看小紅書的樣子:

在這裡插入圖片描述
在這裡插入圖片描述
在這裡插入圖片描述
emmmm,從效果上來看呢,其實也只是基本的Translation和Scale組合而已,難點在於縮小態下的阻尼計算,左下角那個按鈕用來控制留白,填充等狀態的切換(好像小紅書還有bug,狀態切換會導致圖片位置不正確,哈哈哈),接下來我們就一步步分析,從而打造出屬於我們的自己的效果。

仔細觀察,有沒有發現:

  • 單指滑動,圖片跟隨手指移動,當手指滑動到圖片邊緣繼續沿同一方向滑動,會出現阻尼效果,滑動的距離越大,阻尼越大,手指抬起後,圖片回彈到控制元件邊緣;

  • 雙指觸控分兩種情況,一種是雙指向內擠壓,圖片縮小;另一種是雙指向外擴散,圖片放大;

  • 當雙指向外擴散達到一定的臨界值,手指抬起後,圖片縮小到臨界值狀態;

  • 手指觸控且有一定的滑動值,會顯示線條九宮格,且線條跟隨圖片的大小動態改變,始終分割圖片為9等分,如果手指觸控停止,線條消失,再次滑動,線條則再次出現;

那麼圖片縮放時,需要一個縮放中心點,也就是PivotX和PivotY,這個點預設情況下在View的中心。但很明顯,它這個就不是在中心了,至於在哪裡,先看下這張圖:

在這裡插入圖片描述
可以看到,圖片始終是以雙指的中點在縮放,那麼縮放中心點就是雙指連線的中點位置上了。又怎麼獲取到雙指的中點座標呢?這裡涉及到了Android提供的兩個幫助類:GestureDetector、ScaleGestureDetector。接下來讓我們先來了解下這兩個類,揭開它的神祕面紗。神祕?你個糟老頭,壞得很,信你個鬼。。。

手勢幫助類

什麼是手勢幫助類?Android手機螢幕上,當我們觸控螢幕的時候,會產生許多手勢事件,如down,up,scroll,filing等等。我們可以在onTouchEvent()方法裡面完成各種手勢識別。但是,我們自己去識別各種手勢就比較麻煩了,而且有些情況可能考慮的不是那麼的全面。所以,為了方便我們的使用Android就提供了GestureDetector幫助類,先來看看他的構造方法:

    public GestureDetector(Context context, OnGestureListener listener, Handler handler,
            boolean unused) {
    }
複製程式碼

context表示上下文,listener表示手勢的監聽回撥,handler可以指定執行緒(UI執行緒、非UI執行緒),unused未被使用的引數。如果我們的手勢不需要在子執行緒中處理,我們一般只關心前兩個引數,context是上下文這個簡單,重點看下listener引數:

GestureDetector給我們提供了三個介面類與一個外部類:

  • OnGestureListener:介面,用來監聽手勢事件(6種);

  • OnDoubleTapListener:介面,用來監聽雙擊事件;

  • OnContextClickListener:介面,外接裝置,比如外接滑鼠產生的事件(本文中我們不考慮);

  • SimpleOnGestureListener:外部類,SimpleOnGestureListener其實是上面三個介面中所有函式的整合,它包含了這三個介面裡所有必須要實現的函式而且都已經重寫,但所有方法體都是空的。需要自己根據情況去重寫;

OnGestureListener介面方法:

public interface OnGestureListener {
        /**
         * 按下。返回值表示事件是否處理
         */
        boolean onDown(MotionEvent e);
        
        /**
         * 短按(手指尚未鬆開也沒有達到scroll條件)
         */
        void onShowPress(MotionEvent e);

        /**
         * 輕觸(手指鬆開)
         */
        boolean onSingleTapUp(MotionEvent e);

        /**
         * 滑動(一次完整的事件可能會多次觸發該函式)。返回值表示事件是否處理
         */
        boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);

        /**
         * 長按(手指尚未鬆開也沒有達到scroll條件)
         */
        void onLongPress(MotionEvent e);

        /**
         * 滑屏(使用者按下觸控式螢幕、快速滑動後鬆開,返回值表示事件是否處理)
         */
        boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
    }
複製程式碼

OnDoubleTapListener介面方法:

    public interface OnDoubleTapListener {
        /**
         * 單擊事件(onSingleTapConfirmed,onDoubleTap是兩個互斥的函式)
         */
        boolean onSingleTapConfirmed(MotionEvent e);

        /**
         * 雙擊事件
         */
        boolean onDoubleTap(MotionEvent e);

        /**
         * 雙擊事件產生之後手指還沒有抬起的時候的後續事件
         */
        boolean onDoubleTapEvent(MotionEvent e);
    }
複製程式碼

GestureDetector的使用:

  • 定義GestureDetector類;

  • 將touch事件交給GestureDetector(onTouchEvent函式裡面呼叫GestureDetector的onTouchEvent函式);

  • 處理SimpleOnGestureListener或者OnGestureListener、OnDoubleTapListener、OnContextClickListener三者之一的回撥;

GestureDetector使用流程如下(有關例子會在後文中講到):

    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()關聯相應的MotionEvent事件。

ScaleGestureDetector類給提供了OnScaleGestureListener介面,來告訴我們縮放的過程中的一些回撥:

  public interface OnScaleGestureListener {
        /**
         * 縮放進行中,返回值表示是否下次縮放需要重置,如果返回ture,那麼detector就會重置縮放事件,如果返回false,detector會在之前的縮放上繼續進行計算
         */
        public boolean onScale(ScaleGestureDetector detector);

        /**
         * 縮放開始,返回值表示是否受理後續的縮放事件
         */
        public boolean onScaleBegin(ScaleGestureDetector detector);

        /**
         * 縮放結束
         */
        public void onScaleEnd(ScaleGestureDetector detector);
    }
複製程式碼

ScaleGestureDetector類常用函式介紹,因為在縮放的過程中,要通過ScaleGestureDetector來獲取一些縮放資訊:

    /**
     * 縮放是否正處在進行中
     */
    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()返回值為基準,一旦該方法返回true,代表本次事件結束,重新開啟下次縮放事件。
     */
    public float getScaleFactor();

    /**
     * 返回上次縮放事件結束時到當前的時間間隔
     */
    public long getTimeDelta();

    /**
     * 獲取當前motion事件的時間
     */
    public long getEventTime();
複製程式碼

ScaleGestureDetector使用方式與GestureDetector類似,這裡就不再重複講解,瞭解了相關手勢類,接下來開始程式碼構思。

構思程式碼

想一想,圖片有任意尺寸,怎樣才能讓圖片鋪滿控制元件,那麼就需要對圖片進行縮放,平移。還有一點是必須考慮的,在載入高解析度的圖片非常消耗記憶體,在低記憶體的手機上很容易造成OOM,那麼針對高解析度的圖片就必須壓縮。還有一種情況是來回切換相同的兩張圖片,如果每次都載入本地圖片,既消耗記憶體速度還很慢,這時候快取就很有必要了,第一次載入本地圖片,再次切回到該圖片載入快取圖片。

顯示圖片,一般有兩種方式,一種是Android提供了ImageView控制元件來顯示圖片;另一種直接在onDraw()方法裡呼叫canvas.drawBitmap()方法,通過調研小紅書顯示方案,發現他採用了第二種:

在這裡插入圖片描述
(^__^) 嘻嘻……那我們就用第一種顯示圖片的方式,繼承ImageView來顯示圖片。

通過觀察小紅書,我們會發現:

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

  2. 圖片預設會充滿整個控制元件並居中對齊,那麼怎麼保證圖片充滿控制元件,最常規的做法就是:取控制元件的寬高與圖片的寬高比的最大值縮放Math.max(控制元件寬度/圖片寬度,控制元件高度/圖片高度);同理,取控制元件寬高與圖片寬高的偏移量的一半來平移圖片保證居中對齊。

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

  4. 移動圖片,那就不得不考慮越界問題,請觀察下圖,這裡以上邊界為例(左,右,下邊界同理)。注意:這裡的越界指的不是陣列越界,而是圖片滑動到邊緣繼續沿相同方向滑動,圖片未鋪滿控制元件區域。 在下圖中你會發現:圖片跟隨手指繼續滑動,手指滑動的距離越大阻尼越大,手指抬起後圖片會回彈到控制元件頂部。

    在這裡插入圖片描述

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

  6. 圖片在滑動或縮放態下,會出現九宮格白色線條,線條始終平分控制元件內的圖片為九等分,滑動或縮放停止線條消失,再次滑動或縮放線條出現,手指抬起後線條消失。

嗯,整個過程的大致行為就是這樣了。

開工寫程式碼咯~

起名字

在開始寫程式碼之前,要先給這個自定義控制元件起一個名字,又哦豁。。。不會起名字, 就叫:裁剪圖片控制元件(MCropImageView) 吧。不要問我M字母是啥含義,我不會告訴你的。

編寫程式碼

寬高相等矩陣測量

測量比較簡單,具體請看相關程式碼:

    @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);
        }
    }
複製程式碼

鋪滿居中

鋪滿的原理上文已經講到了,對應的公式如下:

控制元件寬度/圖片寬度 = 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);
    }
複製程式碼

有關Matrix的set 、 pre、post方法呼叫順序,這裡簡單說一下(個人理解,有錯還望指出 ),可以把Matrix的操作看成佇列,post方法新增到佇列的尾部,pre新增到佇列的頭部,而set方法則重置佇列

看看鋪滿居中的效果:

在這裡插入圖片描述

單指滑動

單指滑動,在上文已經講到GestureDetector.SimpleOnGestureListener內部介面用來處理手勢滑動,重寫以下介面方法:

    // 處理手指滑動
    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 左正右負 所以這裡取相反數
                mMatrix.postTranslate(-distanceX, -distanceY);
                setImageMatrix(mMatrix);
                return true;
            }
            return super.onScroll(e1, e2, distanceX, distanceY);
        }
    };
複製程式碼

獲取到手指滑動的距離,對圖片矩陣進行平移Matrix.postTranslate(),但在x軸方向獲取到的滑動距離右負左正,y軸方向獲取到的滑動距離上正下負,跟實際平移的值相反,那麼平移值Matrix.postTranslate(-distanceX, -distanceY)取滑動距離的負數。

單指滑動還有一個效果,越界下的阻尼效果,看看效果圖:

在這裡插入圖片描述
很明顯圖片跟隨手指滑動,距離控制元件邊緣越近,阻尼越大。那麼很明顯需要獲取圖片邊緣距離控制元件的距離,然後根據滑動偏移量進行計算。為了獲取圖片邊緣距離控制元件的距離,就需要獲取圖片的位置資訊。那麼怎樣才能獲取圖片位置資訊呢?

在ViewGroup的transformPointToViewLocal方法中有這樣一段程式碼:

    if (!child.hasIdentityMatrix()) {
        child.getInverseMatrix().mapPoints(point);
    }
複製程式碼

如果child所對應的矩陣發生過旋轉、縮放等變化的話(補間動畫不算,因為是臨時的),會通過矩陣的mapPoints方法來將觸控點轉換到矩陣變換後的座標。

沒錯,我們也可以用矩陣的mapRect方法來將圖片的座標及尺寸轉換一下,就像這樣:

在這裡插入圖片描述
這樣就可以獲取到圖片的矩形區域,相關方法如下:

    // 獲取圖片矩陣區域
    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,分別對應的程式碼如下:

   // 獲取圖片矩陣
   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();
複製程式碼

好了,這樣就可以準確判定圖片是否越界。接下來我們看看越界狀態下的阻尼演算法是怎麼計算的,有什麼規律:

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

  1. 左越界
    在這裡插入圖片描述

可以看到在向左滑動的情況下,圖片左側距離控制元件左側距離越大,阻力越大。通俗一點,手指滑動的距離越大,圖片跟隨手指滑動的距離就越小,那麼可以根據以下公式獲取阻尼係數:

 最大阻尼數 / 最大偏移量 * leftEdgeDistanceLeft
複製程式碼

最大阻尼數預設取值為9,最大偏移量為控制元件寬度的三分之一,對應的程式碼如下:

   // 獲取圖片矩陣
   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;
   // 圖片左側越界並且圖片右側未越界
   if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight > 0) {
       // distanceX < 0 表示繼續向右滑動
       if (distanceX < 0) {
           if (leftEdgeDistanceLeft < maxOffsetWidth) {
               // DAMP_FACTOR = 9 係數越大阻尼越大  +1防止ratio為0
               int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * leftEdgeDistanceLeft) + 1;
               distanceX /= ratio;
           } else {
               // 圖片向右滑動超過了最大偏移量 圖片則不平移
               distanceX = 0;
           }
       }
       // 向左滑動不做處理 預設取值distanceX
   }
複製程式碼
  1. 左右越界
    在這裡插入圖片描述

左右越界的情況與左越界的情況正好相反,距離控制元件邊緣越近,圖片阻力越大。那麼怎麼判定圖片距離控制元件邊緣越近,這裡分兩種情況,圖片中點在控制元件中點左側以及圖片中點在控制元件中點右側。第一種情況圖片中點在控制元件中點左側,向左滑動阻力越大,向右滑動阻力為0;第二種情況圖片中點在控制元件中點的右側,向右滑動阻力越大,向左滑動阻力為0。

來看看程式碼怎麼寫:

    // 圖片左側越界並且圖片右側越界
    if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight < 0) {
        // 控制元件寬度的一半
        int halfWidth = getWidth() / 2;
        // 獲取圖片中點x座標
        float centerX = (rectF.right - rectF.left) / 2 + rectF.left;
        // 圖片中點x座標是否右側偏移
        boolean rightOffsetCenterX = centerX >= halfWidth;
        // 右側偏移並且向右滑動
        if (distanceX < 0 && rightOffsetCenterX) {
            // centerX - halfWidth 圖片右側偏移量
            int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (centerX - halfWidth)) + 1;
            distanceX /= ratio;
        }
        // 左側偏移並且向左滑動
        else if (distanceX > 0 && !rightOffsetCenterX) {
            // halfWidth - centerX 左側的偏移量
            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;

                // 圖片左側越界並且圖片右側未越界
                if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight > 0) {
                    // distanceX < 0 表示繼續向右滑動
                    if (distanceX < 0) {
                        if (leftEdgeDistanceLeft < maxOffsetWidth) {
                            // DAMP_FACTOR = 9 係數越大阻尼越大  +1防止ratio為0
                            int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * leftEdgeDistanceLeft) + 1;
                            distanceX /= ratio;
                        } else {
                            // 圖片向右滑動超過了最大偏移量 圖片則不平移
                            distanceX = 0;
                        }
                    }
                    // 向左滑動不做處理 預設取值distanceX
                }
                // 圖片右側越界並且圖片左側未越界 (同上處理)
                else if (rightEdgeDistanceRight < 0 && leftEdgeDistanceLeft < 0) {
                    // distanceX > 0 表示繼續向左滑動
                    if (distanceX > 0) {
                        if (rightEdgeDistanceRight > -maxOffsetWidth) {
                            int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * -rightEdgeDistanceRight) + 1;
                            distanceX /= ratio;
                        } else {
                            // 圖片右側距離控制元件右側超過最大偏移量 圖片則不平移
                            distanceX = 0;
                        }
                    }
                }
                // 圖片左側越界並且圖片右側越界
                else if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight < 0) {
                    // 控制元件寬度的一半
                    int halfWidth = getWidth() / 2;
                    // 獲取圖片中點x座標
                    float centerX = (rectF.right - rectF.left) / 2 + rectF.left;
                    // 圖片中點x座標是否右側偏移
                    boolean rightOffsetCenterX = centerX >= halfWidth;
                    // 右側偏移並且向右滑動
                    if (distanceX < 0 && rightOffsetCenterX) {
                        // centerX - halfWidth 圖片右側偏移量
                        int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (centerX - halfWidth)) + 1;
                        distanceX /= ratio;
                    }
                    // 左側偏移並且向左滑動
                    else if (distanceX > 0 && !rightOffsetCenterX) {
                        // halfWidth - centerX 左側的偏移量
                        int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (halfWidth - centerX)) + 1;
                        distanceX /= ratio;
                    }
                }

                // 上下越界 處理方式同左右處理方式一樣 本可以提成一個方法但為了方便理解先這樣了
                // 圖片上側越界並且圖片下側未越界
                if (topEdgeDistanceTop > 0 && bottomEdgeDistanceBottom > 0) {
                    // distanceY < 0 表示圖片繼續向下滑動
                    if (distanceY < 0) {
                        if (topEdgeDistanceTop < maxOffsetHeight) {
                            // 獲取阻尼比例
                            int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * topEdgeDistanceTop) + 1;
                            distanceY /= ratio;
                        } else {
                            // 向下滑動超過了最大偏移量 則圖片不滑動
                            distanceY = 0;
                        }
                    }
                }
                // 圖片下側越界並且圖片上側未越界
                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座標
                    float centerY = (rectF.bottom - rectF.top) / 2 + rectF.top;
                    // 圖片中點y座標是否向下偏移
                    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);
        }
複製程式碼

雙指縮放

雙指縮放的原理在上文已經提及過了,重寫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) {
        }
    };
複製程式碼

回彈

在手指抬起時,圖片在某種狀態下會出現回彈動效,這裡某種狀態指的是越界&圖片的縮放比例大於一定的閾值&圖片的縮放比例小於一定的閾值三種狀態,回彈無非改變圖片矩陣的setTranslation,setScale值。當我們需要監聽手指抬起的狀態時,都是直接重寫onTouchEvent去實現:

    @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;
    }
複製程式碼

為了防止父類攔截事件,一般會在手指按下,抬起呼叫requestDisallowInterceptTouchEvent方法來避免事件衝突。 getScale方法如下,獲取圖片矩陣的縮放比例:

    private float getScale() {
        float[] values = new float[9];
        mMatrix.getValues(values);
        return values[Matrix.MSCALE_X];
    }
複製程式碼

縮小放大的動畫怎麼實現呢?知道了開始與結束的縮放比例,在動畫回撥介面中動態設定 mMatrix.setValues(values)來實現縮小放大的效果,可現實很骨感,效果相去甚遠,縮放中心點PivotX和PivotY始終在圖片原點,同時Matrix並沒有提供設定縮放中心點的方法。看來只能老老實實的使用Matrix.postScale(float sx, float sy, float px, float py)方法,同時設定縮放中心點為雙指的中點座標ScaleGestureDetector.getFocusX()。注意:sx,sx是相對值,相對上一個終點的縮放值。

相對值,多縮放一次與少縮放一次圖片的狀態完全不一樣,那麼必須控制縮放次數,由於ValueAnimator回撥次數在不同的機型上並不一樣,那麼就不能用ValueAnimator的回撥來實現動畫,那麼怎麼做呢?

emmmm,你一定會想到Handler,既可以控制次數還可以控制訊息延時。知道了開始與結束縮放點,也知道了縮放次數,那麼怎麼獲取縮放相對值呢,利用Math.pow數學公式:

      /**
     * 計算d的1/count次冪
     *
     * @param d
     * @param count 開根的次數
     * @return 相對值
     */
    private static float getRelativeValue(double d, double count) {
        if (count == 0) {
            return 1F;
        }
        count = 1 / count;
        return (float) Math.pow(d, count);
    }
複製程式碼

接下來就是傳送訊息與接收訊息:

    /**
     * 傳送訊息
     *
     * @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);
    }
   
   // 呼叫 省略前面 ...   
    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) {
            // 防止記憶體洩露
            mHandler.removeCallbacksAndMessages(null);
        }
        super.onDetachedFromWindow();
    }
複製程式碼

回彈還剩最後一種情況越界,在上文中已經提到了越界的四種(上下左右)情況,手指抬起後圖片平移到控制元件邊緣。所謂的平移,就是從一點平移到另一點,那麼怎麼獲取起點與結束點呢?

首先需要判定越界,根據getMatrixRectF圖片矩陣,程式碼已經很清晰:

    // 邊界檢測
    private void boundCheck() {
        // 獲取圖片矩陣
        RectF rectF = getMatrixRectF();

        if (rectF.left >= 0) {
            // 左越界
        }

        if (rectF.top >= 0) {
            // 上越界
        }

        if (rectF.right <= getWidth()) {
            // 右越界
        }

        if (rectF.bottom <= getHeight()) {
            // 下越界
        }
    }
複製程式碼

在左越界的情況下,起點為rectF.left,結束點為0;同理上越界的起點rectF.top,結束點0;那麼右越界起點與結束點呢?有小夥伴會說那還不簡單,不就是rectF.right,getWidth()嗎?

很遺憾,你又哦豁了,不得不提一下,圖片的矩陣的平移是以左上角為基點,那麼右越界的起點同樣為rectF.left,結束點為:

    起點 + 圖片右側距離控制元件右側的距離
複製程式碼

圖片右側距離控制元件右側的距離為getWidth() - rectF.right,那麼結束點的座標為rectF.left + getWidth() - rectF.right;同理下越界的起點為rectF.top,結束點getHeight() - rectF.bottom + rectF.top。有了起點與結束點,那麼平移就很容易了:

    /**
     * 開始越界動畫
     *
     * @param start      開始點座標
     * @param end        結束點座標
     * @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();
    }
複製程式碼

好了,看看效果:

在這裡插入圖片描述

九宮線條

在上文已經提到九宮線條的規律: 圖片在滑動或縮放態下,會出現九宮格白色線條,線條始終平分控制元件內的圖片為九等分,滑動或縮放停止線條消失,再次滑動或縮放線條出現,手指抬起後線條消失。那麼從這句話中我們可以得出以下結論:

  1. 有關繪製涉及到onDraw()方法的重寫

  2. 線條的顯示區域為圖片與控制元件的交集

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

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

    // 開始點
    float startX = 0;
    float startY = 0;
    // 結束點
    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;
複製程式碼

獲取到線條繪製的區域,那麼怎麼繪製線條?繪製多少線條?就比較容易了:

        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);
        }
複製程式碼

怎麼控制線條的顯示消失,注意顯示消失的規則,縮放或滑動停止線條消失,再次滑動或縮放線條顯示,以此類推,絕大部分人會想到怎麼判定滑動或縮放停止?

寫控制元件很多時候就是這樣,不知不覺就入坑了,一頭扎進裡面,茶不思飯不想。。。然而這一切並沒有什麼用,最後還得換方案。

說下為什麼不行,你會在手勢MotionEvent.ACTION_MOVE事件判定滑動或縮放停止,但同時GestureDetector與ScaleGestureDetector也在消費滑動事件,導致判定不準確。那麼怎麼解決呢?

還記得Android原始碼長按事件的處理方式嗎?相關程式碼如下:

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延時傳送長按事件,在延遲週期內,如果發生滑動,則移除長按事件,反之未發生滑動則觸發長按事件。

借鑑長按事件的處理方式:

    // 繪製九宮線條
    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();
        }
    }
複製程式碼

效果就像這樣:

在這裡插入圖片描述
哈哈哈~,小紅書的圖片裁剪控制元件喜歡嗎?想看更多炫酷控制元件,請搜尋關注公眾號:控制元件人生

在這裡插入圖片描述
你可以留言,告訴小編想實現什麼樣的炫酷控制元件?小編會每週選取炫酷的控制元件進行講解。

由於篇幅原因,文章到這裡就差不多了,有關左下角留白,填充效果,以及聯動效果,將在下一篇講解,打造屬於你自己的CoordinatorLayout效果,喜歡的小夥伴被忘記關注控制元件人生(新公眾號),同大家一起成長。

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

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

相關文章