天天看点

重新造轮子之仿手势下拉关闭图片效果

现在很多 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 这两种常用布局的触摸事件分发实现。重新造轮子的意义不正在于此,学习优秀的代码,巩固自己的知识吗?