最近因为项目需求,需要实现一个类似于粘性标题的功能,具体效果见封面。
思考了很久,也迭代了三个版本。

版本一

这个版本使用了两个ViewPager2嵌套的方式,通过一个ViewGroup去协调两个ViewPager2的滑动事件。看代码:

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
class NestedViewPager @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val mViewPager2: ViewPager2 = ViewPager2(context, attrs, defStyleAttr).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}

private val count: Int
get() = mViewPager2.adapter?.itemCount ?: 0
private var position = 0
private var lastX = 0f

init {
addView(mViewPager2)
mViewPager2.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
this@NestedViewPager.position = position
}
})
}

var adapter: RecyclerView.Adapter<*>?
get() = mViewPager2.adapter
set(value) {
mViewPager2.adapter = value
}

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
val delta = ev.x - lastX
when (ev.action) {
MotionEvent.ACTION_MOVE -> {
Log.d("test", "onInterceptTouchEvent: ")
val isDisallowIntercept =
count > 1 && (position == 0 && delta < 0 || position == count - 1 && delta > 0 || position > 0 && position < count - 1)
parent.requestDisallowInterceptTouchEvent(isDisallowIntercept)
}
}
lastX = ev.x
return super.onInterceptTouchEvent(ev)
}

}

这个是内层的ViewPager2,由于ViewPager2是final的,不支持继承,因此只能采用在外面再套一层FrameLayout的方式去截取滑动事件,并且分发事件。
可以看到在37行,我重写了onInterceptTouchEvent方法,这个方法会在dispatchTouchEvent
方法中被调用,详细请看。在这里,我根据ViewPager当前显示的Item的,确定当是首尾两个Item并判断此时的滑动方向,决定是否要调用父View的requestDisallowInterceptTouchEvent,这个方法的作用是,当参数值为true的时候,请求父View不拦截这一次触摸事件序列,使得事件能够正确后续的事件能够正确的传递到子View。

值得注意的是:requestDisallowInterceptTouchEvent,需要将将父View设置为不拦截任何ACTION_DOWN事件,因 为 ACTION_DOWN 事 件 并 不 受FLAG_DISALLOW_INTERCEPT这个标记位的控制,所以一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截就无法起作用了

效果

横向粘性标题ViewPager2.gif

版本二

效果太差,代码没有保存。大致思想是使用两个RecyclerView,然后通过一个父容器去协调两者的滑动事件。

版本三

这个应该是最终版,思想是每一组数据生成一个View,作为RecyclerView的子项,然后再根据RecyclerView的滑动去控制子View标题的协同滑动。

难点一

每一组的数据的数量是不同的且最大值无法确定,难道要为不同数量的数据各自编写layout,这是不现实的,最佳的方法是代码动态去生成。

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
class AuthorAdapter(val authors: List<Pair<String, List<Celebrity>>>) :
RecyclerView.Adapter<AuthorViewHolder>() {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AuthorViewHolder {
return AuthorViewHolder(
ItemAuthorInfoLayout(parent.context,viewType).apply {
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
)
}

override fun onBindViewHolder(holder: AuthorViewHolder, position: Int) {
holder.bind(authors[position])
}

override fun getItemCount(): Int {
return authors.size
}

override fun getItemViewType(position: Int): Int {
return authors[position].second.size
}
}

Adapter有一个getItemViewType,可以将数据的数量作为一个ViewType,再在onCreateViewHolder中拿到这个ViewType,然后在代码中去动态的调整View中子布局的数量。

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
class ItemAuthorInfoLayout @JvmOverloads constructor(
context: Context,
val viewType: Int = 0,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val defaultWidth = Res.getDimension(R.dimen.item_author_layout_width).toInt()
private val mLayoutParamsOne = LayoutParams(defaultWidth * viewType, LayoutParams.WRAP_CONTENT)
private val mLayoutParamsTwo = LayoutParams(defaultWidth, LayoutParams.WRAP_CONTENT)
private val padding = Res.getDimension(R.dimen.subject_common_padding).toInt[[[[[[()]]]]]]

private val titlePosition = 0

val authorItems: ArrayList<ItemAuthorLayout> = ArrayList[[()]]

val title = TextView(context, attrs, defStyleAttr, R.style.SubjectInfo_Title).apply {
layoutParams = mLayoutParamsTwo
setPadding(padding, padding, padding, padding)
}

val titleContainer = LinearLayout(context).apply {
orientation = HORIZONTAL
layoutParams = mLayoutParamsOne
addView(title)
}

val authorContainer = LinearLayout(context).apply {
orientation = HORIZONTAL
layoutParams = mLayoutParamsOne
val inflater = LayoutInflater.from(context)
(0 until viewType).forEach { _ ->
inflater.inflate(R.layout.item_author_layout, this, false).also {
authorItems.add(it as ItemAuthorLayout)
this.addView(it)
}
}
}

init {
orientation = VERTICAL
addView(titleContainer)
addView(authorContainer)
}
}

难点二

该怎么去具体实现标题定住的效果呢,按照我现有的逻辑,标题是会随着RecyclerView的滑动而划走的。那我们来想想,是不是只要我们再滑动的时候,让标题往反方向上去滑动相同的距离,是不是就做到不动的效果了,看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ItemAuthorInfoLayout @JvmOverloads constructor(
context: Context,
val viewType: Int = 0,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

fun scrollTitle(dx: Int){
val start = 0
val end = (viewType - 1) * defaultWidth
val newX = dx + title.x
if (newX >= start && newX <= end) {
title.x = newX
invalidate()
} else if (newX < start && title.x > start) {
title.x = start.toFloat()
invalidate()
} else if (newX > end && title.x < end) {
title.x = end.toFloat()
invalidate()
}
}
}

上面,我通过改变标题在容器内的相对位置并且调用invalidate重绘界面,来实现滑动的效果,上面大部分的判断是限制滑动的位置,读者可以不必关注。

最后,我们需要在RecyclerView滑动的时候调用我们的滑动方法。来看

1
2
3
4
5
6
7
8
9
10
private val container = itemView as RecyclerView
container.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val position = layoutManager.findFirstVisibleItemPosition()
if (position > -1){
val view = layoutManager.findViewByPosition(position) as ItemAuthorInfoLayout
view.scrollTitle(dx)
}
}
})

通过监听RecyclerView的滑动,然后去不断调用ItemAuthorInfoLayout的滑动方法,往相反的方向滑就可以实现这个需求了。

效果

横向粘性标题.gif