仿抖音右滑清屏,左滑列表功能

概述

​ 项目中要实现仿抖音直播间滑动清屏,侧滑列表的功能,在此记录下实现过程和踩坑记录希望避免大家走些弯路,也当作自己的一个总结

​ 首先看下Demo中的效果

​ 阅读文章需要提前熟悉些事件分发的内容,相信大家都已经了解过了,网上也有很多优秀的文章,这里推荐两篇自己读过印象较深的文章

https://www.jianshu.com/p/e99b5e8bd67b

https://www.jianshu.com/p/38015afcdb58/

关于这方面的知识,在Android中是再重要不过的了,是迟早都要掌握的知识,所以还是希望大家都能提早掌握,最好可以跟着源码一起分析,理解掌握的更深刻一点

实践

所以网上基于这部分内容讲解已经很详细了,这里就不再搬砖了,主要分享一下自己项目中结合这部分知识运用过程中产生的一些想法和经验,解决的一些bug

以上就是功能在实现过程中要解决的问题,下面详细展开

1. 布局结构

​ 布局结构始终是界面设计时首先要考虑的一个问题,从接到一个需求开始,首先要根据项目中现有的布局结构,考虑如何更优雅的嵌入布局层次。如果一不小心,走上了错误的实现道路,那么不好意思,即使功能最后实现了,到了后期,也有千万种理由迫使你不得不走上重构的道路。

​ 比如实现不合理,导致的布局结构复杂,嵌套冗余层次,比如代码业务逻辑处理复杂蹩脚,比如资源浪费,内存消耗过多等等。虽然功能好使,使用起来也没有差别,但是,作为一个有追求的程序员,我们还是要避免这种情况的发生不是吗

不巧的是,本文就属于上述踩坑记录,下面详细分析

1.1 初步实现

​ 上来以后,思路很直接明了的去想要实现清屏和滑屏的功能是每个房间都有的功能,每个房间又都是一个RecyclerView 的一个Item。所以,很明显在Item的布局上包一层,实现清屏和侧滑列表的功能就可以了,这样每个房间都可以上下滑,切换房间。切换以后,滑屏的功能是在每个房间里的,互不影响,所以很好理解

我们项目中实现直播间上下滑切换的功能是RecyclerView + 自定义LinearLayoutManager实现的,这部分内容网上demo很多,就不展开了

​ 具体实施,是自定义布局继承RelativeLayout,解析自定义的布局文件,里面包含,直播间的房间布局,和自己右侧滑块儿布局,然后用自己实现的布局替换之前的房间Item布局位置

image-20200504225725117
  • 由于我们自定义的Container布局是继承子RelativeLayout实现的,内部三个子View 又全部是占满父布局的,所以就是三层覆盖的效果,类似抖音直播间效果
  • 这里我们尽量将覆盖层/清屏控件,封装成一个ViewGroup 内部包含了上边细分的各个子View,例如头部个人信息,头像列表等等;中间弹幕,SVGA礼物展示区域;底部聊天评论区域方便管理
  • 还有右侧滑块我们也做成继承自RelativeLayout形式,解析自定义布局,方便扩展

这样我们调用封装的Container将清屏控件,和右侧滑块儿布局View分别添加到内部即可

API提供如下

1
2
3
4
5
// 添加需要清屏的view
fun addClearViews(vararg views: View?)

// 添加需要滑入的view
fun addSlideView(view: RightSlideLayout)

这样我们在视频播放页面滑动,就可以在Container内判断手势,处理清屏控件或者滑出右侧滑块儿了

右侧滑块再动态加载Fragment,展示列表布局,基本完成功能效果了

1.2 重构

​ 本来以为开开心心的可以上线了,谁知到下边继续体验和对比抖音到过程中还是发现不足:

第一个是,右侧滑块儿(后边称RightSlider)包含在房间,这样上下切换房间(后边称Container),RightSlider布局也会随着Container新建而新建,虽然有RecyclerView的布局缓存,但是至少也会新建Holder几次,造成资源的浪费。第二个是,RightSlider的新建就会导致里边的Fragment的新建,所以又会重新请求加载列表数据,再次造成资源浪费,而且,新建后右侧列表又会重新顶到头,之前滑动过的距离就会丢失。这样就造成,用户从右侧列表点击切换房间后,再次滑出RightSlider切换房间,发现又要从头开始往下滑,这样肯定不符合用户体验。观察抖音列表后发现,每次滑动到固定位置点击Item切换房间后,再次滑出滑块儿,发现列表还是之前的位置,好像跟之前滑出的是一个滑块儿的效果,于是恍然大悟,滑块儿是跟Activity绑定的,也就是要把RightSlider放在跟Activity布局那一层

​ 其实提出RightSlider到外层的过程中,还是走了不少弯路,因为之前毕竟已经实现好的逻辑,如果改动布局结构,肯定要重写滑动冲突、事件分发这部分代码,工作量又不可预计。所以想着能不能不动布局结构的情况下实现仿抖音效果

动态替换Fragment

​ 首先想到的是滑出RightSlider里的列表每次都好像是同一个,那么保证里边的Fragment是同一个不就好了,滑出的滑块儿虽然不同,但是里边装载的Fragment列表是同一个,这样就营造出同一个滑块儿的效果。

​ 但是实现过程中还是出现了问题,由于RecyclerView的预加载功能,导致我们项目中,从第一个房间上滑到下一个房间,过程中会新建两个Holder,这样Fragment替换就出了问题,切换房间后Fragment添加不上去,折腾一下午后最终放弃这个方案

固定List高度

​ 然后想的,既然Fragment替换不了了,那么RecyclerView肯定不是同一个了,如果点击后记录当前RecyclerView滑动的位置,下次滑出时,代码固定到当前位置不是也可以伪造出同一个滑块儿的效果嘛,这部分也去找了一些资料,实现了个小demo。其中用到的主要方法是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 获取滑动距离
*/
fun getScollYDistance(): Int {
// 获取recyclerview 的layoutManager
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
// 获取当前第一个可见View的位置
val position = layoutManager.findFirstVisibleItemPosition()
// 根据position 获取当前View
val firstVisiableChildView = layoutManager.findViewByPosition(position)
// 获取当前View 高度
val itemHeight = firstVisiableChildView.height
// 滑动距离
return position * itemHeight - firstVisiableChildView.top
}

滑动距离计算的思想是:根据当前可见View 的position * 每个ItemView 的高度 + 当前View已经滑出去的部分

image_2

​ 计算出高度后,每次加载时,调用RecyclerView的API

1
recyclerView.scrollBy(0,scroll) //scroll 刚才计算的高度

还有其他几个滑动的方法:

1
2
3
4
5
6
7
8
9
10
11
// 带动画移动距离
public void smoothScrollBy(int dx, int dy)

// 带动画移动到position
public void smoothScrollToPosition(int position)

// 移动到adapter position ,由LayoutManager实现
public void scrollToPosition(int position)

// 空实现,无效
public void scrollTo(int x, int y)

原理上可以实现,但是最后综合比较还是放弃了这种方式,因为总感觉这种方法属于投机取巧不是正道,还是老老实实将RightSlider 提到外面得了

2. 动画

​ 动画也是这个功能中很重要的一个方面,因为动画效果的流畅直接影响了用户体验,所以这方面也是细扣了很久。首先这个功能主要分成三个动画效果:

2.1 进场出场

​ 包含清屏控件入场、出场:

1
2
3
4
5
6
7
8
9
10
mClearAnimator = ValueAnimator.ofFloat(0f, 1.0f).setDuration(300)
mClearAnimator.addUpdateListener(ValueAnimator.AnimatorUpdateListener { valueAnimator ->
val value = valueAnimator.animatedValue as Float
translateClearChild((startX + value * (endX - startX)).toInt())
})
mClearAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
isCleared = !isCleared
}
})

这里使用了属性动画ValueAnimator,其中 translateClearChild 负责移动View 代码如下:

1
2
3
4
5
6
7
8
/**
* 移动清屏控件
*/
private fun translateClearChild(translate: Int) {
for (i in mClearViews.indices) {
mClearViews[i].translationX = translate.toFloat()
}
}

​ 滑块儿的入场、出场:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
mSlideInAnimator = ValueAnimator.ofFloat(0f, 1.0f).setDuration(500)
// 设置减速拦截器
mSlideInAnimator.interpolator = DecelerateInterpolator(3f)
mSlideInAnimator.addUpdateListener(ValueAnimator.AnimatorUpdateListener { valueAnimator ->
val value = valueAnimator.animatedValue as Float
translateSlideView((startX + value * (endX - startX)).toInt())
})
mSlideInAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
mSlideView!!.visibility = View.VISIBLE
mBgColorView.isClickable = true
}

override fun onAnimationEnd(animation: Animator) {
if (!isSlideShow && translateX == 0) {
isSlideShow = !isSlideShow
} else if (isSlideShow && abs(translateX) == width - mSlideView!!.paddingLeft) {
isSlideShow = !isSlideShow
}
if (!isSlideShow) {
parent.requestDisallowInterceptTouchEvent(false)
mSlideView!!.visibility = View.GONE
removeView(mBgColorView)
addView(mBgColorView, childCount - 4)
}
isSliderGoning = false
}
})

这里startX,endX 分别代表入场和出场时候,动画起止位置。由于清屏控件没有中间位置状态,直接是从0 到屏幕宽度两个值之间替换;而滑块儿中间由于要跟随手势移动,所以要记录中间translateX,标记为startX

2.2 跟随手势

​ 跟随手势实现主要是拦截移动手势,根据按下手势位置坐标和Move移动位置坐标的差值,调用移动SliderView的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
val x = event.rawX.toInt()
// 标记移动距离
val offsetX = x - mDownX

when (event.action) {
MotionEvent.ACTION_MOVE -> {
if ((isSlideShow) && offsetX > 0 && mSlideInAnimator.isRunning && !isSliderGoning) {
// 滑入情况下,向右滑一段松开,再向右滑,清除回弹动画,跟随手势
mSlideInAnimator.cancel()
translateSlideView(offsetX)
}
if ((isSlideShow) && offsetX > 0 && !mSlideInAnimator.isRunning) {
// 滑入情况下,向右滑,跟随手势
translateSlideView(offsetX)
}
return true
}
}

2.3 颜色渐变

​ 跟随手势滑动过程中还伴随的左侧空白区域颜色渐变,这部分可以在RightSlider移动过程中的距离值关联起来,设置起始颜色透明和截止颜色灰色蒙层。再根据距离动态算出当前颜色在区间范围内取值,主要代码逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 移动滑块儿
*/
private fun translateSlideView(translate: Int) {
val percent = (mSlideView!!.width.toFloat() - translate) / mSlideView!!.width
// 根据百分比算出色值
val color = (MASK_DARK_COLOR * percent).toInt() shl 24
// 动态设置背景色渐变
mBgColorView.setBackgroundColor(color)
translateX = translate
mSlideView!!.translationX = translate.toFloat()
}

3 事件分发

​ 这部分可以说是本功能实现的核心,也是耗费了相当时间的精力,从最开始的Container包含RightSlider布局处理经典的事件分发顺序,到最后重构布局,将RightSlider提到外层变成不是包含关系,而是并列或者说是覆盖关系,中间对事件传递的顺序理解又深入了一层

3.1 传递顺序

​ 重构之前的布局结构是每个Container包含了一个RightSlider,两个是一个整体使用的,滑动的逻辑都可以在Container层内的onInterceptTouchEvent方法内处理。判断是否拦截事件即可,然后RightSlider内想要禁止父层Container拦截事件,可以使用parent.requestDisallowInterceptTouchEvent(true)禁止父层拦截;是属于经典模式的事件分发模型,事件分发的顺序在一个U型结构里,比较好处理

​ 然后重构以后布局结构变成了如下图所示

image_3

​ 每个Container 共用一个RightSlider,这样属于事件的分发处理不在一个ViewGroup的U型模型里了,这样的分发顺序也是属于自己的一个大胆尝试,想着实在不行,还是要把Activity内布局包一层,将Container和RightSlider 放在一个U型结构里去处理。

​ 还好最后不断踩坑,终于实现了事件从Activity分发,到RightSlider,再分发到Container的过程

​ 这里贴下Demo里的布局实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@mipmap/bg"
tools:context=".MainActivity">

<com.fxf.slide.SlideContainerLayout
android:id="@+id/layout_slider_container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/ll12"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="100dp"
android:background="#00f"
android:orientation="vertical">

<TextView
android:id="@+id/tv111"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="111111111"
android:textColor="#fff" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="222222222"
android:textColor="#fff" />
</LinearLayout>
</com.fxf.slide.SlideContainerLayout>

<com.fxf.slide.RightSlideLayout
android:id="@+id/layout_right_slider"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="60dp"
android:visibility="gone">

<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/shape_slider_background">
<View
android:id="@+id/live_slide_bar"
android:layout_width="4.5dp"
android:layout_height="90dp"
android:layout_centerVertical="true"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:background="@drawable/shape_slider_dark_bar" />

<FrameLayout
android:id="@+id/list_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_toRightOf="@+id/live_slide_bar" />
</RelativeLayout>

</com.fxf.slide.RightSlideLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

其中做了部分简化,主要帮助大家理解布局层次

​ 然后贴下RightSlider核心分发代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// 获取坐标,这里用rawX 相对屏幕绝对位置,不然随手势移动过程中父布局的移动,导致获取的坐标左右抖动,会出现移动过程中左右一直抖动现象
val x = event.rawX.toInt()
val y = event.rawY.toInt()
// X方向位移
val offsetX = x - mDownX
if (!mSlideContainerLayout.isSlideShow){
// Container滑块儿没滑出来不分发事件
return false
}
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 记录按下点坐标
mDownX = x
mDownY = y
mSlideContainerLayout.setDownXY(mDownX,mDownY)
}
MotionEvent.ACTION_MOVE -> if (abs(x - mDownX) < abs(y - mDownY) && paddingLeft < x) { // 上下滑动情况处理
if (isSlideHorizontal) {
return mSlideContainerLayout.dispatchTouchEvent(event)
}
} else if ( offsetX < 0 && mSlideContainerLayout.isAlignLeftSide()) {
// 向左滑动,滑块儿已经靠最左边了,不分发
return super.dispatchTouchEvent(event)
} else if (abs(x - mDownX) > abs(y - mDownY)){
// 水平方向移动,分发事件
isSlideHorizontal = true
return mSlideContainerLayout.dispatchTouchEvent(event)// 事件传递给Container处理
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL ->{
// 抬起时处理
if (offsetX < 0 && mSlideContainerLayout.isAlignLeftSide()){
return super.dispatchTouchEvent(event)
}
if (abs(x - mDownX) > abs(y - mDownY) || isSlideHorizontal){
isSlideHorizontal = false
return mSlideContainerLayout.dispatchTouchEvent(event)
}
isSlideHorizontal = false
}
}
return super.dispatchTouchEvent(event)
}

3.2 滑动冲突

  • 因为房间是可以上下滑动的,所以可以判断如果滑块儿没滑粗来时,直接返回分发,不让RightSlider和Container处理事件
1
2
3
if (!mSlideContainerLayout.isSlideShow){
return false
}
  • 然后滑块儿滑出来以后,因为里边有列表,所以要消费上下滑动事件,可以处理如下:
1
2
3
4
5
MotionEvent.ACTION_MOVE -> if (abs(x - mDownX) < abs(y - mDownY) && paddingLeft < x) {
if (isSlideHorizontal) {
return mSlideContainerLayout.dispatchTouchEvent(event)
}
}

其中paddingLeft < x 是因为滑块左边有一部分空白区域 paddingLeft ,所以当x坐标在此区域右侧时才处理事件

  • Container动画执行过程中,说明正在消费事件,此时禁止父层拦截事件

    1
    2
    3
    4
    if (mClearAnimator.isRunning || mSlideInAnimator.isRunning || isSlideShow) {
    // 滑入情况下,禁止上下滑切换直播间
    parent.requestDisallowInterceptTouchEvent(true)
    }
  • Container处理事件时候和直播间上的进入房间头像列表冲突,解决方法是判断mDownY 大于进入头像列表高度时才处理事件,因为正常人滑入滑块都是在屏幕中下部操作的,所以太靠上的部分不处理事件也可以接受
1
2
3
4
5
6
7
8
MotionEvent.ACTION_MOVE -> {
if (!mClearAnimator.isRunning && mDownY > 200 && abs(x - mDownX) > abs(y - mDownY)) {
// 清屏不在执行时 && 高度大于200dp(解决进入房间头像滑动冲突)&& 横向滑动时拦截事件
if (abs(x - mDownX) > 10) {
return true
}
}
}

3.3 滑动优化

​ 这部分有很多细节处理的地方,包括动画执行到一半情况下,再次左右滑动,先向左后向右,左右滑一半再上下滑等等各种情况具体可以看代码中SlideContainerLayout中onTouchEvent方法内处理逻辑,都添加了注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
override fun onTouchEvent(event: MotionEvent): Boolean {
mVelocityTracker!!.addMovement(event)
val x = event.rawX.toInt()
val offsetX = x - mDownX
if (mLastOffsetList.size > 2){
mLastOffsetList.removeFirst()
}
mLastOffsetList.add(offsetX)
var slideRight = (offsetX - mLastOffsetList.first) > 0
when (event.action) {
MotionEvent.ACTION_MOVE -> {
if ((isSlideShow) && offsetX > 0 && mSlideInAnimator.isRunning && !isSliderGoning) {
// 滑入情况下,向右滑一段松开,再向右滑,清除回弹动画,跟随手势
mSlideInAnimator.cancel()
translateSlideView(offsetX)
}
if ((isSlideShow) && offsetX > 0 && !mSlideInAnimator.isRunning) {
// 滑入情况下,向右滑,跟随手势
translateSlideView(offsetX)
}
return true
}
MotionEvent.ACTION_UP -> {
mVelocityTracker!!.computeCurrentVelocity(10)
if (isSlideShow && offsetX > 0 && abs(offsetX) > width / 3 && !isSliderGoning && mVelocityTracker!!.xVelocity >= 0) {
// 滑入情况下,向右滑距离超过宽度1/3,滑出滑块
startX = offsetX
endX = width - mSlideView!!.paddingLeft
isSliderGoning = true
mSlideInAnimator.start()
return true
}
if (abs(mVelocityTracker!!.xVelocity) > 1) {
if (isCleared && offsetX < 0) {
// 清屏情况下,左滑速度超过10个像素时 ===》滑入清屏控件
layerShowWithAnim()
} else if (!isCleared && offsetX > 0 && !isSlideShow && !mSlideInAnimator.isRunning) {
// 未清屏 && 向右速度 > 10 && 没滑入滑块 && 滑块动画没执行的时候 ===》清屏
layerGoneWithAnim()
} else if (isSlideShow && offsetX > 0 && slideRight) {
// 滑入情况下 && 向右速度 > 10 ===》滑出滑块
mSlideInAnimator.cancel()
isSliderGoning = true
startX = translateX
endX = width - mSlideView!!.paddingLeft
mSlideInAnimator.start()
} else if (isSlideShow && offsetX < 0 && translateX != 0) {
// 滑入情况下 && 向左速度 > 10 && 已经向右滑动了一段距离 ===》 滑块回弹
startX = translateX
endX = 0
mSlideInAnimator.start()
} else if (!isSlideShow && offsetX < 0 && !mSlideInAnimator.isRunning) {
// 没滑入情况下 && 向左滑速度 > 10 && 没右正在滑入情况下 ===》 滑入滑块
sliderShowWithAnim()
} else {
if (isSlideShow && translateX != 0) {
// 滑入情况下 && 已经向右滑动过,速度没达到松开 ===》回弹
startX = translateX
mSlideInAnimator.start()
}
}
}else {
if (isSlideShow && translateX != 0) {
// 滑入情况下 && 已经向右滑动过,速度没达到松开 ===》回弹
startX = translateX
mSlideInAnimator.start()
}
}
return super.onTouchEvent(event)
}
MotionEvent.ACTION_CANCEL -> {
if (isSlideShow) {
//取消事件时,滑入情况下回弹
startX = translateX
mSlideInAnimator.start()
}
}
}
return super.onTouchEvent(event)
}

总结

​ 最后通过这次实践,感触比较深的是功能实现之前,一定要做好充分的调研,研究好需求的细节,并预先想几种实现策略,对比哪一种更合理。不要埋头就写,结果最后发现不符合需求还要重构

​ 感谢,这里Contanier内的逻辑主要参考了gitHub上这篇文章的处理不过里边处理滑动冲突的逻辑比较少还是要自己结合项目处理

奉上GitHub 项目地址

项目地址

​ 如果有任何对你有帮助的地方,欢迎给项目点个Star,谢谢~