最近因为项目需求,需要实现一个类似于粘性标题的功能,具体效果见封面。
思考了很久,也迭代了三个版本。
版本一
这个版本使用了两个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事件,那么所有的事件都无法传递到子元素中去,这样内部拦截就无法起作用了
效果

版本二
效果太差,代码没有保存。大致思想是使用两个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的滑动方法,往相反的方向滑就可以实现这个需求了。
效果
