從PhotoView看Android手勢監(jiān)聽實踐

PhotoView 在做圖片縮放的組件或者有類似的需求功能時提供了極大的便利,自身功能也是十分強大。比如

  • 支持手勢雙擊 多指觸摸 輕擊
  • 完美解決了和一些scroll控件的沖突 比如ViewPager
  • 有drag fling scale 這些操作的回調
  • 兼容性十分好 基本覆蓋了全版本 (低版本部分功能不支持)

PhotoView這個library中最重要核心的部分就是手勢的操作,所以這篇文章主要分析整個PhotoView的手勢設計思想,學習其中的實現(xiàn)方式。相信看完后對于基本的手指縮放,雙擊,以及多指操作和事件處理有一個更好的理解。

手勢版本兼容和監(jiān)聽

先放一張整個library的結構圖

library structure

看上面的圖中能看到有一個類叫做 VersionedGestureDetector ,這就是整個手勢的入口,它實際上是一個代理類,里面就一個靜態(tài)方法 newInstance,通過不同的版本拿到對應的GestureDetector,不過這個并不是系統(tǒng)內(nèi)部的手勢,這個是一個自己創(chuàng)建的抽象接口

public interface GestureDetector {

    public boolean onTouchEvent(MotionEvent ev);

    public boolean isScaling();

    public boolean isDragging();

    public void setOnGestureListener(OnGestureListener listener);

}

整個手勢監(jiān)聽的結構是一種高版本繼承低版本,必要時進行重寫的思想,類之間的繼承圖是這樣的

Version implement extends

整個GestureDetector提供了 OnGestureListener 監(jiān)聽的注冊,這個監(jiān)聽從PhotoViewAttacher傳遞到VersionedGestureDetector,然后到上面繼承實現(xiàn)的每個類中。
那么還有一個問題就是在什么地方把onTouchEvent這個方法從實現(xiàn)類中注入到PhotoViewAttacher中,我們直接搜一下這個touchEvent在哪調用的

 public boolean onTouch(View v, MotionEvent ev) {
        boolean handled = false;
        if (mZoomEnabled && hasDrawable((ImageView) v)) {
            ...
            // Try the Scale/Drag detector
            if (null != mScaleDragDetector) {
                boolean wasScaling = mScaleDragDetector.isScaling();
                boolean wasDragging = mScaleDragDetector.isDragging();
                //注入onTouchEvent
                handled = mScaleDragDetector.onTouchEvent(ev); 
                boolean didntScale = !wasScaling && !mScaleDragDetector.isScaling();
                boolean didntDrag = !wasDragging && !mScaleDragDetector.isDragging();
                mBlockParentIntercept = didntScale && didntDrag;
            }
            // Check to see if the user double tapped
            if (null != mGestureDetector && mGestureDetector.onTouchEvent(ev)) {
                handled = true;
            }
        }
        return handled;
    }

從這段代碼可以看出在 onTouch 中如果兩個變量滿足,就會調用 onTouchEvent 把事件傳遞進去,而onTouch實際上是ImageView設置的setOnTouchListener的回調實現(xiàn)。

imageView.setOnTouchListener(this);

到這里,其實就很清晰了,imageView觸發(fā)了Touch事件并且將這個event傳遞給抽象的自定義GestureDetector處理。在這里,事件的處理會調用設置進來的OnGestureListener的對應方法,這也是PhotoView這個庫如何實現(xiàn)手勢拖動,滑動,縮放的重點。

public interface OnGestureListener {

    public void onDrag(float dx, float dy);

    public void onFling(float startX, float startY, float velocityX,
                        float velocityY);

    public void onScale(float scaleFactor, float focusX, float focusY);

}

然后繼續(xù)往下看event是怎么處理的。先從最低版本的實現(xiàn)開始看,也就是CupcakeGestureDetector,主要就是看onTouchEvent的實現(xiàn)。

 @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                mVelocityTracker = VelocityTracker.obtain();
                if (null != mVelocityTracker) {
                    mVelocityTracker.addMovement(ev);
                } else {
                    LogManager.getLogger().i(LOG_TAG, "Velocity tracker is null");
                }

                mLastTouchX = getActiveX(ev);
                mLastTouchY = getActiveY(ev);
                mIsDragging = false;
                break;
            }

            case MotionEvent.ACTION_MOVE: {
                final float x = getActiveX(ev);
                final float y = getActiveY(ev);
                final float dx = x - mLastTouchX, dy = y - mLastTouchY;

                if (!mIsDragging) {
                    // Use Pythagoras to see if drag length is larger than
                    // touch slop
                    mIsDragging = Math.sqrt((dx * dx) + (dy * dy)) >= mTouchSlop;
                }

                if (mIsDragging) {
                    mListener.onDrag(dx, dy);
                    mLastTouchX = x;
                    mLastTouchY = y;

                    if (null != mVelocityTracker) {
                        mVelocityTracker.addMovement(ev);
                    }
                }
                break;
            }

            case MotionEvent.ACTION_CANCEL: {
                // Recycle Velocity Tracker
                if (null != mVelocityTracker) {
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                break;
            }

            case MotionEvent.ACTION_UP: {
                if (mIsDragging) {
                    if (null != mVelocityTracker) {
                        mLastTouchX = getActiveX(ev);
                        mLastTouchY = getActiveY(ev);

                        // Compute velocity within the last 1000ms
                        mVelocityTracker.addMovement(ev);
                        mVelocityTracker.computeCurrentVelocity(1000);

                        final float vX = mVelocityTracker.getXVelocity(), vY = mVelocityTracker
                                .getYVelocity();

                        // If the velocity is greater than minVelocity, call
                        // listener
                        if (Math.max(Math.abs(vX), Math.abs(vY)) >= mMinimumVelocity) {
                            mListener.onFling(mLastTouchX, mLastTouchY, -vX,
                                    -vY);
                        }
                    }
                }

                // Recycle Velocity Tracker
                if (null != mVelocityTracker) {
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                break;
            }
        }

        return true;
    }

代碼比較長,不過還是比較清晰簡單的,在ACTION_DOWN的事件中初始化了一個VelocityTracker,這個是系統(tǒng)用于監(jiān)聽速度的一個類,獲取對象的方法為obtain,看到這種形式就應該想到是設計模式中的享元模式,Message其實也是一樣,在DOWN事件同時初始化了一個mIsDragging的flag。
然后就是MOVE事件,如果mIsDragging為false,也就是當前沒有處于Drag狀態(tài),就判斷滑動的相對位移是否大于系統(tǒng)認定的一個滑動大小。如果是的話就回調設置進來的 onDrag(dx, dy) 方法。
最后就是UP事件,如果當前已經(jīng)處于Drag狀態(tài),在手指釋放的瞬間,通過前面所說的VelocityTracker的一個方法 computeCurrentVelocity 來計算速度,這里傳進去1000,也就是計算前面1000ms的平均速度,如果大于一個可以判定為Fling狀態(tài)的最小速度,那么就直接回調onFling(mLastTouchX, mLastTouchY, -vX,-vY),最后再將VelocityTracker回收掉。
所以這個類是一個基礎的手勢處理,主要是回調drag,fling兩個方法,而真正的scale方法并不是在這個類實現(xiàn)了,也說明了scale是存在版本限制的。
然后我們繼續(xù)看EclairGestureDetector,它繼承了上面的類,也就是說擁有了父類這些方法,而且看到這個類的API限制為5。同樣的我們直接看onTouchEvent方法。

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        final int action = ev.getAction();
        switch (action & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN:
                mActivePointerId = ev.getPointerId(0);
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                mActivePointerId = INVALID_POINTER_ID;
                break;
            case MotionEvent.ACTION_POINTER_UP:
                final int pointerIndex = Compat.getPointerIndex(ev.getAction());
                final int pointerId = ev.getPointerId(pointerIndex);
                if (pointerId == mActivePointerId) {
                    final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
                    mActivePointerId = ev.getPointerId(newPointerIndex);
                    mLastTouchX = ev.getX(newPointerIndex);
                    mLastTouchY = ev.getY(newPointerIndex);
                }
                break;
        }

        mActivePointerIndex = ev
                .findPointerIndex(mActivePointerId != INVALID_POINTER_ID ? mActivePointerId
                        : 0);
        return super.onTouchEvent(ev);
    }

這個API為5的限制主要是因為加入了多指監(jiān)聽,如果想要詳細的了解多指監(jiān)聽,可以看官方文檔,這里簡短的描述一下,如果想要監(jiān)聽多指,首先在獲取action時,需要使用 action & MotionEvent.ACTION_MASK ,普通的action是拿不到ACTION_POINTER_UP的事件的,這個事件只有在手指UP并且屏幕上依然還有手指時才會回調,這里所做的工作就是將x,y的坐標切換到新的手指上,修正坐標計算的偏差,在多指操作上這個步驟十分重要。
最后看FroyoGestureDetector這個類,這個類的api限制是8,因為系統(tǒng)在8之后才加入了ScaleGestureDetector這個類。

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        mDetector.onTouchEvent(ev);
        return super.onTouchEvent(ev);
    }

在這個onTouchEvent中只是加入了一個ScaleGestureDetector的對象來進行監(jiān)聽縮放。下面就會分析這個類是做什么的。

手勢縮放監(jiān)聽ScaleGestureDetector

先看一下這個類的系統(tǒng)注釋

/**
 * Detects scaling transformation gestures using the supplied {@link MotionEvent}s.
 * The {@link OnScaleGestureListener} callback will notify users when a particular
 * gesture event has occurred.
 *
 * This class should only be used with {@link MotionEvent}s reported via touch.
 */

從注釋可以看出這個類主要就是用來檢測手勢變換,并且有一個callback來通知用戶scale發(fā)生。

        ScaleGestureDetector.OnScaleGestureListener mScaleListener = new ScaleGestureDetector.OnScaleGestureListener() {

            @Override
            public boolean onScale(ScaleGestureDetector detector) {
                float scaleFactor = detector.getScaleFactor();

                if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor))
                    return false;

                mListener.onScale(scaleFactor,
                        detector.getFocusX(), detector.getFocusY());
                return true;
            }

            @Override
            public boolean onScaleBegin(ScaleGestureDetector detector) {
                return true;
            }

            @Override
            public void onScaleEnd(ScaleGestureDetector detector) {
                // NO-OP
            }
        };
        mDetector = new ScaleGestureDetector(context, mScaleListener);

在前面所說的FroyoGestureDetector中,就是創(chuàng)建了這樣一個對象,并且實現(xiàn)了一個OnScaleGestureListener 的接口,用法也是十分的簡單,onScale 這個方法中可以獲取縮放倍數(shù),以及控制點,所以在這里將結果通過 mListener.onScale(scaleFactor,detector.getFocusX(), detector.getFocusY()) 回調出去,這些參數(shù)用于后面通過Matrix來縮放ImageView。

我們繼續(xù)跟到系統(tǒng)ScaleGestureDetector里面看看是怎么判斷縮放的。
首先第一步就是確定縮放的焦點,簡單的雙擊和單擊焦點就不說了,主要是多指焦點的判斷。
在多指情況下分為幾種事件,其中POINT_UP的計算和非POINT_UP的事件焦點計算是不一樣的,來一段簡短的代碼

        final int count = event.getPointerCount();
        final boolean pointerUp = action == MotionEvent.ACTION_POINTER_UP;
        final int skipIndex = pointerUp ? event.getActionIndex() : -1;
        final int div = pointerUp ? count - 1 : count;
            ...
            for (int i = 0; i < count; i++) {
                if (skipIndex == i) continue;
                sumX += event.getX(i);
                sumY += event.getY(i);
            }
            focusX = sumX / div;
            focusY = sumY / div;

可以看到是將所有手指的坐標加起來除上手指的個數(shù),并且其中考慮了POINT_UP,并且將抬起的手指坐標移出計算的范圍。
第二步就是通過焦點坐標和每個手指的坐標,計算一個偏差,源碼里稱之為span。
計算的代碼比較長,這里就不貼了,先分別計算每個點距離焦點的X軸距離和Y軸距離,求得一個平均數(shù),然后一個 Math.hypot 求得最終的span。這個span主要是后面用來計算縮放比例的一個值。
第三步就是根據(jù)前面的span來判斷是否滿足Scale的標準,如果滿足就先觸發(fā) onScaleBegin 回調

        if (!mInProgress && span >=  minSpan &&
                (wasInProgress || Math.abs(span - mInitialSpan) > mSpanSlop)) {
            mPrevSpanX = mCurrSpanX = spanX;
            mPrevSpanY = mCurrSpanY = spanY;
            mPrevSpan = mCurrSpan = span;
            mPrevTime = mCurrTime;
            mInProgress = mListener.onScaleBegin(this);
        }

這里有一個mInProgress 的返回值,是用來后面的onScale判定的,如果覆寫成false,那么后面的一系列回調都不會發(fā)生。
第四步就是在ACTION_MOVE的事件中產(chǎn)生縮放

        if (action == MotionEvent.ACTION_MOVE) {
            mCurrSpanX = spanX;
            mCurrSpanY = spanY;
            mCurrSpan = span;

            boolean updatePrev = true;

            if (mInProgress) {
                updatePrev = mListener.onScale(this);
            }

第五步也就是結束整個Scale,

        final boolean streamComplete = action == MotionEvent.ACTION_UP ||
                action == MotionEvent.ACTION_CANCEL || anchoredScaleCancelled;

        if (action == MotionEvent.ACTION_DOWN || streamComplete) {
            // Reset any scale in progress with the listener.
            // If it's an ACTION_DOWN we're beginning a new event stream.
            // This means the app probably didn't give us all the events. Shame on it.
            if (mInProgress) {
                mListener.onScaleEnd(this);

可以看到結束的場景很多,ACTION_DOWN ,ACTION_UP ,ACTION_CANCEL以及取消縮放設置都會導致 onScaleEnd 的回調。
到這里就分析完了整個ScaleGestureDetector是如何產(chǎn)生以及縮放比例的設置,以及焦點和結束所有的操作。

雙擊放大縮小以及單擊監(jiān)聽

相比于前面的內(nèi)容,這里就更簡單的, PhotoViewAttacher在構造函數(shù)中給了一個默認的單擊和雙擊的手勢實現(xiàn)

 mGestureDetector.setOnDoubleTapListener(new DefaultOnDoubleTapListener(this));

可以看到所有的實現(xiàn)其實是在DefaultOnDoubleTapListener這個類中。
當然為了擴展性,PhotoView也同樣提供了一個方法,可以讓我們在外部設置這個實現(xiàn)。

    @Override
    public void setOnDoubleTapListener(GestureDetector.OnDoubleTapListener newOnDoubleTapListener) {
        if (newOnDoubleTapListener != null) {
            this.mGestureDetector.setOnDoubleTapListener(newOnDoubleTapListener);
        } else {
            this.mGestureDetector.setOnDoubleTapListener(new DefaultOnDoubleTapListener(this));
        }
    }

這里就直接看DefaultOnDoubleTapListener的實現(xiàn)了

DefaultOnDoubleTapListener

這里比較難理解的一塊就是圖中的 onSingleTapConfirmed 方法,這個方法主要是判斷當前的點擊是否在ImageView上面,實際使用的話會發(fā)現(xiàn)如果點擊在ImageView的縮放之外是無法觸發(fā)單擊的那個事件的

            final RectF displayRect = photoViewAttacher.getDisplayRect();

            if (null != displayRect) {
                final float x = e.getX(), y = e.getY();

                // Check to see if the user tapped on the photo
                if (displayRect.contains(x, y)) {

                    float xResult = (x - displayRect.left)
                            / displayRect.width();
                    float yResult = (y - displayRect.top)
                            / displayRect.height();

                    photoViewAttacher.getOnPhotoTapListener().onPhotoTap(imageView, xResult, yResult);
                    return true;
                }
            }

這里有一個方法getDisplayRect,主要是根據(jù)我們設置的ScaleType轉換后的matrix的坐標進行一個變換得到一個新的RectF ,這個就是Imageview實際縮放后的邊界,再與Event進行比較看是否單擊發(fā)生在邊界之內(nèi),如果在里面,就會觸發(fā) onPhotoTap 這個回調。
這個類另一個就是雙擊的實現(xiàn)

    @Override
    public boolean onDoubleTap(MotionEvent ev) {
        if (photoViewAttacher == null)
            return false;

        try {
            float scale = photoViewAttacher.getScale();
            float x = ev.getX();
            float y = ev.getY();

            if (scale < photoViewAttacher.getMediumScale()) {
                photoViewAttacher.setScale(photoViewAttacher.getMediumScale(), x, y, true);
            } else if (scale >= photoViewAttacher.getMediumScale() && scale < photoViewAttacher.getMaximumScale()) {
                photoViewAttacher.setScale(photoViewAttacher.getMaximumScale(), x, y, true);
            } else {
                photoViewAttacher.setScale(photoViewAttacher.getMinimumScale(), x, y, true);
            }
        } catch (ArrayIndexOutOfBoundsException e) {
            // Can sometimes happen when getX() and getY() is called
        }

        return true;
    }

可以看到雙擊放大和縮小都是在這里產(chǎn)生的,根據(jù)當前的縮放比來決定下一次縮放到哪一個等級。

后記

經(jīng)過以上分析就能了解整個PhotoView關于手勢的一切實現(xiàn),如何產(chǎn)生拖拽,如何在手指抬起后依然滑動,如何實現(xiàn)多指的縮放,以及單擊和雙擊的事件響應。當然這篇文章只是講解手勢部分。
至于產(chǎn)生事件后,PhotoView是如何通過Matrix來進行變換在這里并沒有講解,因為Matrix這個類能使用的場景遠比這個需求復雜,不僅是平移和縮放,更能做到錯切以及3維變換,這個部分將會在以后的文章中詳細講解。

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,809評論 25 708
  • 我知道你在這個世界的某個角落煎熬,在被子里哭得一塌糊涂,心痛得快要死掉。 我知道你在中國的某個城市,生...
    云歸處vv閱讀 417評論 0 0
  • 歲月驚艷了流年 凡塵俗世迷了那雙清澈的眼 時光長河中 失去了多少 又收獲了多少 那個夏天炎炎烈日下 講述一個沒有結...
    遠方孤雁閱讀 170評論 1 2
  • 繼承概念:通過一個類,創(chuàng)建另一個類這樣新創(chuàng)建出來的類不僅擁有了原來的屬性和方法,而且還可以添加自己獨有的屬性和方法...
    愛琴寶閱讀 333評論 0 0
  • 多年的朋友,即使身處異地,也會為你著想。我多幸運我的人生中有你出現(xiàn),為我的青春添了一份活力,也讓我在無聊的時候有人...
    安瑾顔柒柒閱讀 239評論 0 0