仿写豆瓣详情页(一)开篇
仿写豆瓣详情页(二)底部浮层
仿写豆瓣详情页(三)内容列表
仿写豆瓣详情页(四)弹性布局 doing
仿写豆瓣详情页(五)联动和其余细节 doingjava
查看动图git
若是不考虑浮层,这其实就是一个大的可滑动列表。我一开始想,这个页面不就是个 NestedScrollView
加 LinearLayout
,里面放不一样的卡片,最后再来一个 ViewPager
。后来发现事情没那么简单,仅仅用 NestedScrollView
会有问题,最后还须要经过自定义 View 来解决,解决的关键依然是「滚动量」的分发问题,下面请听我细细道来。github
NestedScrollView
加 LinearLayout
这个方案在交互效果上能够说和豆瓣详情页没有差异,从直觉上看也是如此,并且是且实可行的。可是上面说这个方案有问题,有啥问题呢?咱们先看下这样实现的话,View 的布局是啥样的。ide
因为 NestedScrollView
不会限制子 View 的高度,因此会致使 LinearLayout
里面放的 View 全都 layout 出来。就会致使性能不好,用户只看到了一两个卡片,却把因此卡片都给 layout 处理了;其实卡片少的话还好,可是豆瓣详情页不只卡片多,并且还有两三个横向滑动的嵌套 RecyclerView
,这种方案在性能上就存在严重问题。并且不利于数据统计,由于咱们没法得知哪一个卡片展示出来了,那些没有,固然了,经过计算卡片位置和滚动位置是能够获得这些数据,但仍是麻烦。布局
RecyclerView
既然 NestedScrollView
不行,那我很快就想到 RecyclerView
,用不一样的 ViewType
和 ViewHolder
就是实现,这里推荐下本源码仓库下本源码仓库下的 SimpleAdapter
,可以方便实现这种效果。post
不过这种方案有个嵌套滑动冲突的问题,水平滑动却是无所谓,最下面的 ViewPager
里是有垂直滑动的 RecyclerView
的,因为暂时无法先什么现成的解决方案,又不想继承 RecyclerView
进行冲突处理,固然也是怕改出 bug,就放弃这种方案了。性能
NestedScrollView
加 LinearLayout
加 RecyclerView
既然 NestedScrollView
有性能问题,而 RecyclerView
有滑动冲突,那就二者结合一下,在 LinearLayout
里只放 RecyclerView
和 ViewPager
,RecyclerView
里只上面的那些卡片,这样问题就解决了。ui
这里须要注意的是 RecyclerView
的 layout_height
不能是 wrap_content
的,而是须要写死高度,否则因为 NestedScrollView
不会限制子 View 的高度,就会让 RecyclerView
无限高,把子 View 全都 layout 出来。spa
手指往上滑动的时候,RecyclerView
的内容先往下滚,滚到头了 NestedScrollView
会开始滚,接着露出下面的 ViewPager
。若是此时 RecyclerView
和 ViewPager
都显示了一部分,就有个比较尴尬的问题,滑上面的 RecyclerView
是能够滑的(滑不动了,NestedScrollView
才会滚动),下面的 ViewPager
也是能够滑的。还有就是,连续滑动时,不能实现 RecyclerView
和 NestedScrollView
联动起来滚动的效果。code
怎么会这样呢?这个就是本文要解决的一个核心问题:父 View 和子 View 均可以滚动时,如何分发滚动量?
要解决这个问题就须要自定义一套规则来解决,既然要自定义,咱们就不用这个方案了,这里不论是继承 NestedScrollView
仍是 RecyclerView
都挺麻烦,仍是单独搞把。
方便起见,这里继承自 FrameLayout
,命名为 LinkedScrollView
,旨在实现能够联动的滚动效果。只设置 topContainer
和 bottomContainer
两个容器子 View,二者上下挨着,使用 scroll 方式实现 View 的位移。
指望实现 topContainer
的子 View 里的内容滚到底时,整个 LinkedScrollView
开始滚到,滚到 bottomContainer
所有露出来时再滚到 topContainer
的子 View 的内容。
这里须要解答下 如何分发滚动量
的核心问题:
topContainer
或 bottomContainer
)彻底显示出来,且容器中有能够处理「滚到量」的 View,则分发给该 View 处理LinkedScrollView
)优先,本身能够处理「滚到量」就直接处理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 版却是有)效果以下:
对外主要提供上下两个容器的操做,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 方法也是可用的。
布局的处理比较简单,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
})
}
复制代码
事件拦截在 仿写豆瓣详情页(二)底部浮层 中有过详细探讨,这里就不赘述了,这里仍是采用「尽量拦截」的思想,拦截后再将 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
}
}
// ...
}
}
复制代码
在 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)
}
}
复制代码
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
来实现豆瓣详情页横向滑动列表的弹性效果。