Android侧滑退出靠谱方案

滑动返回是ios设备中默认支持的一种滑动退出效果,由于IPhone设备没有返回键,所以滑动退出使用起来十分方便。而如今随着手机屏幕越来越大,而单手使用手机的情况愈发频繁,所以在Android端添加测滑返回也是各大app的一项趋势,今天我们就通过分析下实现测滑退出的几种方式,来实现一套自己的“测滑退出”方案。

滑动返回是ios设备中默认支持的一种滑动退出效果,由于IPhone设备没有返回键,所以滑动退出使用起来十分方便。而如今随着手机屏幕越来越大,而单手使用手机的情况愈发频繁,所以在Android端添加测滑返回也是各大app的一项趋势,今天我们就通过分析下实现测滑退出的几种方式,来实现一套自己的“测滑退出”方案。

一、滑动返回案例

网易新闻:


网易.gif

今日头条:

头条.gif

上面是网易新闻和今日头条中实现的滑动返回样式,从gif图中我们看到,网易新闻在测滑时底部蒙层有一个透明度的变化,而底部Activity样式并没发生变化。而头条中实现的样式中,底部Activity(前一个Activity)有一个渐变的动画。通过这两种不同的样式,我们可以将“测滑退出”分为两个过程:

  1. 实现当前Activity跟随手指进行滑动;
  2. 展现底部(上一级)Activity的view,并对其进行相应操作(各种动画);

而常见的实现方案有两种,其中一种为:“透明主题样式方案”,另一种为:“视觉差方案”。这两种方案针对上述过程1并无差别,主要差别在过程2。下面将分别从“测滑退出”的两个过程来进行具体分析。

二、实现当前Activity跟随手指进行滑动

而实现此效果可以有以下几种方式:

  • 重写View的dispatchTouchEvent()方法及onTouchEvent():参考 swipeback
  • 结合GestureDetector类实现:对于GestureDetector这个类,不了解的可以参考官方文档GestureDetector
  • DrawerLayout/SlidingPaneLayout:采用DrawerLayout实现时,需要修改DrawerLayout的滑动范围,可以采用反射的方式修改其私有属性mEdgeSize,将DrawLayout修改为划出后为全屏幕。具体DrawerLayout使用可参考 Android 之 DrawerLayout 详解DrawerLayout滑动范围的设置
  • 结合ViewDragHelper类实现:ViewDragHelper类提供了一系列用于用户拖动子view的辅助方法和相关状态记录的工具类,如DrawerLayout等内部均使用ViewDragHelper来处理滑动相关操作。那么我们就采用ViewDragHelper来为一个自定义view添加滑动处理,来作为当前实现方案。

采用ViewDragHelper实现测滑

在使用ViewDragHelper实现具体功能之前,让我们首先来学习一下ViewDragHelper:

基本用法:
  1. 在自定义View构造方法中调用ViewDragHelper的静态工厂方法create()创建ViewDragHelper实例;

  2. 实现ViewDragHelper.Callback接口,具体方法解析如下:

    • void onViewDragStateChanged(int state)
      拖动状态改变时会调用此方法,状态state有STATE_IDLE、STATE_DRAGGING、STATE_SETTLING三种取值。

    • void onViewPositionChanged(View changedView, int left, int top, int dx, int dy)
      正在被拖动的View或者自动滚动的View的位置改变时会调用此方法。

    • void onViewCaptured(View capturedChild, int activePointerId)
      tryCaptureViewForDrag()成功捕获到子View时会调用此方法。

    • void onViewReleased(View releasedChild, float xvel, float yvel)
      拖动View松手时(processTouchEvent()的ACTION_UP)或被父View拦截事件时(processTouchEvent()的ACTION_CANCEL)会调用此方法。

    • void onEdgeTouched(int edgeFlags, int pointerId)
      ACTION_DOWN或ACTION_POINTER_DOWN事件发生时如果触摸到监听的边缘会调用此方法。edgeFlags的取值为EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM的组合。

    • boolean onEdgeLock(int edgeFlags)
      返回true表示锁定edgeFlags对应的边缘,锁定后的那些边缘就不会在onEdgeDragStarted()被通知了,默认返回false不锁定给定的边缘,edgeFlags的取值为EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM其中之一。

    • void onEdgeDragStarted(int edgeFlags, int pointerId)
      ACTION_MOVE事件发生时,检测到开始在某些边缘有拖动的手势,也没有锁定边缘,会调用此方法。edgeFlags取值为EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM的组合。可在此手动调用captureChildView()触发从边缘拖动子View的效果。

    • int getOrderedChildIndex(int index)
      在寻找当前触摸点下的子View时会调用此方法,寻找到的View会提供给tryCaptureViewForDrag()来尝试捕获。如果需要改变子View的遍历查询顺序可改写此方法,例如让下层的View优先于上层的View被选中。

    • int getViewHorizontalDragRange(View child)、int getViewVerticalDragRange(View child)
      返回给定的child在相应的方向上可以被拖动的最远距离,默认返回0。ACTION_DOWN发生时,若触摸点处的child消费了事件,并且想要在某个方向上可以被拖动,就要在对应方法里返回大于0的数。
      被调用的地方有三处:

      • 在checkTouchSlop()中被调用,返回值大于0才会去检查mTouchSlop。在ACTION_MOVE里调用tryCaptureViewForDrag()之前会调用checkTouchSlop()。如果checkTouchSlop()失败,就不会去捕获View了。
      • 如果ACTION_DOWN发生时,触摸点处有子View消费事件,在shouldInterceptTouchEvent()的ACTION_MOVE里会被调用。如果两个方向上的range都是0(两个方法都返回0),就不会去捕获View了。
      • 在调用smoothSlideViewTo()时被调用,用于计算自动滚动要滚动多长时间,这个时间计算出来后,如果超过最大值,最终时间就取最大值,所以不用担心在getView[Horizontal|Vertical]DragRange里返回了不合适的数导致计算的时间有问题,只要返回大于0的数就行了。
    • boolean tryCaptureView(View child, int pointerId)
      在tryCaptureViewForDrag()中被调用,返回true表示捕获给定的child。tryCaptureViewForDrag()被调用的地方有

      • shouldInterceptTouchEvent()的ACTION_DOWN里
      • shouldInterceptTouchEvent()的ACTION_MOVE里
      • processTouchEvent()的ACTION_MOVE里
    • int clampViewPositionHorizontal(View child, int left, int dx)、int clampViewPositionVertical(View child, int top, int dy)
      child在某方向上被拖动时会调用对应方法,返回值是child移动过后的坐标位置,clampViewPositionHorizontal()返回child移动过后的left值,clampViewPositionVertical()返回child移动过后的top值。
      两个方法被调用的地方有两处:

      • 在dragTo()中被调用,dragTo()在processTouchEvent()的ACTION_MOVE里被调用。用来获取被拖动的View要移动到的位置。
      • 如果ACTION_DOWN发生时,触摸点处有子View消费事件,在shouldInterceptTouchEvent()的ACTION_MOVE里会被调用。如果两个方向上返回的还是原来的left和top值,就不会去捕获View了。
  3. 在onInterceptTouchEvent()方法里调用并返回ViewDragHelper的shouldInterceptTouchEvent()方法

  4. 在onTouchEvent()方法里调用ViewDragHelper()的processTouchEvent()方法。ACTION_DOWN事件发生时,如果当前触摸点下要拖动的子View没有消费事件,此时应该在onTouchEvent()返回true,否则将收不到后续事件,不会产生拖动。

  5. 上面几个步骤已经实现了子View拖动的效果,如果还想要实现fling效果(滑动时松手后以一定速率继续自动滑动下去并逐渐停止,类似于扔东西)或者松手后自动滑动到指定位置,需要实现自定义ViewGroup的computeScroll()方法,方法实现如下:

@Override
public void computeScroll() {
    if (mDragHelper.continueSettling(true)) {
        postInvalidate();
    }
}

并在ViewDragHelper.Callback的onViewReleased()方法里调用以下三个方法中任意一个:

  • settleCapturedViewAt(int finalLeft, int finalTop)
    以松手前的滑动速度为初速动,让捕获到的View自动滚动到指定位置。只能在Callback的onViewReleased()中调用。
  • flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop)
    以松手前的滑动速度为初速动,让捕获到的View在指定范围内fling。只能在Callback的onViewReleased()中调用。
  • smoothSlideViewTo(View child, int finalLeft, int finalTop)
    指定某个View自动滚动到指定的位置,初速度为0,可在任何地方调用。

如果要实现边缘拖动的效果,需要调用ViewDragHelper的setEdgeTrackingEnabled()方法,注册想要监听的边缘。然后实现ViewDragHelper.Callback里的onEdgeDragStarted()方法,在此手动调用captureChildView()传递要拖动的子View。

以上为ViewDragHelper的基本使用方法,更加详细的ViewDragHelper源码分析,请参考 Android ViewDragHelper源码解析

而通过上述对ViewDragHelper的了解,我们可以实现各个方向上的测滑退出(左、上、右、下),而仅通过调用以下方法即可:

/**
 * Enable edge tracking for the selected edges of the parent view.
 * The callback's {@link Callback#onEdgeTouched(int, int)} and
 * {@link Callback#onEdgeDragStarted(int, int)} methods will only be invoked
 * for edges for which edge tracking has been enabled.
 *
 * @param edgeFlags Combination of edge flags describing the edges to watch
 * @see #EDGE_LEFT
 * @see #EDGE_TOP
 * @see #EDGE_RIGHT
 * @see #EDGE_BOTTOM
 */
public void setEdgeTrackingEnabled(int edgeFlags) {
    mTrackingEdges = edgeFlags;
}
具体实现

下面将给出基于ViewDragHelper实现的支持滑动的自定义ViewGroup: SwipeBackLayout.java ,具体代码为:

public class SwipeBackLayout extends FrameLayout {

    private static String TAG = SwipeBackLayout.class.getSimpleName();
    private float mScrollThreshold = DEFAULT_SCROLL_THRESHOLD;

    private WeakReference<View> mContentViewRef;
    //mInsets保存当前内部contentView的margin
    private Rect mInsets = new Rect();

    private ViewDragHelper mDragHelper;
    private SwipeSlideCallback mSlideCallback;
    private int mContentLeft;
    private int mContentTop;

    private boolean mInLayout;

    private int mFlingVelocity = FLING_VELOCITY;
    private int mEdgeFlag = ViewDragHelper.EDGE_LEFT;

    private int mEdgeMode = SwipeConstantUtils.EDGEMODE_FULLSCREEN;

    //children中有需要滚动的view
    private View mScrollChildView;

    public SwipeBackLayout(Context context) {
        super(context);
        //初始化viewDragHelper
        mDragHelper = ViewDragHelper.create(this, 1f, new ViewDragCallback());
    }

    @TargetApi(Build.VERSION_CODES.KITKAT_WATCH)
    @Override
    public WindowInsets onApplyWindowInsets(WindowInsets insets) {
        int top = insets.getSystemWindowInsetTop();
        View contentView = getContentView();
        if(contentView != null) {
            if (contentView.getLayoutParams() instanceof MarginLayoutParams) {
                MarginLayoutParams params = (MarginLayoutParams)contentView.getLayoutParams();
                mInsets.set(params.leftMargin, params.topMargin + top, params.rightMargin, params.bottomMargin);
            }
        }
        return super.onApplyWindowInsets(insets);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        try {
            //交给viewDragHelper来处理
            return mDragHelper.shouldInterceptTouchEvent(event);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //交给viewDragHelper来处理
        mDragHelper.processTouchEvent(event);
        return true;
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        mInLayout = true;
        View contentView = getContentView();
        if(contentView != null) {
            int cleft = mContentLeft;
            int ctop = mContentTop;
            ViewGroup.LayoutParams params = contentView.getLayoutParams();
            if (params instanceof MarginLayoutParams) {
                cleft += ((MarginLayoutParams) params).leftMargin;
                ctop += ((MarginLayoutParams) params).topMargin;
            }
            contentView.layout(cleft, ctop,
                    cleft + contentView.getMeasuredWidth(),
                    ctop + contentView.getMeasuredHeight());
        }
        mInLayout = false;
    }

    @Override
    public void requestLayout() {
        if (!mInLayout) {
            super.requestLayout();
        }
    }

    @Override
    public void computeScroll() {
        //实现fling效果
        if (mDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    public void setContentView(View view) {
        if(mContentViewRef != null && mContentViewRef.get() != null) {
            mContentViewRef.clear();
        }
        mContentViewRef = new WeakReference<>(view);
    }

    public void setSlideCallback(SwipeSlideCallback slideCallback) {
        mSlideCallback = slideCallback;
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        recycle();
    }
    public void recycle() {
        if(mContentViewRef != null && mContentViewRef.get() != null) {
            mContentViewRef.clear();
        }
        mSlideCallback = null;
        removeAllViews();
    }

    public int getEdgeFlag() {
        return mEdgeFlag;
    }

    private boolean canChildScrollUp() {
        return mScrollChildView != null && ViewCompat.canScrollVertically(mScrollChildView, -1);
    }

    private boolean canChildScrollDown() {
        return mScrollChildView != null && ViewCompat.canScrollVertically(mScrollChildView, 1);
    }

    private boolean canChildScrollRight() {
        return mScrollChildView != null && ViewCompat.canScrollHorizontally(mScrollChildView, 1);
    }

    private boolean canChildScrollLeft() {
        return mScrollChildView != null && ViewCompat.canScrollHorizontally(mScrollChildView, -1);
    }

    /**
     * 设置滑动方向,left、right、top、bottom
     *
     * @param edgeFlag the edge flag
     */
    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
    public void setEdgeFlag(@EdgeFlag int edgeFlag) {
        mEdgeFlag = edgeFlag;
        mDragHelper.setEdgeTrackingEnabled(edgeFlag);
    }

    public void setEdgeMode(int mEdgeMode) {
        this.mEdgeMode = mEdgeMode;
    }

    /**
     * 解决滑动冲突,如果当前布局中有其他子view需要获取滑动时间,如viewPager等,则需设置scrollView.
     * @param scrollView : child scroll view
     */
    public void setChildScrollView(View scrollView) {
        this.mScrollChildView = scrollView;
    }

    private View getContentView() {
        if(mContentViewRef != null && mContentViewRef.get() != null) {
            return mContentViewRef.get();
        }
        Loger.e(TAG,"exception !!! content view ref is null !!!");
        return null;
    }

    private class ViewDragCallback extends ViewDragHelper.Callback {

        private float mScrollPercent;

        @Override
        public boolean tryCaptureView(View view, int pointerId) {

            // edgeMode == fullScreen表示全屏滑动,ret直接返回true,边缘滑动时,根据isEdgeTouched来做判断.
            boolean ret = mEdgeMode == SwipeConstantUtils.EDGEMODE_FULLSCREEN || (mEdgeMode == SwipeConstantUtils.EDGEMODE_EDGE &&
                mDragHelper.isEdgeTouched(mEdgeFlag, pointerId));

            boolean directionCheck = false;

            if (mEdgeFlag == ViewDragHelper.EDGE_LEFT || mEdgeFlag == ViewDragHelper.EDGE_RIGHT) {
                directionCheck = !mDragHelper.checkTouchSlop(ViewDragHelper.DIRECTION_VERTICAL, pointerId);
            } else if (mEdgeFlag == ViewDragHelper.EDGE_BOTTOM || mEdgeFlag == ViewDragHelper.EDGE_TOP) {
                directionCheck = !mDragHelper
                        .checkTouchSlop(ViewDragHelper.DIRECTION_HORIZONTAL, pointerId);
            }
            return ret && (view == getContentView()) && directionCheck;
        }

        @Override
        public int getViewHorizontalDragRange(View child) {
            return mEdgeFlag & (ViewDragHelper.EDGE_LEFT | ViewDragHelper.EDGE_RIGHT);
        }

        @Override
        public int getViewVerticalDragRange(View child) {
            return mEdgeFlag & (ViewDragHelper.EDGE_BOTTOM | ViewDragHelper.EDGE_TOP);
        }

        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
            View contentView = getContentView();
            if(contentView != null) {
                if ((mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
                    mScrollPercent = Math.abs((float)(left - mInsets.left)
                        / contentView.getWidth());
                }
                if ((mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
                    mScrollPercent = Math.abs((float)(left - mInsets.left)
                        / contentView.getWidth());
                }
                if ((mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
                    mScrollPercent = Math.abs((float)(top - mInsets.top)
                        / contentView.getHeight());
                }
                if ((mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
                    mScrollPercent = Math.abs((float)top
                        / contentView.getHeight());
                }
                mContentLeft = left;
                mContentTop = top;
                invalidate();
                if (mSlideCallback != null && mScrollPercent < SLIDE_MAX_PERCENT) {
                    mSlideCallback.onPositionChanged(mScrollPercent);
                }
                // SCROLLER_MAX_PERCENT = 0.99f
                if (mScrollPercent >= SCROLLER_MAX_PERCENT) {
                        // todo: 添加退出Activity操作,执行Activity的onBackPressed()方法
                        if (mSlideCallback != null) {
                            mSlideCallback.onSwipeFinished();
                        }
                }
            }
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            final int childWidth = releasedChild.getWidth();
            final int childHeight = releasedChild.getHeight();
            boolean fling = false;
            int left = mInsets.left, top = 0;
            if ((mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
                if (Math.abs(xvel) > mFlingVelocity) {
                    fling = true;
                }
                left = xvel >= 0 && (fling || mScrollPercent > mScrollThreshold)
                        ? childWidth + mInsets.left : mInsets.left;
            }
            if ((mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
                if (Math.abs(xvel) > mFlingVelocity) {
                    fling = true;
                }
                left = xvel <= 0 && (fling || mScrollPercent > mScrollThreshold)
                        ? -childWidth + mInsets.left : mInsets.left;
            }
            if ((mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
                if (Math.abs(yvel) > mFlingVelocity) {
                    fling = true;
                }
                top = yvel >= 0 && (fling || mScrollPercent > mScrollThreshold)
                        ? childHeight : 0;
            }
            if ((mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
                if (Math.abs(yvel) > mFlingVelocity) {
                    fling = true;
                }
                top = yvel <= 0 && (fling || mScrollPercent > mScrollThreshold)
                        ? -childHeight + mInsets.top : 0;
            }
            mDragHelper.settleCapturedViewAt(left, top);
            invalidate();
        }

        @Override
        public void onViewDragStateChanged(int state) {
            super.onViewDragStateChanged(state);
            if (mSlideCallback != null) {
                mSlideCallback.onStateChanged(state);
            }
        }

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            int ret = mInsets.left;
            if (!canChildScrollLeft() && (mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
                ret = Math.min(child.getWidth(), Math.max(left, 0));
            } else if (!canChildScrollRight() && (mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
                ret = Math.min(mInsets.left, Math.max(left, -child.getWidth()));
            }
            return ret;
        }

        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            View contentView = getContentView();
            if(contentView != null) {
                int ret = contentView.getTop();
                if (!canChildScrollDown() && (mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
                    ret = Math.min(0, Math.max(top, -child.getHeight()));
                } else if (!canChildScrollUp() && (mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
                    ret = Math.min(child.getHeight(), Math.max(top, 0));
                }
                return ret;
            }
            return 0;
        }

    }
}

关键代码都有注释,并且ViewDragHelper.Callback中每个方法具体用途也已经解释过,不再赘述。

三、显示上一级Activity的View

如何显示上一级Activity的view也有两种普遍做法:

  1. 当前Activity背景设置为透明:

    Activity设置透明主题,最简单便捷的一种方式为直接在manifest中对activity设置一个透明的theme,如:

    <resources>
    
    <!-- Application theme. -->
    <style name="AppTheme" parent="@style/Theme.AppCompat.Light.NoActionBar">
        <item name="android:windowIsTranslucent">true</item>
    </style>
    
    </resources>
    

    然后直接设置为application或者对应activity的theme即可。或者直接设置activity的background="@android:color/transparent"也可以达到同样效果。

    但以设置theme的方式来对activity进行统一设置,往往会改动比较大,比如我们自己实现了一个测滑退出的库,要接入已经上线的工程代码中,这样的修改会导致全部activity都跟着修改theme,接入成本略高,所以我们可以采用下面 TranslucentUtils 类的方式来对activity做修改,具体代码如下:

    public class TranslucentUtils {
    
        /**
         * Convert a translucent themed Activity
         * {@link android.R.attr#windowIsTranslucent} to a fullscreen opaque
         * Activity.
         * <p>
         * Call this whenever the background of a translucent Activity has changed
         * to become opaque. Doing so will allow the {@link android.view.Surface} of
         * the Activity behind to be released.
         * <p>
         * This call has no effect on non-translucent activities or on activities
         * with the {@link android.R.attr#windowIsFloating} attribute.
         */
        public static void convertActivityFromTranslucent(Activity activity) {
            try {
                @SuppressLint("PrivateApi")
                Method method = Activity.class.getDeclaredMethod("convertFromTranslucent");
                method.setAccessible(true);
                method.invoke(activity);
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }
    
        /**
         * Convert a translucent themed Activity
         * {@link android.R.attr#windowIsTranslucent} back from opaque to
         * translucent following a call to
         * {@link #convertActivityFromTranslucent(android.app.Activity)} .
         * <p>
         * Calling this allows the Activity behind this one to be seen again. Once
         * all such Activities have been redrawn
         * <p>
         * This call has no effect on non-translucent activities or on activities
         * with the {@link android.R.attr#windowIsFloating} attribute.
         */
        public static void convertActivityToTranslucent(Activity activity) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                convertActivityToTranslucentAfterL(activity);
            } else {
                convertActivityToTranslucentBeforeL(activity);
            }
        }
    
        public static boolean isCanSetActivityToTranslucent() {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                return checkActivityToTranslucentAfterL();
            } else {
                return checkActivityToTranslucentBeforeL();
            }
        }
    
        @SuppressLint("PrivateApi")
        private static boolean checkActivityToTranslucentBeforeL() {
            try {
                Class<?>[] classes = Activity.class.getDeclaredClasses();
                Class<?> translucentConversionListenerClazz = null;
                for (Class clazz : classes) {
                    if (clazz.getSimpleName().contains("TranslucentConversionListener")) {
                        translucentConversionListenerClazz = clazz;
                    }
                }
                Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT,
                    translucentConversionListenerClazz);
                return true;
            } catch (Throwable t) {
                t.printStackTrace();
                return false;
            }
        }
    
        @SuppressLint("PrivateApi")
        private static boolean checkActivityToTranslucentAfterL() {
            try {
                Activity.class.getDeclaredMethod(GET_ACTIVITY_OPTIONS);
    
                Class<?>[] classes = Activity.class.getDeclaredClasses();
                Class<?> translucentConversionListenerClazz = null;
                for (Class clazz : classes) {
                    if (clazz.getSimpleName().contains("TranslucentConversionListener")) {
                        translucentConversionListenerClazz = clazz;
                    }
                }
                Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT,
                    translucentConversionListenerClazz, ActivityOptions.class);
                return true;
            } catch (Throwable t) {
                t.printStackTrace();
                return false;
            }
        }
    
        /**
         * Calling the convertToTranslucent method on platforms before Android 5.0
         */
        private static void convertActivityToTranslucentBeforeL(Activity activity) {
            try {
                Class<?>[] classes = Activity.class.getDeclaredClasses();
                Class<?> translucentConversionListenerClazz = null;
                for (Class clazz : classes) {
                    if (clazz.getSimpleName().contains("TranslucentConversionListener")) {
                        translucentConversionListenerClazz = clazz;
                    }
                }
                @SuppressLint("PrivateApi")
                Method method = Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT,
                    translucentConversionListenerClazz);
                method.setAccessible(true);
                method.invoke(activity, new Object[] {
                    null
                });
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }
    
        /**
         * Calling the convertToTranslucent method on platforms after Android 5.0
         */
        private static void convertActivityToTranslucentAfterL(Activity activity) {
            try {
                @SuppressLint("PrivateApi")
                Method getActivityOptions = Activity.class.getDeclaredMethod(GET_ACTIVITY_OPTIONS);
                getActivityOptions.setAccessible(true);
                Object options = getActivityOptions.invoke(activity);
    
                Class<?>[] classes = Activity.class.getDeclaredClasses();
                Class<?> translucentConversionListenerClazz = null;
                for (Class clazz : classes) {
                    if (clazz.getSimpleName().contains("TranslucentConversionListener")) {
                        translucentConversionListenerClazz = clazz;
                    }
                }
                @SuppressLint("PrivateApi")
                Method convertToTranslucent = Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT,
                    translucentConversionListenerClazz, ActivityOptions.class);
                convertToTranslucent.setAccessible(true);
                convertToTranslucent.invoke(activity, null, options);
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }
    }
    

    我们只需要在需要通过设置“透明”方式来实现测滑的方案中,针对接入测滑功能的activity,调用TranslucentUtils.isCanSetActivityToTranslucent()统一判断当前设备是否支持反射的方式设置透明,继而使用TranslucentUtils.convertActivityToTranslucent(activity)来完成activity透明的设置。这样既不影响现有activity的theme设置,也达到了我们的目的。

    虽然我们可以通过TranslucentUtils来做到便捷修改activity为透明,但采用“当前Activity背景设置为透明”的这种方式来完成“测滑退出”的过程2,将会带来一些意想不到的问题,因为activity设置了透明背景,那么在启动当前activity后,前一个activity的生命周期将发生变化,PreActivity将不会执行onStop,而是仅执行onPause方法。如果你的应用在不同activity的生命周期(主要是onPause和onStop)做了一些回收及状态处理操作,那这种实现方式无疑是比较蛋疼的,所以这种方式实现过程2虽然简单,但后续坑很多 ...

  2. 在当前Activity中绘制前置Activity的DecorView的方式

    让我们回过头来看文章开头“头条”的测滑退出效果,可以发现其前一个Activity会根据当前页面滑动的范围做一个scale动画,如果我们要实现类似的效果,很显然采用上面“透明方案”无法做到。既然涉及到对View做动画,肯定需要首先从当前Activity拿到前置Activity的view才可以,我们都知道在Android中Activity并不负责控制视图,真正控制视图的是Window,而Window作为视图的承载器,其内部持有一个DecorView,这个DecorView才是 view 的根布局(更多Window相关资料请自行学习)。所以我们只需要在当前Activity中拿到前置Activity的DecorView,在过程1处理滑动时,再对其做各种动画即可,获取activity的decorView:

    public static View getActivityDecorView(Activity activity) {
        return activity != null ? activity.getWindow().getDecorView() : null;
    }
    

    这样我们只需要将过程1与过程2进行结合,就能实现类似的效果,下面将描述最终解决方案。

三、最终方案

通过上述分别讲述滑动测滑的两个过程,我采用的是“ViewDragHelper实现滑动” + “获取前置Activity DecorView”的方式来实现测滑退出。为了降低已有工程的接入成本,我们可以考虑实现Application.ActivityLifecycleCallbacks接口,在各个activity的生命周期中来接入测滑退出

首先我们需要在Callback的onActivityCreated方法中将每个已启动activity保存到:Stack<SoftReference<Activity>> mActivityStack中,这样便于后续我们查找前置Activity,然后在需要接入测滑退出功能的activity(可通过注解/基类回调来判断当前activity是否需要接入)onActivityCreated方法中完成接入操作,其大致流程为:

测滑流程.png

获取前置Activity:

private Activity findPreActivity() {
    Activity preActivity = null;
    Stack<SoftReference<Activity>> activityStack = ActivityManager.getInstance().getActivityStack();
    if(!CollectionUtils.isEmpty(activityStack) && activityStack.size() > 1) {

        int reciprocalIndex = 2;
        SoftReference<Activity> softRA = activityStack.get(activityStack.size() - reciprocalIndex);

        while(softRA != null && softRA.get() != null) {
            preActivity = softRA.get();
            //preActivity是否已经finish
            if(preActivity.isFinishing()) {
                reciprocalIndex++;
                if(activityStack.size() < reciprocalIndex) {  //无法获取到当前已经finish掉之前的activity了,置空.
                    return null;
                } else {
                    softRA = activityStack.get(activityStack.size() - reciprocalIndex);
                }
            } else {
                return preActivity;
            }

        }
    }
    return preActivity;
}

如果前置Activity为空,那么可以考虑取消接入当前Activity的测滑退出,或者添加一个默认preView。下面我们来看有了前置Activity的DecorView后,如何构造当前Activity的布局:

测滑退出-图.png

从图中我们可以看到,我们可以将过程1和过程2结合,并且构造一个FrameLayout作为当前Activity新的decorView,而将原有decorView添加到包含滑动处理的SwipeLayout中,将前置Activity的decorView添加到maskViewLayout,作为previewContainer,同时还可在maskViewLayout中添加蒙层并且可对整个maskLayout做各种动画操作。

MaskViewLayout.java 大致代码如下:

public class MaskViewLayout extends FrameLayout {

    private WeakReference<View> mPreContentViewRef;
    private float mDragOffset;
    private IMaskTransform iMaskTransform;

    public MaskViewLayout(@NonNull Context context) {
        super(context);
        init();
    }

    public MaskViewLayout(Context context, AttributeSet attributeSet) {
        super(context, attributeSet);
        init();
    }

    public MaskViewLayout(Context context, AttributeSet attributeSet, int i) {
        super(context, attributeSet, i);
        init();
    }

    private void init() {
        setBackgroundColor(Color.WHITE);
    }

    public void setPreContentView(View view, int backupContentRes) {
        if(view == null && backupContentRes != 0) {
            //构造一个默认preView
            View backupView = SwipeConstantUtils.inflateView(getContext(),backupContentRes,this);
            if(backupView != null) {
                backupView.setId(R.id.mask_backup_preview);
                backupView.setVisibility(View.GONE);
                addView(backupView);
            }
            return;
        }
        if (mPreContentViewRef == null || mPreContentViewRef.get() == null || mPreContentViewRef.get() != view) {
            if (!(mPreContentViewRef == null || mPreContentViewRef.get() == null)) {
                mPreContentViewRef.clear();
            }
            mPreContentViewRef = new WeakReference(view);
        }
    }

    //设置滑动距离
    public void setDragOffset(float f) {
        Object obj = null;
        if (mDragOffset < 0.01f && f >= 0.01f) {
            obj = 1;
        }
        this.mDragOffset = f;
        if (obj != null) {
            setBackupPreviewVisible();
            invalidate();
        }
    }

    private void setBackupPreviewVisible() {
        View backupView = findViewById(R.id.mask_backup_preview);
        if(backupView != null && backupView.getVisibility() != View.VISIBLE) {
            backupView.setVisibility(View.VISIBLE);
        }
    }

    @Override
    public void draw(Canvas canvas) {
        super.draw(canvas);
        if (this.mDragOffset <= 0) {
            return;
        }
        View view = getCloneView();
        if (view != null) {
            drawContentView(view, canvas);
            drawMaskView(canvas);
            return;
        }
        drawMaskView(canvas);
        return;
    }

    private void drawContentView(View view, Canvas canvas) {
        if (view != null && canvas != null) {
            // 对前置contentView做动画
            iMaskTransform.animateContentView(canvas,getWidth(),getHeight(),mDragOffset);
            canvas.translate(0.0f, (float) (getHeight() - view.getHeight()));
            view.draw(canvas);
            invalidate();
        }
    }

    private void drawMaskView(Canvas canvas) {
        if (iMaskTransform.isDrawMask()) {
            //绘制蒙层
            iMaskTransform.drawMask(this,canvas,getWidth(),getHeight(),mDragOffset);
            invalidate();
        }
    }

    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        recycleContentView();
    }

    private void recycleContentView() {
        if (mPreContentViewRef != null && mPreContentViewRef.get() != null) {
            mPreContentViewRef.clear();
        }
    }

    public View getCloneView() {
        if (mPreContentViewRef == null || mPreContentViewRef.get() == null) {
            return null;
        }
        return mPreContentViewRef.get();
    }

    public void setMaskTransform(IMaskTransform transform) {
        this.iMaskTransform = transform;
    }

    public void setSlidingVideoHandler(SlidingVideoHandler slidingVideoHandler) {
        this.slidingVideoHandler = slidingVideoHandler;
    }
}

SwipebackLayout.java中添加如下方法:

/**
 * attach activity to container
 *
 * @param activity the activity
 * @param viewContainer the parent
 * @param swipeImplMode swipe implement mode : preview or transparent
 */
public void attachActivityToContainer(Activity activity, ViewGroup viewContainer) {
    if(mSwipeActivityRef != null && mSwipeActivityRef.get() != null) {
        mSwipeActivityRef.clear();
    }
    mSwipeActivityRef = new WeakReference<>(activity);

    if(mSwipeActivityRef.get() != null) {
        ViewGroup decor = (ViewGroup)SwipeConstantUtils.getActivityDecorView(mSwipeActivityRef.get());
        if(decor != null) {
            ViewGroup decorChild = (ViewGroup)decor.getChildAt(0);
            if(decorChild != null) {
                decor.removeView(decorChild);
                addView(decorChild, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
                setContentView(decorChild);

                if (viewContainer != null) {
                    viewContainer.addView(this);
                    decor.addView(viewContainer);
                    //set id when container add this success.
                    setId(R.id.swipe_layout);
                } 
            }
        }
    }
}

并在onViewPositionChanged()方法中添加:

@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
    super.onViewPositionChanged(changedView, left, top, dx, dy);
    //此处代码文章上述已添加
        ... 
            
        // 滑动位置改变时回调到外部    
        if (mSlideCallback != null && mScrollPercent < SLIDE_MAX_PERCENT) {
                    mSlideCallback.onPositionChanged(mScrollPercent);
        }
        if (mScrollPercent >= SCROLLER_MAX_PERCENT) {
            if (mSwipeActivityRef != null && mSwipeActivityRef.get() != null && !mSwipeActivityRef.get()
                .isFinishing()) {
                Activity activity = mSwipeActivityRef.get();
                activity.onBackPressed();
                //in case of any activity override onBackPressed,do not finished activity cause exception.
                activity.finish();
                activity.overridePendingTransition(0, 0);

                if (mSlideCallback != null) {
                    mSlideCallback.onSwipeFinished();
                }
            }
        }
    }
}

接下来就是构建当前Activity接入测滑退出,我们可以使用前置Activity获取其DecorView后,构造MaskViewLayout,并添加到外层FrameLayout中,接着构造SwipeSlideCallback回调:

SwipeSlideCallback swipeSlideCallback = new SwipeSlideCallback() {
    @Override
    public void onStateChanged(int state) {

    }

    @Override
    public void onPositionChanged(float percent) {
        maskViewLayout.setDragOffset(percent);
    }

    @Override
    public void onSwipeFinished() {
        maskViewLayout.onSwipeFinished();
    }
};

最终构造SwipeBackLayout,并将其添加到外层FrameLayout中:

backLayout.attachActivityToContainer(activity, frameLayout);
backLayout.setSlideCallback(swipeSlideCallback);

至此,我们就完成了对当前Activity接入测滑退出的整体操作。至于如何在onActivityCreated()方法中判断当前activity是否需要接入测滑退出,可通过添加特殊annotation或者基类Activity实现钩子的方式进行判断,不再赘述。

按照当前方案来实现可以有效避免采用“透明背景”方案中影响Activity生命周期的情况,而且可以做到接入成本更低,美中不足就是会增加现有View层级,当然滑动处理部分,我们可以采用DrawerLayout或者自定义onTouchEvent的方式(见上文),但仍不可避免增加View层级的问题。如果你有更好的实现方式,欢迎指教~

在最初实现此方案时,同样遇到了几个问题比较典型,在此列出:

  1. 获取前置Activity的DecorView后,在当前页面绘制时,如果PreDecorView中有用到Fresco库中GenericDraweeView组件加载图片时,在7.0以上的设备中会出现无法正常显示图片的情况(使用系统自带ImageView没有此问题),该问题原因为:当 api >= 24(android 7.0以上)时,在 ViewGroup.attachToParent() 方法中调用了ViewGroup.dispatchVisibilityAggregated()方法,而dispatchVisibilityAggregated ()方法最终会调用各个 子view 的以下方法:onVisibilityAggregated,而系统的ImageView内部实现为:

    public void onVisibilityAggregated(boolean isVisible) {
        super.onVisibilityAggregated(isVisible);
        // Only do this for new apps post-Nougat
        if (mDrawable != null && !sCompatDrawableVisibilityDispatch) {
            mDrawable.setVisible(isVisible, false);
        }
    }
    

    也就是设置了对应的 drawable 的visible = false,但默认的 Drawable 在 draw 的时候,不管 visible 的值是 true 还是 false,都会进行绘制。

    而Fresco的GenericDraweeView在实现时,与其绑定的Drawable指定为RootDrawableRootDrawable在绘制时,会对 isVisible 进行判断,如果 isVisible=false ,那么就不进行绘制。所以导致只有 fresco 的图片会出现此问题! 并且只是在 7.0 以上设备会出现(因为 7.0 以下不会在 ViewGroup 中回调到 onVisibilityAggregated 方法)

    解决办法: 重写DenericDraweeView中onVisibilityAggregated方法如:

    @Override
    public void onVisibilityAggregated(boolean isVisible) {
        super.onVisibilityAggregated(isVisible);
        if (getDrawable() != null) {
            getDrawable().setVisible(true, false);
        }
    }
    
  2. 当我们设置滑动模式为全屏均可滑动时(并非仅边缘可测滑退出),如果当前 Activity 的 contentView 视图中存在与滑动方向一致的可滑动组件(如 ScrollView, ViewPager, RecyclerView等),内部可滑动的View将会无法滚动。出现此问题的原因为 SwipeBackLayout 在处理 touchEvent 时,全部都交给ViewDragHelper 来统一处理,而 ViewDragHelper 没有检测其内部是否存在可滑动组件,所以导致出现此问题。之前有看到网上有些解法说可以遍历当前 Activity 内部的 contentView ,直到找到第一个可滑动组件,但这种解法当 contentView 内部包含多个可滑动组件时,仍会出现问题,而且遍历查找过程可以通过 Activity 内部设置 childScrollView 的方式来避免。

    解决办法:SwipebackLayout 新增 setChildScrollView(View scrollView)接口,有需要的 Activity 可查找对应 SwipebackLayout 后,设置对应childScrollView。并且在 ViewDragHelper.Callback 接口 clampViewPositionHorizontal 及 clampViewPositionVertical 中对 childScrollView 是否可滑动添加判断,如:

    @Override
    public int clampViewPositionHorizontal(View child, int left, int dx) {
        int ret = mInsets.left;
        if (!canChildScrollLeft() && (mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
            ret = Math.min(child.getWidth(), Math.max(left, 0));
        } else if (!canChildScrollRight() && (mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0)      {
            ret = Math.min(mInsets.left, Math.max(left, -child.getWidth()));
        }
        return ret;
    }
    

    详见上述 SwipeBackLayout.java

  3. 前置 Activity 中如果包含播放组件相关View,如 SurfaceView 或 TextureView 时,要注意 Player 的播放状态控制,避免出现在 onPause() 中暂停,在 onResume 中再次马上恢复暂停且立即展示 SurfaceView,可能会出现 SurfaceView 位置错乱问题(具体原因尚不明确)。推荐在 onResume 时暂停播放并且隐藏播放的 SurfaceView,在 SwipeSlideCallback.onSwipeFinished() 回调中采用 Handler.postDelayed(action, 300) 方式延迟展示 SurfaceView ,可以解决此问题。如果有遇到类似问题的朋友有更好的解决办法,可以联系我,多谢~

四、总结

本文介绍及简要分析了常见的实现Android测滑退出的几种常见方案,并且给出了笔者认为相对靠谱的一种实现方案,但该方案仍有一定局限性(支持滑动的页面内,如果存在与子View冲突的滑动view,仅能支持一个子View,即childScrollView),分析及实现过程难免存在一定错误,如有发现,烦请不吝赐教。

鸣谢及参考:

Android ViewDragHelper源码解析

DrawerLayout滑动范围的设置

关于Android实现滑动返回的几种方法总结

bingoogolapple/BGASwipeBackLayout-Android

anzewei/ParallaxBackLayout

ikew0ng/SwipeBackLayout

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,133评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,682评论 3 390
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,784评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,508评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,603评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,607评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,604评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,359评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,805评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,121评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,280评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,959评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,588评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,206评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,442评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,193评论 2 367
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,144评论 2 352

推荐阅读更多精彩内容

  • 是否好好爱过 by songqiao 对这个题目,我有一点抵触。爱的纯粹让人不想给予评价,何谓好好爱过,何谓无心轻...
    songqiao姚松乔阅读 269评论 0 0