定制Android滑动关闭Activity
现在手机屏幕越来越大,而页面的退出按键通常设置在屏幕左上角,这就导致了当单手操作时用户体验及其不好。虽然也能通过实体按键返回和现在流行的全面屏手势解决,但是感觉会很生硬,这里就定制一个用户体验极佳的滑动关闭功能。
写在前面
相信大多数人日常刷各种爱啪啪的时候都有使用过滑动关闭,我也是因为用了觉得很舒服才决定写这篇文章。
对于这个功能实际上已经有一些封装好的三方库可以让我们直接使用了。那为什么我还要费力不讨好的自己来写一遍呢,第一:为了加深对Android中Window和View的理解。第二:目前封装好的库存在一些瑕疵,不能很好的满足我的要求。
思路
熟悉Activity生命周期的童鞋应该知道多个Activity是以压栈的形式进行管理的,并且通过查看官方的描述可以大概猜测出当存在多个Activity时只是将新的覆盖在了之前的上面使其不可见。因此要实现滑动关闭效果只需要通过手势将顶部Activity移开,从而让底部被遮挡的显示出来。
步骤
Window和ContentView
我们一般会使用Activity的setContentView
来设置显示的布局,但是为什么这样做就涉及到Android显示机制了。
简单来说每个Activity都是一个Window
,Window
会绑定一个根布局DecorView
;
DecorView
中包含一个垂直方向的LinearLayout
;
LinearLayout
里面为TitleBar
和ContentView
。
是不是很眼熟呢,没错,平时我们调用的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>
其中的坑
正常这样写是没有问题的,但是就面临着需要在Manifest
中挨个添加android:theme="@style/TranslucentTheme"
。这无形中便增加了我们的工作量和代码的耦合度。肯定有人会说这还不简单,在代码里面动态设置Theme
不就好了吗,我最开始也是这么想的,果然实践是检验问题的唯一真理,设置了之后最最重要的属性windowIsTranslucent
没有生效,然后我以为是设置主题的时间没掌握好接下来试了各种方法,不出意外的全部失效。
最后总结出来,在Activity生命周期内无论在任何地方设置Theme
其中windowIsTranslucent=true
参数都会失效。那么难道就没办法了吗?皇天不负有心人,在我扒Window以及Activity原码之后终于让我发现了一个Activity
的私有方法:convertToTranslucent()
,话不多说直接上代码,利用反射暴力更改。
/**
* 反射设置windowIsTranslucent
*/
private fun convertActivityToTranslucentAfterL(activity: Activity) {
try {
val getActivityOptions = Activity::class.java.getDeclaredMethod("getActivityOptions")
getActivityOptions.isAccessible = true
val options = getActivityOptions.invoke(activity)
val classes = Activity::class.java.declaredClasses
var translucentConversionListenerClazz: Class<*>? = null
for (clazz in classes) {
if (clazz.simpleName.contains("TranslucentConversionListener")) {
translucentConversionListenerClazz = clazz
}
}
val convertToTranslucent = Activity::class.java.getDeclaredMethod("convertToTranslucent",
translucentConversionListenerClazz, ActivityOptions::class.java)
convertToTranslucent.isAccessible = true
convertToTranslucent.invoke(activity, null, options)
} catch (t: Throwable) {
}
}
听说在5.0之前该方法参数不同,现在5.0之前的机型也几乎可以忽略不计了,如果有需求的自己去看看。
就当我以为万事大吉的时候,叕叕叕他娘的出bug了!!转场动画和共享元素动画全部失效。
按理说Translucent
在代码中实现和在Theme中定义效果是完全一样的,然而。。。又是漫长的尝试和思考,最后终于找到一个解决方法:在Activity启动之后调用convertToTranslucent()
,本来是准备放在onResume
中调用但是不知道为什么没效果,最后无奈只能放在屏幕点击事件中。
打完收工。
写在最后
以上就完成了一个自定义的滑动关闭手势动画,我目前使用起来也没有遇到什么问题,识别率、滑动冲突、误触以及动画方面都还可以。后续可以添加根据用户滑动速度的来判断是否关闭的机制。当页面存在垂直列表时也不会有问题,至于横向的话暂时没有试过。
如果有什么问题欢迎留言,如果对横向冲突有更好解决方案的也感谢指出。