天天看點

重新造輪子之仿手勢下拉關閉圖檔效果

現在很多 App 上檢視圖檔時都有手勢下拉關閉圖檔的效果,個人非常非常喜歡這種互動,就想也仿制一個。

重新造輪子之仿手勢下拉關閉圖檔效果

在 GitHub 上找到一個現成的 repo:MNImageBrowser 已經實作了相應的效果,這個庫設計的挺不錯的,也有很豐富的定制 API,不過仔細看過代碼以後發現一個問題,圖檔切換用的是 ViewPager,ViewPager 在展示大量圖檔時有發生 OOM 的風險,雖然也是有對應解決辦法的,但我還是想改用 RecyclerView 重新實作一下。

先學習一下 MNImageBrowser 實作相關效果的核心原理,他是在 ViewPager 外面又包了一個自定義的布局來攔截處理 MotionEvent,通過計算 Y 軸上的移動距離來移動縮放整個布局:

<RelativeLayout
        android:id="@+id/rl_black_bg"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#000000" />

    <com.maning.imagebrowserlibrary.view.MNGestureView
        android:id="@+id/mnGestureView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.maning.imagebrowserlibrary.view.MNViewPager
            android:id="@+id/viewPagerBrowser"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </com.maning.imagebrowserlibrary.view.MNGestureView>
           

自定義布局 MNGestureView 中的核心代碼都在 onInterceptTouchEvent 中:

@Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
            // 記錄手指觸摸螢幕的初始位置
                mDisplacementX = event.getRawX();
                mDisplacementY = event.getRawY();

                mInitialTy = getTranslationY();
                mInitialTx = getTranslationX();

                break;

            case MotionEvent.ACTION_MOVE:
                // get the delta distance in X and Y direction
                float deltaX = event.getRawX() - mDisplacementX;
                float deltaY = event.getRawY() - mDisplacementY;

                //隻有在不縮放的狀态才能下滑
                if(onCanSwipeListener!= null){
                    boolean canSwipe = onCanSwipeListener.canSwipe();
                    if(!canSwipe){
                        break;
                    }
                }

                // set the touch and cancel event
                if (deltaY >  && (Math.abs(deltaY) > ViewConfiguration.get(getContext()).getScaledTouchSlop() *  && Math.abs(deltaX) < Math.abs(deltaY) / )
                        || mTracking) {

                    onSwipeListener.onSwiping(deltaY);

                    setBackgroundColor(Color.TRANSPARENT);

                    mTracking = true;

                // 移動縮放整個 Layout
                    setTranslationY(mInitialTy + deltaY);
                    setTranslationX(mInitialTx + deltaX);

                    float mScale =  - deltaY / mHeight;
                    if (mScale < ) {
                        mScale = f;
                    }
                    setScaleX(mScale);
                    setScaleY(mScale);

                }
                if (deltaY < ) {
                    setViewDefault();
                }

                break;

            case MotionEvent.ACTION_UP:

                if (mTracking) {
                    mTracking = false;
                    float currentTranslateY = getTranslationY();

                    if (currentTranslateY > mHeight / ) {
                        onSwipeListener.downSwipe();
                        break;
                    }

                }
                setViewDefault();

                onSwipeListener.overSwipe();
                break;
        }
        // 始終傳回 false,不攔截觸摸事件
        return false;
    }
           

原理其實非常簡單,于是我就興高采烈地隻把布局中的 ViewPager 換成 RecyclerView了。結果一試試出了問題,手指向下滑動的過程中隻要稍稍在 X 軸方向上移動的距離大一些,RecyclerView 就會開始接管觸摸事件,外面的那層自定義的布局就再也收不到觸摸事件,即 RecyclerView 的 onInterceptTouchEvent 傳回 true 了。這樣外層的布局不僅無法再收到 ACTION_MOVE 事件随手指的滑動而移動,也收不到最後的 ACTION_UP 事件,即無法決定最後是退出界面還是恢複原來位置和大小。效果可以說是相當的失敗。

仔細分析一下,出現這個問題的原因也很簡單,由于自定義布局中的 onInterceptTouchEvent 始終傳回的是 false,他隻是在觸摸事件分發到他的過程中做相應的處理,是以一旦他的子 View 中有任何一個 onInterceptTouchEvent 傳回了 true,那他将再也接受不到任何的觸摸事件。 那為啥 ViewPager 就一直傳回的是 false 呢,它不也是支援 X 軸方向上的滑動的嗎?

Read the fucking source code!!!

@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        /*
         * This method JUST determines whether we want to intercept the motion.
         * If we return true, onMotionEvent will be called and we do the actual
         * scrolling there.
         */

        final int action = ev.getAction() & MotionEvent.ACTION_MASK;

        // Always take care of the touch gesture being complete.
        if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
            // Release the drag.
            if (DEBUG) Log.v(TAG, "Intercept done!");
            resetTouch();
            return false;
        }

        // Nothing more to do here if we have decided whether or not we
        // are dragging.
        if (action != MotionEvent.ACTION_DOWN) {
            if (mIsBeingDragged) {
                if (DEBUG) Log.v(TAG, "Intercept returning true!");
                return true;
            }
            if (mIsUnableToDrag) {
            // 注釋 1
                if (DEBUG) Log.v(TAG, "Intercept returning false!");
                return false;
            }
        }

        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                /*
                 * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
                 * whether the user has moved far enough from his original down touch.
                 */

                /*
                * Locally do absolute value. mLastMotionY is set to the y value
                * of the down event.
                */
                final int activePointerId = mActivePointerId;
                if (activePointerId == INVALID_POINTER) {
                    // If we don't have a valid id, the touch down wasn't on content.
                    break;
                }

                final int pointerIndex = ev.findPointerIndex(activePointerId);
                final float x = ev.getX(pointerIndex);
                final float dx = x - mLastMotionX;
                final float xDiff = Math.abs(dx);
                final float y = ev.getY(pointerIndex);
                final float yDiff = Math.abs(y - mInitialMotionY);
                if (DEBUG) Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);

                if (dx !=  && !isGutterDrag(mLastMotionX, dx)
                        && canScroll(this, false, (int) dx, (int) x, (int) y)) {
                    // Nested view has scrollable area under this point. Let it be handled there.
                    mLastMotionX = x;
                    mLastMotionY = y;
                    mIsUnableToDrag = true;
                    return false;
                }
                if (xDiff > mTouchSlop && xDiff * f > yDiff) {
                    if (DEBUG) Log.v(TAG, "Starting drag!");
                    mIsBeingDragged = true;
                    requestParentDisallowInterceptTouchEvent(true);
                    setScrollState(SCROLL_STATE_DRAGGING);
                    mLastMotionX = dx > 
                            ? mInitialMotionX + mTouchSlop : mInitialMotionX - mTouchSlop;
                    mLastMotionY = y;
                    setScrollingCacheEnabled(true);
                } else if (yDiff > mTouchSlop) {
                    // The finger has moved enough in the vertical
                    // direction to be counted as a drag...  abort
                    // any attempt to drag horizontally, to work correctly
                    // with children that have scrolling containers.
                    if (DEBUG) Log.v(TAG, "Starting unable to drag!");
                    // 注釋 2
                    mIsUnableToDrag = true;
                }
                if (mIsBeingDragged) {
                    // Scroll to follow the motion event
                    if (performDrag(x)) {
                        ViewCompat.postInvalidateOnAnimation(this);
                    }
                }
                break;
            }

            case MotionEvent.ACTION_DOWN: {
                /*
                 * Remember location of down touch.
                 * ACTION_DOWN always refers to pointer index 0.
                 */
                mLastMotionX = mInitialMotionX = ev.getX();
                mLastMotionY = mInitialMotionY = ev.getY();
                mActivePointerId = ev.getPointerId();
                mIsUnableToDrag = false;

                mIsScrollStarted = true;
                mScroller.computeScrollOffset();
                if (mScrollState == SCROLL_STATE_SETTLING
                        && Math.abs(mScroller.getFinalX() - mScroller.getCurrX()) > mCloseEnough) {
                    // Let the user 'catch' the pager as it animates.
                    mScroller.abortAnimation();
                    mPopulatePending = false;
                    populate();
                    mIsBeingDragged = true;
                    requestParentDisallowInterceptTouchEvent(true);
                    setScrollState(SCROLL_STATE_DRAGGING);
                } else {
                    completeScroll(false);
                    mIsBeingDragged = false;
                }

                if (DEBUG) {
                    Log.v(TAG, "Down at " + mLastMotionX + "," + mLastMotionY
                            + " mIsBeingDragged=" + mIsBeingDragged
                            + "mIsUnableToDrag=" + mIsUnableToDrag);
                }
                break;
            }

            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                break;
        }

        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(ev);

        /*
         * The only time we want to intercept motion events is if we are in the
         * drag mode.
         */
        return mIsBeingDragged;
           

我們來想象一下,要觸發手勢下滑的手勢基本條件是什麼?肯定是 Y 軸方向上的移動距離要大于 X 軸方向上的距離。那麼在第一個 ACTION_MOVE 到達 ViewPager 的 onInterceptTouchEvent 的時候就會走到 注釋2 處将 mIsUnableToDrag 設為 true,後面的 ACTION_MOVE 事件再來的時候直接在 注釋1 處就直接傳回false了。

那我們現在要實作的其實也比較簡單了,就看第一下的 ACTION_MOVE,一旦确認了是向下的手勢,就攔下來處理。這樣一分析,其實我們也不必加一個自定義布局包住 RecyclerView 了,因為 RecyclerView 有一個方法:addOnItemTouchListener,看看 API 的注釋,這個監聽可以攔截發到子View的觸摸事件或者本來要由RecyclerView自身來處理的事件。

/**
     * Add an {@link OnItemTouchListener} to intercept touch events before they are dispatched
     * to child views or this view's standard scrolling behavior.
     *
     * <p>Client code may use listeners to implement item manipulation behavior. Once a listener
     * returns true from
     * {@link OnItemTouchListener#onInterceptTouchEvent(RecyclerView, MotionEvent)} its
     * {@link OnItemTouchListener#onTouchEvent(RecyclerView, MotionEvent)} method will be called
     * for each incoming MotionEvent until the end of the gesture.</p>
     *
     * @param listener Listener to add
     * @see SimpleOnItemTouchListener
     */
    public void addOnItemTouchListener(OnItemTouchListener listener) {
        mOnItemTouchListeners.add(listener);
    }
           

我的實作如下:

mRecyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {

            private float mDisplacementX;
            private float mDisplacementY;
            private float mInitialTy;
            private float mInitialTx;
            private boolean mTracking;

            private boolean canSwipe(RecyclerView rv) {
                View view = rv.getChildAt();
                PhotoView photoView = view.findViewById(R.id.welfare_img);
                if (photoView.getScale() != ) {
                    return false;
                }
                return true;
            }

            @Override
            public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent event) {
                switch (event.getActionMasked()) {
                    case MotionEvent.ACTION_DOWN:
                        mDisplacementX = event.getRawX();
                        mDisplacementY = event.getRawY();

                        mInitialTy = rv.getTranslationY();
                        mInitialTx = rv.getTranslationX();

                        break;

                    case MotionEvent.ACTION_MOVE:
                        float deltaX = event.getRawX() - mDisplacementX;
                        float deltaY = event.getRawY() - mDisplacementY;

                        //隻有在不縮放的狀态才能下滑
                        if(!canSwipe(rv)){
                            break;
                        }

                        if (deltaY >  &&
                                (Math.abs(deltaY) >  touchSlop *  &&
                                        Math.abs(deltaX) < Math.abs(deltaY) / )) {
                            mTracking = true;
                            return true;
                        }

                    default:
                        break;

                }
                return false;
            }

            @Override
            public void onTouchEvent(RecyclerView rv, MotionEvent event) {
                switch (event.getActionMasked()) {
                    case MotionEvent.ACTION_MOVE:
                        saveBtn.setVisibility(View.GONE);
                        textView.setVisibility(View.GONE);
                        float deltaX = event.getRawX() - mDisplacementX;
                        float deltaY = event.getRawY() - mDisplacementY;

                        rv.setBackgroundColor(Color.TRANSPARENT);
                        rv.setTranslationY(mInitialTy + deltaY);
                        rv.setTranslationX(mInitialTx + deltaX);

                        float mScale =  - deltaY / ;
                        if (mScale < ) {
                            mScale = f;
                        }
                        rv.setScaleX(mScale);
                        rv.setScaleY(mScale);
                        rl_black_bg.setAlpha(mScale);

                        break;
                    case MotionEvent.ACTION_UP:
                        float currentTranslateY = rv.getTranslationY();

                        if (currentTranslateY >  / ) {
                            finish();
                            break;
                        }
                        saveBtn.setVisibility(View.VISIBLE);
                        textView.setVisibility(View.VISIBLE);
                        rv.setAlpha();
                        rv.setTranslationX();
                        rv.setTranslationY();
                        rv.setScaleX();
                        rv.setScaleY();
                        rv.setBackgroundColor(Color.BLACK);
                        break;
                    default:
                        break;
                }
            }

            @Override
            public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {

            }
        });
           

完整的修改連結:https://github.com/Cr321/GankIO/commit/e6e69127f95fb09722ef169c76fd7303fa093330

這次重新造輪子的過程收獲頗多,不僅讓自己鞏固了 Android 觸摸事件分發機制,也順便學習了 ViewPager 和 RecyclerView 這兩種常用布局的觸摸事件分發實作。重新造輪子的意義不正在于此,學習優秀的代碼,鞏固自己的知識嗎?