优雅的构建 Android 项目——侧滑返回使用及原理分析

大屏幕手机在返回前页操作时,点击左上角的 APP 内返回键或者手机自带的返回按键都不是很方便,这时候能通过屏幕侧滑退出当前页面体验就会好很多了。但是 Android 系统并没有想 IOS 一样自带侧滑返回,好在 Android 轮子比较多,本文记录一下个人开源项目 PandaEye 中使用的侧滑返回库 SwipBackLayout 。该库参考 github 上的开源库 SwipeBackLayout 做了一些简化;

使用方式

定义侧滑基础 Activity

侧滑返回的实现是基于 Activity 的,可以直接继承 Activity 或者继承自己应用实现的 BaseActivity 然后实现 SwipeBackLayout.SwipeListener 接口即可.

public class SwipeBackActivity extends BaseActivity implements SwipeBackLayout.SwipeListener {
    protected SwipeBackLayout layout;
    private ArgbEvaluator argbEvaluator;

    @SuppressLint("InflateParams")
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        layout = (SwipeBackLayout) LayoutInflater.from(this).inflate(
                R.layout.swipeback_base, null);
        layout.attachToActivity(this);
        argbEvaluator = new ArgbEvaluator();
        layout.addSwipeListener(this);
        if (Build.VERSION.SDK_INT >= 23) {
            currentStatusColor = getResources().getColor(R.color.colorPrimaryDark, null);
        } else {
            currentStatusColor = getResources().getColor(R.color.colorPrimaryDark);
        }
    }

    // 提供给子类设置 ViewPager 的接口,用于 SwipeLayout 中处理滑动冲突
    public void addViewPager(ViewPager pager) {
        layout.addViewPager(pager);
    }

}

效果优化

需要侧滑返回的 Activity 继承 SwipeBackActivity 即可实现侧滑返回的功能了,但是侧滑过程中返回界面会被默认的窗口背景颜色覆盖,因此我们需要把实现侧滑返回的界面的 theme 做一些小小的优化,将背景设置为透明状态,并设置进入和退出的动画。
style 中的属性设置

    <!--全屏加透明-->
    <style name="TranslucentFullScreenTheme" parent="AppTheme">
        <item name="android:windowBackground">@android:color/transparent</item>
        <item name="android:colorBackgroundCacheHint">@null</item>
        <item name="android:windowIsTranslucent">true</item>
        <!--<item name="android:windowAnimationStyle">@android:style/Animation</item>-->
        <item name="android:windowAnimationStyle">@style/AnimationActivity</item>
    </style>
    <!--动画设置-->
        <style name="AnimationActivity" mce_bogus="1" parent="@android:style/Animation.Activity">
        <item name="android:activityOpenEnterAnimation">@anim/base_slide_right_in</item>
        <item name="android:activityOpenExitAnimation">@anim/base_slide_right_out</item>
        <item name="android:activityCloseEnterAnimation">@anim/base_slide_right_in</item>
        <item name="android:activityCloseExitAnimation">@anim/base_slide_right_out</item>
    </style>

界面进入动画

<!--base_slide_right_in-->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
    <translate
        android:duration="300"
        android:fromXDelta="100.0%"
        android:interpolator="@android:anim/decelerate_interpolator"
        android:toXDelta="0.0%" />

</set>

界面退出动画

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
    <translate
        android:duration="300"
        android:fromXDelta="100.0%"
        android:interpolator="@android:anim/decelerate_interpolator"
        android:toXDelta="0.0%" />

</set>

然后在 manifest 文件中将继承 SwipeBackActivity 的 Activity 的 theme 设置为 TranslucentFullScreenTheme 即可解决滑动过程中背景覆盖问题。

原理浅析

Activity 中 View 视图层级

要明白侧滑返回的原理我们得先明白 Android Activity 界面的视图层级关系:

Activity 界面视图层级
Activity 界面视图层级

Activity 和 PhoneWindow 这里可以忽略,重点在 DecorView上。这个 DecorView 是 Activity 中 View 布局的祖宗级布局,是一个 FrameLayout,通过 getWindow().getDecorView() 可以获取到对象;如图中 DecorView 有且仅有一个 LinearLayout 子布局,即图中的黄色部分。这个 LinearLayout 一般情况下又包含有 ViewStub 和 FrameLayout 两部分(不同的主题 Theme 可能会多处一些对象),ViewStub 即是应用的 ActionBar,他会根据 theme 来决定是否真正引入 ActionBar 到界面显示。而这个 FrameLayout 中的内容即是我们写的 layout 布局文件中想要展示的内容。需要注意如果 Activity 继承自 AppComcatActivity 则这个 FrameLayout 中还会有两个子布局,第一个子布局中的内容才是我们写的布局文件中的内容

实现原理

通过 SDK 自带的视图分析工具 Hierarchy View 我们可以看到视图的如下分布:

DecorView 视图节点
DecorView 视图节点

界面所有显示的内容其实都在这个 LinearLayout 中,如果我们给这个 LinearLayout 增加一个父布局然后对这个父布局进行滑动处理就可以实现界面的整体滑动,即把整个可视界面放入一个滑动抽屉。因此实现滑动的界面视图应该变成如下的样子:

可侧滑的界面的 DecorView 试图节点
可侧滑的界面的 DecorView 试图节点

如图 SwipeBackLayout 即是添加的滑动抽屉,接下来我们看一下 SwipeBackLayout 中是怎样实现在 LinearLayout 上层插入一个 SwipeBackLayout 布局的。
在 SwipeBackActivity 中只调用了 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();
        //获取到 DecorView 对象
        ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView();
        Log.i("decorChildCount", decor.getChildCount() + "");
        ViewGroup decorChild = (ViewGroup) decor.getChildAt(0);
        Log.i("decorChild", decorChild.toString());
        //重置背景色资源
        decorChild.setBackgroundResource(background);
        //decorView 中将子布局移除
        decor.removeView(decorChild);
        //SwipeBackLayout 添加从decorView中移除布局
        addView(decorChild);
        //将ContentView设置为decorChild的父布局即添加进来的SwipeBackLayout
        setContentView(decorChild);
        //将SwipeBackLayout添加进DecorView
        decor.addView(this);
    }

从中我添加的注释不难看出,实现替换的流程:

  • 1、传入的 activity 对象获取到 DecorView
  • 2、DecorView.getChildAt(0) 获取到 LinearLayout 对象
  • 3、将 LinearLayout 背景资源重置,并从 DecorView 中移除
  • 4、将 LinearLayout 添加到自定义的 SwipeBackLayout 中
  • 5、将自定义的 SwipeBackLayout 添加到 DecorView 中

滑动处理及 ViewPager 处理

在 SwipeBackLayout 中通过重写 onInterceptTouchEvent(MotionEvent ev) 方法和 onTouchEvent(MotionEvent ev) 方法来实现侧滑返回事件的处理及对 ViewPager 滑动的兼容的。

@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //处理ViewPager冲突问题
        ViewPager mViewPager = getTouchViewPager(mViewPagers, ev);
        //当无触摸ViewPager或者该ViewPager未滑动到最左则不对滑动时间进行拦截
        if (mViewPager != null && mViewPager.getCurrentItem() != 0) {
            return super.onInterceptTouchEvent(ev);
        }
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = tempX = (int) ev.getRawX();
                downY = (int) ev.getRawY();
                canSwipe = downX <= viewWidth / 2;
                if (!canSwipe) {
                    return super.onInterceptTouchEvent(ev);
                }
                break;
            case MotionEvent.ACTION_MOVE:
                if (!canSwipe) {
                    return super.onInterceptTouchEvent(ev);
                }
                int moveX = (int) ev.getRawX();
                // 满足此条件屏蔽SildingFinishLayout里面子类的touch事件
                if (moveX - downX > mTouchSlop
                        && Math.abs((int) ev.getRawY() - downY) < mTouchSlop) {
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

在手指按下的时候相较于 onTouchEvent() 方法 onInterceptTouchEvent() 方法会先执行,在此方法中先判断当前触摸是否为 ViewPager,是 ViewPager 则判断是否滑动到了 ViewPager 的最左侧。如果触摸的 ViewPager 且未滑动到最左侧则不对事件进行拦截交给 ViewPager 处理触摸事件,否则触摸位置进行判断,在有效区域内则记录触摸开始点,否则按系统默认方式处理。在移动事件中会根据按下事件的判断结果决定是否按默认方式处理,当需要处理侧滑时会再次判断如果 X 方向的滑动大于最小有效滑动距离 Y方向滑动距离小于最小有效滑动距离则此次事件将会被 SwipeBackLayout 所消费,将进入 SwipeBackLayout 的 onTouchEvent() 方法中的处理逻辑。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                if (!canSwipe) {
                    return super.onInterceptTouchEvent(event);
                }
                int moveX = (int) event.getRawX();
                int deltaX = tempX - moveX;
                tempX = moveX;
                if (moveX - downX > mTouchSlop
                        && Math.abs((int) event.getRawY() - downY) < mTouchSlop) {
                    isSilding = true;
                }
                if (moveX - downX >= 0 && isSilding) {
                    //deltaX 为单次移动的距离向右滑为负数
                    // TODO: 2017/6/22 实现 y 方向的移动,即向右任意方向滑出界面
                    mContentView.scrollBy(deltaX, 0);
                }
                break;
            case MotionEvent.ACTION_UP:
                if (!canSwipe) {
                    return super.onInterceptTouchEvent(event);
                }
                isSilding = false;
                if (mContentView.getScrollX() <= -viewWidth / 4) {
                    isFinish = true;
                    scrollRight();
                } else {
                    scrollOrigin();
                    isFinish = false;
                }
                break;
        }
        return true;
    }

同样此方法中也会根据 onInterceptTouchEvent() 中的 DOWN 事件的判定结果 canSwipe 来决定是否按默认方式消费事件,MOVE 事件中如果满足侧滑条件则会调用 scrollBy() 将 mContentView 按滑动方向进行移动,而此处的 mContentView 即是 SwipeBackLayout 自身,因此整个显示的界面会被按照滑动方向移动。当手指抬起时如果滑动距离超过 1/4 界面宽度(可以按自己需求调整),则视为侧滑返回完成,让 Scroller 自动完成剩余距离的滑动,否则让 Scroller 恢复到滑动起始位置

    /**
     * 滚动出界面
     */
    private void scrollRight() {
        final int delta = (viewWidth + mContentView.getScrollX());
        // 调用startScroll方法来设置一些滚动的参数,我们在computeScroll()方法中调用scrollTo来滚动item
        mScroller.startScroll(mContentView.getScrollX(), 0, -delta + 1, 0,
                Math.abs(delta));
        postInvalidate();
    }

    /**
     * 滚动到起始位置
     */
    private void scrollOrigin() {
        int delta = mContentView.getScrollX();
                // 调用startScroll方法来设置一些滚动的参数,我们在computeScroll()方法中调用scrollTo来滚动item
        mScroller.startScroll(mContentView.getScrollX(), 0, -delta, 0,
                Math.abs(delta));
        postInvalidate();
    }
    
    /**
     * 具体执行 Scroller 中的滚动及将滑动距离传递给外部接口
     */
     @Override
    public void computeScroll() {
        Log.i("computeScroll","computeScroll");
        if (mSwipeListener != null) {
            double scrollx = Math.abs(mContentView.getScrollX());
            double offset = scrollx / viewWidth;
            if (offset > 0.9) {
                offset = 1d;
            }
            mSwipeListener.swipeValue(offset);
        }
        if (mScroller.computeScrollOffset()) {
            Log.i("computeScroll","mScroller");
            mContentView.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
            if (mScroller.isFinished() && isFinish) {
                mActivity.finish();
            }
        }
    }

结语

以上就是简化后的侧滑返回的基本使用和原理的简单分析,完整代码可以参考 PandaEye欢迎 Star。文章一遍过为反复检查如有不妥之处欢迎大家踊跃交流。

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

推荐阅读更多精彩内容