Android滑动关闭Activity

时间:2021-07-28 23:52:33
#定制Android滑动关闭Activity

现在手机屏幕越来越大,而页面的退出按键通常设置在屏幕左上角,这就导致了当单手操作时用户体验及其不好。虽然也能通过实体按键返回和现在流行的全面屏手势解决,但是感觉会很生硬,这里就定制一个用户体验极佳的滑动关闭功能。

写在前面

相信大多数人日常刷各种爱啪啪的时候都有使用过滑动关闭,我也是因为用了觉得很舒服才决定写这篇文章。

对于这个功能实际上已经有一些封装好的三方库可以让我们直接使用了。那为什么我还要费力不讨好的自己来写一遍呢,第一:为了加深对Android中Window和View的理解。第二:目前封装好的库存在一些瑕疵,不能很好的满足我的要求。
Android滑动关闭Activity

思路

熟悉Activity生命周期的童鞋应该知道多个Activity是以压栈的形式进行管理的,并且通过查看官方的描述可以大概猜测出当存在多个Activity时只是将新的覆盖在了之前的上面使其不可见。因此要实现滑动关闭效果只需要通过手势将顶部Activity移开,从而让底部被遮挡的显示出来。

步骤

Window和ContentView

我们一般会使用Activity的setContentView来设置显示的布局,但是为什么这样做就涉及到Android显示机制了。

简单来说每个Activity都是一个WindowWindow会绑定一个根布局DecorView

DecorView中包含一个垂直方向的LinearLayout

LinearLayout里面为TitleBarContentView

是不是很眼熟呢,没错,平时我们调用的setContentView就是设置的这个ContentView,该View继承自FrameLayout,我们设置的布局就是放在这个里面的。知道了这些才能进行后续的动画操作。

Theme

Window默认是有背景色的并且不能含有透明通道,因此即便我们将Window下整个布局移开依然看不到底部的Activity。这里就必须在Theme中添加一个参数。

        <item name="android:windowIsTranslucent">true</item>

理论上这样就可以了,但是为了更好的效果,我们需要对背景设置一个渐变的半透明色,它会随着拉动的距离而变淡,于是会将背景设置给DecorView,这样的话位移动画只能设置给DecorView子控件,这里就会发现一些问题,那就是statusBar不会跟着位移,对此我也借鉴了一些已存在并被广泛采用的三方库发现确实存在这个问题。因此我想到了一个曲线救国的解决方案,那就是去掉statusBar。具体参数如下:

        <item name="android:windowTranslucentStatus">false</item>
        <item name="android:windowTranslucentNavigation">true</item>
        <!--Android 5.x开始需要把颜色设置透明,否则导航栏会呈现系统默认的浅灰色-->
        <item name="android:statusBarColor">@color/colorStatBar</item>

Activity

新建一个SwipeBackActivity,重写onCreate方法,设置Window为透明。

        window.setBackgroundDrawableResource(R.color.colorTransparent)

接着重写setContentView,拿到Activity设置的具体View,上面提到了为了效果我们去掉了statusBar,这样整个布局就会顶在屏幕最上面不是很美观,因此我们就需要自己添加一个状态栏,但是总不能每个layout都去手动添加这样太麻烦了,就找到了官方为我们提供的一个参数fitsSystemWindows,设置之后系统会自动帮我们填充一个状态栏高度的控件,并且是沉浸式的。

        findViewById<ViewGroup>(android.R.id.content).let {
                it.getChildAt(0).apply {
                fitsSystemWindows = true
            }
        }

TouchEvent

既然是滑动操作肯定就需要监听屏幕点击事件,我这里是选择监听了dispatchTouchEvent事件,自己来判断手势操作,也可以使用一些封装好的工具类例如:GestureDetector等。

对点击事件不清楚的可以看我的另一篇文章「Android触摸事件」,具体实现逻辑就不详细叙述了,大致思路就是在滑动时判断当前是否为关闭Activity的操作,如果是就消费滑动事件,并且对view设置位移动画。具体代码:

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                isFirst = true
                swipeType = false
                lastPoint.set(ev.x, ev.y)
            }
            MotionEvent.ACTION_MOVE -> {
                val changeX = ev.x - lastPoint.x
                val changeY = ev.y - lastPoint.y
                if (isFirst && Math.abs(changeX) > touchSlop && Math.abs(changeY) > Math.abs(changeX) * 1.5) {
                    isFirst = false
                }
                if (isFirst && Math.abs(changeX) > touchSlop && Math.abs(changeX) > Math.abs(changeY) * 1.5) {
                    swipeType = true
                }
                if (swipeType) {
                    if (tranX + changeX < -shadowDp) {
                        return true
                    }
                    tranX += changeX
                    val a = shadowMax - tranX * shadowPer
                    val shadow = a.toInt().toString(16)
                    decorView.setBackgroundColor(Color.parseColor("#${shadow}000000"))
                    transView.translationX = tranX
                    lastPoint.set(ev.x, ev.y)
                    return true
                }
            }
            MotionEvent.ACTION_UP -> {
                if (swipeType) {
                    if (tranX >= windowSize.x / 3) {
                        startAnim(true)
                    } else {
                        startAnim(false)
                    }
                    return true
                }
            }
        }
        return super.dispatchTouchEvent(ev)
    }

SmoothScroll

从上面代码可以看出,我是根据当前滑动距离来判断是否需要关闭Activity,如果滑动距离超过屏幕大小的1/3就关闭,没有的话就恢复原来位置。

当松手后,如果直接执行关闭或者复原操作会感觉很生硬,所以我添加上一个短时间的平滑过度动画,是ObjectAnimator的一种常规应用,具体代码如下:

    private fun startAnim(isExit: Boolean) {
        ObjectAnimator().apply {
            duration = 300
            if (isExit) {
                setFloatValues(tranX, windowSize.x.toFloat() - shadowDp)
                addListener(object : Animator.AnimatorListener {
                    override fun onAnimationRepeat(animation: Animator?) {
                    }

                    override fun onAnimationEnd(animation: Animator?) {
                        finish()
                    }

                    override fun onAnimationCancel(animation: Animator?) {
                    }

                    override fun onAnimationStart(animation: Animator?) {
                    }

                })
            } else {
                setFloatValues(tranX, -shadowDp)
                addListener(object : Animator.AnimatorListener {
                    override fun onAnimationRepeat(animation: Animator?) {
                    }

                    override fun onAnimationEnd(animation: Animator?) {
                        tranX = -shadowDp
                    }

                    override fun onAnimationCancel(animation: Animator?) {
                    }

                    override fun onAnimationStart(animation: Animator?) {
                    }

                })
            }
            interpolator = DecelerateInterpolator()
            addUpdateListener { animation ->
                tranX = animation.animatedValue as Float
                val a = shadowMax - tranX * shadowPer
                if (a >= 16) {
                    val shadow = a.toInt().toString(16)
                    decorView.setBackgroundColor(Color.parseColor("#${shadow}000000"))
                }
                transView.translationX = tranX
            }
            start()
        }
    }

半透明遮罩和阴影

除了上述说到的需要对背景设置一个半透明遮罩之外,为了更好的效果,还需要对拖拽的部分添加上一些阴影来增加层次感。这些阴影是需要加在显示部分之外,而我们知道View大小是不可能超过其父布局显示内容的。因此就需要添加新的布局来显示阴影。

逻辑比较复杂和繁琐,直接上代码:

    override fun setContentView(layoutResID: Int) {
        super.setContentView(layoutResID)
        findViewById<ViewGroup>(android.R.id.content).let {
            //viewGroup,将背景和content绑定在一起
            val viewGroup = FrameLayout(this).apply {
                layoutParams = ViewGroup.LayoutParams(windowSize.x + shadowDp.toInt(), ViewGroup.LayoutParams.MATCH_PARENT)
                translationX = tranX
            }
            //背景View
            View(this).apply {
                layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
                setBackgroundResource(R.drawable.edge_shadow)
                viewGroup.addView(this)
            }
            //contentView
            it.getChildAt(0).apply {
                val params = layoutParams
                params.width = windowSize.x
                layoutParams = params
                fitsSystemWindows = true
                translationX = shadowDp
                it.removeView(this)
                viewGroup.addView(this)
            }
            it.addView(viewGroup)
            transView = viewGroup
        }
    }

edge_shadow.xml:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item  android:start="8dp">
        <shape>
            <solid android:color="@color/colorBackground"/>
        </shape>
    </item>
    <item  android:width="8dp">
        <shape android:shape="rectangle">
            <!--颜色渐变范围-->
            <gradient  android:endColor="#3f000000" android:startColor="#00000000"/>
        </shape>
    </item>

</layer-list>

写在最后

以上就完成了一个自定义的滑动关闭手势动画,我目前使用起来也没有遇到什么问题,识别率、滑动冲突、误触以及动画方面都还可以。后续可以添加根据用户滑动速度的来判断是否关闭的机制。当页面存在垂直列表时也不会有问题,至于横向的话暂时没有试过。

如果有什么问题欢迎留言,如果对横向冲突有更好解决方案的也感谢指出。