仿写豆瓣详情页(三)内容列表

仿写豆瓣详情页(一)开篇
仿写豆瓣详情页(二)底部浮层
仿写豆瓣详情页(三)内容列表
仿写豆瓣详情页(四)弹性布局 doing
仿写豆瓣详情页(五)联动和其余细节 doingjava

一、前言

查看动图git

若是不考虑浮层,这其实就是一个大的可滑动列表。我一开始想,这个页面不就是个 NestedScrollViewLinearLayout,里面放不一样的卡片,最后再来一个 ViewPager。后来发现事情没那么简单,仅仅用 NestedScrollView 会有问题,最后还须要经过自定义 View 来解决,解决的关键依然是「滚动量」的分发问题,下面请听我细细道来。github

二、方案选择

2.一、NestedScrollViewLinearLayout

这个方案在交互效果上能够说和豆瓣详情页没有差异,从直觉上看也是如此,并且是且实可行的。可是上面说这个方案有问题,有啥问题呢?咱们先看下这样实现的话,View 的布局是啥样的。ide

因为 NestedScrollView 不会限制子 View 的高度,因此会致使 LinearLayout 里面放的 View 全都 layout 出来。就会致使性能不好,用户只看到了一两个卡片,却把因此卡片都给 layout 处理了;其实卡片少的话还好,可是豆瓣详情页不只卡片多,并且还有两三个横向滑动的嵌套 RecyclerView,这种方案在性能上就存在严重问题。并且不利于数据统计,由于咱们没法得知哪一个卡片展示出来了,那些没有,固然了,经过计算卡片位置和滚动位置是能够获得这些数据,但仍是麻烦。布局

2.二、RecyclerView

既然 NestedScrollView 不行,那我很快就想到 RecyclerView,用不一样的 ViewTypeViewHolder 就是实现,这里推荐下本源码仓库下本源码仓库下SimpleAdapter,可以方便实现这种效果。post

不过这种方案有个嵌套滑动冲突的问题,水平滑动却是无所谓,最下面的 ViewPager 里是有垂直滑动的 RecyclerView 的,因为暂时无法先什么现成的解决方案,又不想继承 RecyclerView 进行冲突处理,固然也是怕改出 bug,就放弃这种方案了。性能

2.三、NestedScrollViewLinearLayoutRecyclerView

既然 NestedScrollView 有性能问题,而 RecyclerView 有滑动冲突,那就二者结合一下,在 LinearLayout 里只放 RecyclerViewViewPagerRecyclerView 里只上面的那些卡片,这样问题就解决了。ui

这里须要注意的是 RecyclerViewlayout_height 不能是 wrap_content 的,而是须要写死高度,否则因为 NestedScrollView 不会限制子 View 的高度,就会让 RecyclerView 无限高,把子 View 全都 layout 出来。spa

手指往上滑动的时候,RecyclerView 的内容先往下滚,滚到头了 NestedScrollView 会开始滚,接着露出下面的 ViewPager。若是此时 RecyclerViewViewPager 都显示了一部分,就有个比较尴尬的问题,滑上面的 RecyclerView 是能够滑的(滑不动了,NestedScrollView 才会滚动),下面的 ViewPager 也是能够滑的。还有就是,连续滑动时,不能实现 RecyclerViewNestedScrollView 联动起来滚动的效果。code

怎么会这样呢?这个就是本文要解决的一个核心问题:父 View 和子 View 均可以滚动时,如何分发滚动量?

要解决这个问题就须要自定义一套规则来解决,既然要自定义,咱们就不用这个方案了,这里不论是继承 NestedScrollView 仍是 RecyclerView 都挺麻烦,仍是单独搞把。

2.4 本文方案

方便起见,这里继承自 FrameLayout,命名为 LinkedScrollView,旨在实现能够联动的滚动效果。只设置 topContainerbottomContainer 两个容器子 View,二者上下挨着,使用 scroll 方式实现 View 的位移。

指望实现 topContainer 的子 View 里的内容滚到底时,整个 LinkedScrollView 开始滚到,滚到 bottomContainer 所有露出来时再滚到 topContainer 的子 View 的内容。

这里须要解答下 如何分发滚动量 的核心问题:

  1. 触点位置的容器(topContainerbottomContainer)彻底显示出来,且容器中有能够处理「滚到量」的 View,则分发给该 View 处理
  2. 其余状况本身(LinkedScrollView)优先,本身能够处理「滚到量」就直接处理
  3. 本身不处理时,向下的「滚到量」(大于 0)交给 bottomContainer 的子 View 处理,向上的交给 topContainer 的子 View 处理

这么说太抽象了,咱们拿最终实现的 demo 来讲明吧。

结构上,会在 topContainer 放一个 RecyclerView 暂且命名为 RecyclerViewTopbottomContainer 放一个 ViewPager,里面放两个 RecyclerView 分别命名为 RecyclerView1RecyclerView2

交互上:

  1. 初始化后,topContainer 全屏,bottomContainer 则布局到 topContainer 下面
  2. 手指上滑时,RecyclerViewTop 里的内容先开始向底部滚动,直到滚动到底部
  3. 手指继续上滑,整个 LinkedScrollView 开始向底部滚动,bottomContainer 露出
  4. 此时无论手指在那个方位上下滑,都会滚动 LinkedScrollView,由于topContainerbottomContainer 都没有彻底显示出来
  5. 继续上滑,直到 bottomContainer 彻底显示出来后,开始滚动 ViewPager 里对应 RecyclerView1RecyclerView2 的内容
  6. 手指下滑的状况同理

Fling 比较特殊,这里单独说下。简单的看,fling 就是一系列的滚动,因此也遵循上述规则,fling 的速度大的时候有两个稍特殊的状况:

  1. bottomContainer 里的 RecyclerView1RecyclerView2 向上的 fling(快速下滑),滚动会通过 RecyclerView1/2 -> LinkedScrollView,当 LinkedScrollView 滚到顶,即 topContainer 彻底显示出来后,会继续将「滚动量」传递到 RecyclerViewTop
  2. 同理 RecyclerViewTop 向下的 fling(快速上滑)的滚动会通过: RecyclerViewTop -> LinkedScrollView,当 LinkedScrollView 滚到底,即 bottomContainer 彻底显示出来后,会继续将「滚动量」传递到 ViewPagerRecyclerView1/2(不过豆瓣的 Android 版没作这个处理,iOS 版却是有)

效果以下:

[查看动图](https://user-gold-cdn.xitu.io/2020/4/25/171b0a1dd781872e?w=360&h=640&f=gif&s=1070853)

三、对外暴露的方法和属性

对外主要提供上下两个容器的操做,topContainerbottomContainer 中子 View 的添加和删除。topScrollableViewbottomScrollableView 的设置,这两个会用于 fling,LinkedScrollView 没法处理滚动时,会根据 fling 方向分发给 topContainertopScrollableView 或者 bottomContainerbottomScrollableView 所指向的 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 方法也是可用的。

四、Layout 处理和滚动范围的肯定

布局的处理比较简单,topContainerbottomContainer 上下布局,布局完成后会计算最大滚动范围 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 事件拦截

事件拦截在 仿写豆瓣详情页(二)底部浮层 中有过详细探讨,这里就不赘述了,这里仍是采用「尽量拦截」的思想,拦截后再将 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
                }
            }
            // ...
        }
    }
复制代码

六、Touch 事件的处理和滚动的分发

在 move 时要计算「滚动量」dScrollYfindChildUnder 找到触点所在的直接子 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 的处理

Fling 的处理须要两个辅助类,VelocityTracker 用于计算抬手时的速度,Scroller 用于计算 fling 每次滚动的距离。

onTouchEvent 中经过 VelocityTracker 记录每次事件,在 up 时计算抬手时的速度 yv(这里取反的缘由以前也说过,就是 touch 事件的方向和 scroll 的方向恰好相反)。和 move 时同样,还须要 findChildUnder 找到 childchild?.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 来实现豆瓣详情页横向滑动列表的弹性效果。

github.com/funnywolfda…

相关文章
相关标签/搜索