滑动返回库SwipeBackLayout解析

昌勇锐
2023-12-01

这个库本身并没有很难, 但是它在交互方面的思想是超前的, 虽然现在我们对滑动返回已经习惯了, 但是在当时还是很新鲜的交互方式.

如果要我实现滑动返回, 我第一个看的一定是DrawerLayout的实现, 它用了ViewDragHelper.

接下来我们结合源码解析SwipeBackLayout是如何做到让我们能使用滑动返回的手势的.

SwipeBackActivity

用过的都知道, 想让一个activity具有滑动返回的手势, 需要让它继承SwipeBackActivity.

public class SwipeBackActivity extends FragmentActivity implements SwipeBackActivityBase

SwipeBackActivity继承FragmentActivity, 其实就相当于在正常的继承关系之间插了它自己进去.
至于SwipeBackActivityBase则是一个定义好的接口, 在SwipeBackActivity中实现

public interface SwipeBackActivityBase {
    /**
     * @return the SwipeBackLayout associated with this activity.
     */
    public abstract SwipeBackLayout getSwipeBackLayout();

    public abstract void setSwipeBackEnable(boolean enable);

    /**
     * Scroll out contentView and finish the activity
     */
    public abstract void scrollToFinishActivity();

}

这个些个方法在SwipeBackActivity里面都重写了, 都很简单, 没必要说, 我们看看它重写的其他三个方法

    private SwipeBackActivityHelper mHelper;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mHelper = new SwipeBackActivityHelper(this);
        mHelper.onActivityCreate();
    }

    @Override
    protected void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        mHelper.onPostCreate();
    }

    @Override
    public View findViewById(int id) {
        View v = super.findViewById(id);
        if (v == null && mHelper != null)
            return mHelper.findViewById(id);
        return v;
    }

findViewById就是先调用super.findViewById, 没找到的情况下, 再到mHelper中找. 至于mHelper是什么, 等会儿再说, 先看其他两个方法.

其他两个方法相当于在activity的两个生命周期里面执行了mHelper的两个函数, 那么mHelper到底是什么呢, 我们看看SwipeBackActivityHelper

SwipeBackActivityHelper

首先看它的构造函数和成员变量

    private Activity mActivity;

    private SwipeBackLayout mSwipeBackLayout;

    public SwipeBackActivityHelper(Activity activity) {
        mActivity = activity;
    }

原来构造它的时候, 它立刻将构造它的activity的引用保存了起啦, 我们怀疑和它的两个类似生命周期的方法有关.
先看onActivityCreate

    public void onActivityCreate() {
        mActivity.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
        mActivity.getWindow().getDecorView().setBackgroundDrawable(null);
        mSwipeBackLayout = (SwipeBackLayout) LayoutInflater.from(mActivity).inflate(
                me.imid.swipebacklayout.lib.R.layout.swipeback_layout, null);
        mSwipeBackLayout.addSwipeListener(new SwipeBackLayout.SwipeListener() {
            @Override
            public void onScrollStateChange(int state, float scrollPercent) {
                if (state == SwipeBackLayout.STATE_IDLE && scrollPercent == 0) {
                    Utils.convertActivityFromTranslucent(mActivity);
                }
            }

            @Override
            public void onEdgeTouch(int edgeFlag) {
                Utils.convertActivityToTranslucent(mActivity);
            }

            @Override
            public void onScrollOverThreshold() {

            }
        });
    }

这个方法开头就把初始化它的activity的背景设为透明, 然后初始化了一个SwipeBackLayout, 还和一个布局文件有关, 最后给它添加了一个监听器, 这个监听器无关紧要, 只是用来控制acitivty的透明的, 具体的代码就不说了.

我们可以看一下初始化SwipeBackLayout用到的布局文件

<?xml version="1.0" encoding="utf-8"?>
<me.imid.swipebacklayout.lib.SwipeBackLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/swipe"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

其实什么都没有, 看来刚才只是为了创建一个SwipeBackLayout对象, 这个对象应该是ViewGroup的子类.

这个方法本质上并没有什么异常, 改改activity的背景, 并不会有滑动返回的效果, 所以重点应该在onPostCreate上, 它的代码也很简单

    public void onPostCreate() {
        mSwipeBackLayout.attachToActivity(mActivity);
        Utils.convertActivityFromTranslucent(mActivity);
    }

attachToActivity是关键, 我们等会儿再分析, 先看完这个类.
SwipeBackActivityHelper还有一个方法

    public View findViewById(int id) {
        if (mSwipeBackLayout != null) {
            return mSwipeBackLayout.findViewById(id);
        }
        return null;
    }

刚才在SwipeBackActivity中重写的findViewById会调用这个方法, 这个方法本质上是调用SwipeBackLayout.findViewById, 现在比较奇怪的是SwipeBackLayout里面是没有任何东西的, 何必要调用它的findViewById呢? 所以SwipeBackLayout.attachToActivity一定是一个非常关键的方法, 没有它就不可能有滑动返回的效果.

SwipeBackLayout

public class SwipeBackLayout extends FrameLayout

我们的猜测没有错, SwipeBackLayout的确是ViewGroup的子类, 我们暂时不看这个类的初始化, 先看attachToActivity方法到底写了什么.

    public void attachToActivity(Activity activity) {
        mActivity = activity;
        TypedArray a = activity.getTheme().obtainStyledAttributes(new int[]{
                android.R.attr.windowBackground
        });
        int background = a.getResourceId(0, 0);
        a.recycle();

        ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView();
        ViewGroup decorChild = (ViewGroup) decor.getChildAt(0);
        decorChild.setBackgroundResource(background);
        decor.removeView(decorChild);
        addView(decorChild);
        setContentView(decorChild);
        decor.addView(this);
    }

我们看到这个方法中除了对背景资源的操作, 就是一些奇怪的View操作了. 先是将activity的DecorView和DecorView的第一个child拿到, 然后将它的child从DecorView中删除, 将child添加到SwipeBackLayout自身里面, 最后将SwipeBackLayout添加回DecorView中, 这样就完成了一次狸猫换太子, 我们成功在DecorView和它的第一个child之间插入了我们自己控制的布局. 当然实际上DecorView只有一个child.
那么现在我们的SwipeBackLayout成功打入了敌人内部, 而且扼守咽喉, SwipeBackLayout的行为就是关键了.
我们先看它的构造函数

    public SwipeBackLayout(Context context) {
        this(context, null);
    }

    public SwipeBackLayout(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.SwipeBackLayoutStyle);
    }

    public SwipeBackLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs);
        mDragHelper = ViewDragHelper.create(this, new ViewDragCallback());

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SwipeBackLayout, defStyle,
                R.style.SwipeBackLayout);

        int edgeSize = a.getDimensionPixelSize(R.styleable.SwipeBackLayout_edge_size, -1);
        if (edgeSize > 0)
            setEdgeSize(edgeSize);
        int mode = EDGE_FLAGS[a.getInt(R.styleable.SwipeBackLayout_edge_flag, 0)];
        setEdgeTrackingEnabled(mode);

        int shadowLeft = a.getResourceId(R.styleable.SwipeBackLayout_shadow_left,
                R.drawable.shadow_left);
        int shadowRight = a.getResourceId(R.styleable.SwipeBackLayout_shadow_right,
                R.drawable.shadow_right);
        int shadowBottom = a.getResourceId(R.styleable.SwipeBackLayout_shadow_bottom,
                R.drawable.shadow_bottom);
        setShadow(shadowLeft, EDGE_LEFT);
        setShadow(shadowRight, EDGE_RIGHT);
        setShadow(shadowBottom, EDGE_BOTTOM);
        a.recycle();
        final float density = getResources().getDisplayMetrics().density;
        final float minVel = MIN_FLING_VELOCITY * density;
        mDragHelper.setMinVelocity(minVel);
        mDragHelper.setMaxVelocity(minVel * 2f);
    }

这些构造函数无非就是给一些参数赋默认值, 有一些参数是从style中读取的, 也就是说我们可以通过修改style来控制初始化的参数, 这些style分别定义在attrs.xml和styles.xml中
values/attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="SwipeBackLayout">
        <attr name="edge_size" format="dimension"/>
        <attr name="edge_flag">
            <enum name="left" value="0" />
            <enum name="right" value="1" />
            <enum name="bottom" value="2" />
            <enum name="all" value="3" />
        </attr>
        <attr name="shadow_left" format="reference"/>
        <attr name="shadow_right" format="reference"/>
        <attr name="shadow_bottom" format="reference"/>
    </declare-styleable>

    <attr name="SwipeBackLayoutStyle" format="reference"/>
</resources>

values/styles.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="SwipeBackLayout">
        <item name="edge_size">50dip</item>
        <item name="shadow_left">@drawable/shadow_left</item>
        <item name="shadow_right">@drawable/shadow_right</item>
        <item name="shadow_bottom">@drawable/shadow_bottom</item>
    </style>

</resources>

就是一些默认值的定义, 如果不懂可以参看我之前写的自定义控件和属性的文章.
初始化过程中有一个叫

mDragHelper = ViewDragHelper.create(this, new ViewDragCallback());

这个就是滑动返回的秘密了, 和我的想法一样, 就是使用ViewDragHelper来创造滑动整个activity的效果.
当然, 只是调用ViewDragHelper.create是不够的, 还需要下面两个方法, 这样ViewDragHelper才有能力对我们的事件做出反应.

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        if (!mEnable) {
            return false;
        }
        try {
            return mDragHelper.shouldInterceptTouchEvent(event);
        } catch (ArrayIndexOutOfBoundsException e) {
            // FIXME: handle exception
            // issues #9
            return false;
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!mEnable) {
            return false;
        }
        mDragHelper.processTouchEvent(event);
        return true;
    }

其中的ArrayIndexOutOfBoundsException是某种情况下会触发的bug, 这样写可以忽略掉那个bug造成的崩溃.
真正指导ViewDragHelper的是初始化时传入的ViewDragCallback, 代码如下

    private class ViewDragCallback extends ViewDragHelper.Callback {
        private boolean mIsScrollOverValid;

        @Override
        public boolean tryCaptureView(View view, int i) {
            boolean ret = mDragHelper.isEdgeTouched(mEdgeFlag, i);
            if (ret) {
                if (mDragHelper.isEdgeTouched(EDGE_LEFT, i)) {
                    mTrackingEdge = EDGE_LEFT;
                } else if (mDragHelper.isEdgeTouched(EDGE_RIGHT, i)) {
                    mTrackingEdge = EDGE_RIGHT;
                } else if (mDragHelper.isEdgeTouched(EDGE_BOTTOM, i)) {
                    mTrackingEdge = EDGE_BOTTOM;
                }
                if (mListeners != null && !mListeners.isEmpty()) {
                    for (SwipeListener listener : mListeners) {
                        listener.onEdgeTouch(mTrackingEdge);
                    }
                }
                mIsScrollOverValid = true;
            }
            return ret;
        }

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

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

        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
            if ((mTrackingEdge & EDGE_LEFT) != 0) {
                mScrollPercent = Math.abs((float) left
                        / (mContentView.getWidth() + mShadowLeft.getIntrinsicWidth()));
            } else if ((mTrackingEdge & EDGE_RIGHT) != 0) {
                mScrollPercent = Math.abs((float) left
                        / (mContentView.getWidth() + mShadowRight.getIntrinsicWidth()));
            } else if ((mTrackingEdge & EDGE_BOTTOM) != 0) {
                mScrollPercent = Math.abs((float) top
                        / (mContentView.getHeight() + mShadowBottom.getIntrinsicHeight()));
            }
            mContentLeft = left;
            mContentTop = top;
            invalidate();
            if (mScrollPercent < mScrollThreshold && !mIsScrollOverValid) {
                mIsScrollOverValid = true;
            }
            if (mListeners != null && !mListeners.isEmpty()
                    && mDragHelper.getViewDragState() == STATE_DRAGGING
                    && mScrollPercent >= mScrollThreshold && mIsScrollOverValid) {
                mIsScrollOverValid = false;
                for (SwipeListener listener : mListeners) {
                    listener.onScrollOverThreshold();
                }
            }

            if (mScrollPercent >= 1) {
                if (!mActivity.isFinishing())
                    mActivity.finish();
            }
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            final int childWidth = releasedChild.getWidth();
            final int childHeight = releasedChild.getHeight();

            int left = 0, top = 0;
            if ((mTrackingEdge & EDGE_LEFT) != 0) {
                left = xvel > 0 || xvel == 0 && mScrollPercent > mScrollThreshold ? childWidth
                        + mShadowLeft.getIntrinsicWidth() + OVERSCROLL_DISTANCE : 0;
            } else if ((mTrackingEdge & EDGE_RIGHT) != 0) {
                left = xvel < 0 || xvel == 0 && mScrollPercent > mScrollThreshold ? -(childWidth
                        + mShadowLeft.getIntrinsicWidth() + OVERSCROLL_DISTANCE) : 0;
            } else if ((mTrackingEdge & EDGE_BOTTOM) != 0) {
                top = yvel < 0 || yvel == 0 && mScrollPercent > mScrollThreshold ? -(childHeight
                        + mShadowBottom.getIntrinsicHeight() + OVERSCROLL_DISTANCE) : 0;
            }

            mDragHelper.settleCapturedViewAt(left, top);
            invalidate();
        }

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

        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            int ret = 0;
            if ((mTrackingEdge & EDGE_BOTTOM) != 0) {
                ret = Math.min(0, Math.max(top, -child.getHeight()));
            }
            return ret;
        }

        @Override
        public void onViewDragStateChanged(int state) {
            super.onViewDragStateChanged(state);
            if (mListeners != null && !mListeners.isEmpty()) {
                for (SwipeListener listener : mListeners) {
                    listener.onScrollStateChange(state, mScrollPercent);
                }
            }
        }
    }

tryCaptureView的返回值判断某个View是否可以滑动, 本来是用来判断某个子View是否可以滑动的, 这里这么写其实就是只要发现触摸了activity的边缘, 就会触发滑动.
clampViewPositionHorizontalclampViewPositionVertical是分别计算水平和垂直方向上滑动坐标的方法, 默认返回0, 代表水平或垂直方向上无法滑动.
onViewPositionChanged则是在被滑动的View的位置改变时被回调, 这个方法中主要计算各种状态, 包括滑动的百分比等等, 如果滑动百分比大于1的话, 也就是说我们已经把整个activity滑到屏幕外去了, 那么就finish这个activity. 由于SwipeBackLayout本身是这个activity的一部分, 所以也接受activity的管理.
onViewReleased是松手后的处理, 判断activity是应该回弹恢复原状还是继续滑出屏幕, 滑出屏幕会导致被finish.
松手后继续滑动的秘密在于SwipeBackLayout重写的下面的方法

    @Override
    public void computeScroll() {
        mScrimOpacity = 1 - mScrollPercent;
        if (mDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

其他的方法都无关紧要, 主要是绘图, 比如显示边界阴影, 遮罩的效果等等, 所以就不介绍了.

 类似资料: