仿写豆瓣详情页(一)开篇
仿写豆瓣详情页(二)底部浮层
仿写豆瓣详情页(三)内容列表
仿写豆瓣详情页(四)弹性布局
仿写豆瓣详情页(五)联动和其他细节
1、前言
如果不考虑浮层,这其实就是一个大的可滑动列表。我一开始想,这个页面不就是个 NestedScrollView 加 LinearLayout,里面放不同的卡片,最后再来一个 ViewPager。后来发现事情没那么简单,仅仅用 NestedScrollView 会有问题,最后还需要通过自定义 View 来解决,解决的关键依然是「滚动量」的分发问题,下面请听我细细道来。
2、方案选择
2.1、NestedScrollView 加 LinearLayout
这个方案在交互效果上可以说和豆瓣详情页没有差别,从直觉上看也是如此,而且是且实可行的。但是上面说这个方案有问题,有啥问题呢?我们先看下这样实现的话,View 的布局是啥样的。
由于 NestedScrollView 不会限制子 View 的高度,所以会导致 LinearLayout 里面放的 View 全都 layout 出来。就会导致性能很差,用户只看到了一两个卡片,却把所以卡片都给 layout 处理了;其实卡片少的话还好,但是豆瓣详情页不仅卡片多,而且还有两三个横向滑动的嵌套 RecyclerView,这种方案在性能上就存在严重问题。而且不利于数据统计,因为我们无法得知哪个卡片展现出来了,那些没有,当然了,通过计算卡片位置和滚动位置是可以得到这些数据,但还是麻烦。
2.2、RecyclerView
既然 NestedScrollView 不行,那我很快就想到 RecyclerView,用不同的 ViewType 和 ViewHolder 就是实现,这里推荐下本源码仓库下本源码仓库下的 SimpleAdapter,能够方便实现这种效果。
不过这种方案有个嵌套滑动冲突的问题,水平滑动倒是无所谓,最下面的 ViewPager 里是有垂直滑动的 RecyclerView 的,由于暂时没法先什么现成的解决方案,又不想继承 RecyclerView 进行冲突处理,当然也是怕改出 bug,就放弃这种方案了。
2.3、NestedScrollView 加 LinearLayout 加 RecyclerView
既然 NestedScrollView 有性能问题,而 RecyclerView 有滑动冲突,那就两者结合一下,在 LinearLayout 里只放 RecyclerView 和 ViewPager,RecyclerView 里只上面的那些卡片,这样问题就解决了。
这里需要注意的是 RecyclerView 的 layout_height 不能是 wrap_content 的,而是需要写死高度,不然由于 NestedScrollView 不会限制子 View 的高度,就会让 RecyclerView 无限高,把子 View 全都 layout 出来。
手指往上滑动的时候,RecyclerView 的内容先往下滚,滚到头了 NestedScrollView 会开始滚,接着露出下面的 ViewPager。如果此时 RecyclerView 和 ViewPager 都显示了一部分,就有个比较尴尬的问题,滑上面的 RecyclerView 是可以滑的(滑不动了,NestedScrollView 才会滚动),下面的 ViewPager 也是可以滑的。还有就是,连续滑动时,不能实现 RecyclerView 和 NestedScrollView 联动起来滚动的效果。
怎么会这样呢?这个就是本文要解决的一个核心问题:父 View 和子 View 都可以滚动时,如何分发滚动量?
要解决这个问题就需要自定义一套规则来解决,既然要自定义,我们就不用这个方案了,这里不管是继承 NestedScrollView 还是 RecyclerView 都挺麻烦,还是单独搞把。
2.4 本文方案
方便起见,这里继承自 FrameLayout,命名为 LinkedScrollView,旨在实现可以联动的滚动效果。只设置 topContainer 和 bottomContainer 两个容器子 View,两者上下挨着,使用 scroll 方式实现 View 的位移。
期望实现 topContainer 的子 View 里的内容滚到底时,整个 LinkedScrollView 开始滚到,滚到 bottomContainer 全部露出来时再滚到 topContainer 的子 View 的内容。
这里需要解答下 如何分发滚动量 的核心问题:
- 触点位置的容器(
topContainer或bottomContainer)完全显示出来,且容器中有可以处理「滚到量」的 View,则分发给该 View 处理 - 其他情况自己(
LinkedScrollView)优先,自己可以处理「滚到量」就直接处理 - 自己不处理时,向下的「滚到量」(大于 0)交给
bottomContainer的子 View 处理,向上的交给topContainer的子 View 处理
这么说太抽象了,我们拿最终实现的 demo 来说明吧。
结构上,会在 topContainer 放一个 RecyclerView 暂且命名为 RecyclerViewTop,bottomContainer 放一个 ViewPager,里面放两个 RecyclerView 分别命名为 RecyclerView1 和 RecyclerView2。
交互上:
- 初始化后,
topContainer全屏,bottomContainer则布局到topContainer下面 - 手指上滑时,
RecyclerViewTop里的内容先开始向底部滚动,直到滚动到底部 - 手指继续上滑,整个
LinkedScrollView开始向底部滚动,bottomContainer露出
- 此时不管手指在那个方位上下滑,都会滚动
LinkedScrollView,因为topContainer和bottomContainer都没有完全显示出来 - 继续上滑,直到
bottomContainer完全显示出来后,开始滚动ViewPager里对应RecyclerView1或RecyclerView2的内容
- 手指下滑的情况同理
Fling 比较特殊,这里单独说下。简单的看,fling 就是一系列的滚动,所以也遵循上述规则,fling 的速度大的时候有两个稍特殊的情况:
bottomContainer里的RecyclerView1或RecyclerView2向上的 fling(快速下滑),滚动会经过RecyclerView1/2->LinkedScrollView,当LinkedScrollView滚到顶,即topContainer完全显示出来后,会继续将「滚动量」传递到RecyclerViewTop- 同理
RecyclerViewTop向下的 fling(快速上滑)的滚动会经过:RecyclerViewTop->LinkedScrollView,当LinkedScrollView滚到底,即bottomContainer完全显示出来后,会继续将「滚动量」传递到ViewPager的RecyclerView1/2(不过豆瓣的 Android 版没做这个处理,iOS 版倒是有)
效果如下:
3、对外暴露的方法和属性
对外主要提供上下两个容器的操作,topContainer 和 bottomContainer 中子 View 的添加和删除。topScrollableView 和 bottomScrollableView 的设置,这两个会用于 fling,LinkedScrollView 无法处理滚动时,会根据 fling 方向分发给 topContainer 的 topScrollableView 或者 bottomContainer 的 bottomScrollableView 所指向的 View。scrollableChild 以 lambda 表达式的形式提供,主要是因为像 ViewPager,在切到不同的 page 时,需要滚动的 View 也是不同的。
fun setTopView(v: View, scrollableChild: (()->View?)? = null) {
topContainer.removeAllViews()
topContainer.addView(v)
topScrollableView = scrollableChild
requestLayout()
}
fun removeTopView() {
topContainer.removeAllViews()
topScrollableView = null
}
fun setBottomView(v: View, scrollableChild: (()->View?)? = null) {
bottomContainer.removeAllViews()
bottomContainer.addView(v)
bottomScrollableView = scrollableChild
requestLayout()
}
fun removeBottomView() {
bottomContainer.removeAllViews()
bottomScrollableView = null
}
除此之外,由于 LinkedScrollView 是通过 scroll 的方式移动 View 的,所以相关的 scroll 方法也是可用的。
4、Layout 处理和滚动范围的确定
布局的处理比较简单,topContainer 和 bottomContainer 上下布局,布局完成后会计算最大滚动范围 maxScrollY
/**
* 布局时,topContainer 在顶部,bottomContainer 紧挨着 topContainer 底部
* 布局完还要计算下最大的滚动距离
*/
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
topContainer.layout(0, 0, topContainer.measuredWidth, topContainer.measuredHeight)
bottomContainer.layout(0, topContainer.measuredHeight, bottomContainer.measuredWidth,
topContainer.measuredHeight + bottomContainer.measuredHeight)
maxScrollY = topContainer.measuredHeight + bottomContainer.measuredHeight - height
}
滚动范围是从 0 到 maxScrollY,同时在 scrollTo 的时候也会进行边界限制。
/**
* 滚动范围是[0, [maxScrollY]],根据方向判断垂直方向是否可以滚动
*/
override fun canScrollVertically(direction: Int): Boolean {
return if (direction > 0) {
scrollY < maxScrollY
} else {
scrollY > 0
}
}
/**
* 滚动前做范围限制
*/
override fun scrollTo(x: Int, y: Int) {
super.scrollTo(x, when {
y < 0 -> 0
y > maxScrollY -> maxScrollY
else -> y
})
}
5、Touch 事件拦截
事件拦截在 仿写豆瓣详情页(二)底部浮层 中有过详细探讨,这里就不赘述了,这里还是采用「尽可能拦截」的思想,拦截后再将 touch 移动产生的「滚动量」进行分发。
LinkedScrollView 只处理 y 轴的滚动,所以只要 y 轴的移动大于 x 轴就拦截。
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
// ...
MotionEvent.ACTION_MOVE -> {
if (abs(lastX - e.x) < abs(lastY - e.y)) {
true
} else {
// ...
false
}
}
// ...
}
}
6、Touch 事件的处理和滚动的分发
在 move 时要计算「滚动量」dScrollY,findChildUnder 找到触点所在的直接子 View child 用来判断其是否完全显示出来,同时还要 child?.findScrollableTarget 找到 child 中可以处理「滚动量」的 View,最后 dispatchScrollY 进行滚动的分发。
override fun onTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
// ...
MotionEvent.ACTION_MOVE -> {
// 移动时分发滚动量
val dScrollY = (lastY - e.y).toInt()
val child = findChildUnder(e.rawX, e.rawY)
dispatchScrollY(dScrollY, child, child?.findScrollableTarget(e.rawX, e.rawY, dScrollY))
lastY = e.y
// ...
true
}
// ...
}
}
「滚动量」分发的逻辑在「2.4」中已经阐明过,代码中实现起来更加简明一点。
private fun dispatchScrollY(dScrollY: Int, child: View?, target: View?) {
if (dScrollY == 0) {
return
}
// 滚动所处的位置没有在子 view,或者子 view 没有完全显示出来
// 或者子 view 中没有要处理滚动的 target,或者 target 不在能够滚动
if (child == null || !isChildTotallyShowing(child)
|| target == null || !target.canScrollVertically(dScrollY)) {
// 优先自己处理,处理不了再根据滚动方向交给顶部或底部的 view 处理
when {
canScrollVertically(dScrollY) -> scrollBy(0, dScrollY)
dScrollY > 0 -> bottomScrollableView?.invoke()?.scrollBy(0, dScrollY)
else -> topScrollableView?.invoke()?.scrollBy(0, dScrollY)
}
} else {
target.scrollBy(0, dScrollY)
}
}
7、Fling 的处理
Fling 的处理需要两个辅助类,VelocityTracker 用于计算抬手时的速度,Scroller 用于计算 fling 每次滚动的距离。
在 onTouchEvent 中通过 VelocityTracker 记录每次事件,在 up 时计算抬手时的速度 yv(这里取反的原因之前也说过,就是 touch 事件的方向和 scroll 的方向刚好相反)。和 move 时一样,还需要 findChildUnder 找到 child,child?.findScrollableTarget 找到可以处理 fling 的目标 View。
override fun onTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
MotionEvent.ACTION_DOWN -> {
// 手指按下时记录 y 轴初始位置
lastY = e.y
velocityTracker.clear()
velocityTracker.addMovement(e)
true
}
MotionEvent.ACTION_MOVE -> {
// ...
velocityTracker.addMovement(e)
true
}
MotionEvent.ACTION_UP -> {
// 手指抬起时计算 y 轴速度,然后自身处理 fling
velocityTracker.addMovement(e)
velocityTracker.computeCurrentVelocity(1000)
val yv = -velocityTracker.yVelocity.toInt()
val child = findChildUnder(e.rawX, e.rawY)
handleFling(yv, child, child?.findScrollableTarget(e.rawX, e.rawY, yv))
true
}
// ...
}
}
Fling 的处理只要靠 Scroller 来进行计算,之前也说过 fling 是一些列的滚动,所以需要临时存放一些参数,比如上次 fling 计算的 y 值 lastFlingY(这里从 0 开始,我们只需要相对值就行),触点所在的直接子 View flingChild 和可以处理 fling 的目标 View flingTarget。
/**
* 处理 fling,通过 scroller 计算 fling,暂存 fling 的初值和需要 fling 的 view
*/
private fun handleFling(yv: Int, child: View?, target: View?) {
lastFlingY = 0
scroller.fling(0, lastFlingY, 0, yv, 0, 0, Int.MIN_VALUE, Int.MAX_VALUE)
flingChild = child
flingTarget = target
invalidate()
}
在 computeScroll 计算「滚动量」dScrollY,和 move 事件一样进行 dispatchScrollY 分发。
/**
* 计算 fling 的滚动量,并将其分发到真正需要处理的 view
*/
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
val currentFlingY = scroller.currY
val dScrollY = currentFlingY - lastFlingY
dispatchScrollY(dScrollY, flingChild, flingTarget)
lastFlingY = currentFlingY
invalidate()
} else {
flingChild = null
}
}
结束
LinkedScrollView 的事件处理方式和 BottomSheetLayout 一样,具体逻辑实现还更简单一点,不过我自身文笔不好,讲的有点啰嗦,大佬们有什么不同意见,欢迎在评论区交(dui)流(xian)。
接下来会实现一个弹性布局 JellyLayout 来实现豆瓣详情页横向滑动列表的弹性效果。