懒饭详情页嵌套效果仿写(View/Compose 实现)

信息收集

对懒饭 APP 的视频页和详情页做布局抓取分析

上面两图分别是菜谱视频播放页和菜谱详情页,他们之间通过上下滑可以互相切换,如上 gif 所示,但是比较奇怪的是布局层级中菜谱详情页和菜谱视频播放页他们所处的容器是这样的

菜谱视频播放页

<ViewPager>
  <RecyclerView>
    <ViewPager>
      <RecyclerView>
      </RecyclerView>
    </ViewPager>
  </RecyclerView>
</ViewPager>

菜谱详情页

<ViewPager> //猜测是左右滑动不同菜谱使用,类似画廊效果
  <RecyclerView>//猜测是用来做上下滑动容器使用
    <ViewPager>//不知道干啥用的
      <RecyclerView>//猜测是用来做视频+详情的上下滑动容器使用,这里包含了视频控件
            <RecyclerView/>//菜谱的各个用料列表,最内层
      </RecyclerView>
    </ViewPager>
  </RecyclerView>
</ViewPager>

相当诡异,第一直觉是怎么会套了那么多层 ViewPager 和 RecyclerView 呢?可能对 ViewPager 做了什么修改吧,或者可能采取了 Fragment 分块的策略,把各个块全部分割来开发了?或者可能是把 RecyclerView 当成了 NestedScrollView 来做滑动容器使用?当然可能也许是使用了RecycleView + SnapHelper ?具体本人也没细究,感兴趣的同学可以反编译看看。本篇主要讲下怎么用嵌套滚动仿写这个效果

仿写

View 嵌套滚动实现

省略各个细节,这里主要的是视频和详情页的交互

<ViewPager>//左右切换容器
  <VideoView/>//视频播放页
  <RecyclerView/>//详情页
</ViewPager>

加上嵌套容器

<ViewPager>//左右切换容器
  <CookDetailContainerLayout>//嵌套容器,通常为 NestedScrollView 的扩展类
    <VideoView/>//视频播放页
    <RecyclerView/>//详情页
  <CookDetailContainerLayout/>
</ViewPager>

第一个问题:解决 NestedScrollView 嵌套 RecyclerView 导致复用失效的问题

NestedScrollView嵌套RecyclerView导致RecyclerView复用失效的原因?_One-Heart的博客-CSDN博客_nestedscrollview嵌套recyclerview 复用

问题本质上其实就是因为高度不确定导致复用失效了,那其实指定 RecyclerView 的高度即可

我们的页面根本上最终布局大致这样

根据示意图,将 RecyclerView 高度设置为屏幕高度 - inset 栏高度

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        (recyclerView.layoutParams as MarginLayoutParams).run {
            this.height = contentHeight
        }
        (titleTv.layoutParams as MarginLayoutParams).run {
            this.height = contentHeight
        }
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }

让 NestedScrollView 中的 3 块布局可以互相协作,互相 fling

滑动嵌套

主要是 3 块布局滑动,由于主体布局在 NestedScrollView 中,本身已经具备了滑动的条件,第一步我们先让 RecyclerView 能完美的嵌套在 NestedScrollView 中。

  • 向下滚时
    • 假设将要滚动到的距离 scrollY + dy 小于 HeaderView 高度contentHeight ,并且 rv 不能向下滚动,可以向上滚动,说明 rv 到达顶部边界点,这个时候让 NestedScrollView 消耗滚动偏移量,并且让 NestedScrollView 滚动
    • 因为纯 move 事件会存在 deltaY 偏移超过屏幕的情况(比如快速拖动屏幕,这种机制也是为下拉刷新场景服务所用),这种情况需要对边界进行调整,比如这里的,假设将要滚动到的距离 scrollY + dy 大于 HeaderView 高度contentHeight ,rv 不能向下滚动,可以向上滚动,这种情况是 NestedScrollView 滑过界了,需要将其进行校正,校正距离其实也好办,只需要校正实际高度-当前的scrollY 即可(contentHeight - scrollY
  • 向上滚就不阐述了,其实就是和向下滚相反
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {

        if (target is RecyclerView) {
            when {
                dy > 0 -> {
                    //向下滚
                    when {
                        scrollY + dy <= contentHeight
                                && !target.canScrollVertically(-1)
                                && target.canScrollVertically(1) -> {
                            //rv [0,contentHeight] 区域内不能向下滚动,可以向上滚动,说明到达顶部
                            consumed[1] = dy
                            scrollBy(0, dy)
                        }
                        scrollY + dy > contentHeight
                                && !target.canScrollVertically(-1)
                                && target.canScrollVertically(1) -> {
                            //越界的情况,滑过了 [0,contentHeight] 这个范围,需要矫正回来,矫正距离为 contentHeight - scrollY
                            val scrollViewNeedScrollY = contentHeight - scrollY
                            scrollBy(0, scrollViewNeedScrollY)
                            consumed[1] = scrollViewNeedScrollY
                        }
                    }
                }
                dy < 0 -> {
                    //向上滚
                    when {
                        scrollY + dy >= contentHeight
                                && !target.canScrollVertically(1)
                                && target.canScrollVertically(-1) -> {
                            //[contentHeight,+oo] 区域内不能向下滚动,可以向上滚动,说明到达底部
                            //到达底部,并且滑动不会过界
                            consumed[1] = dy
                            scrollBy(0, dy)
                        }
                        scrollY + dy < contentHeight
                                && !target.canScrollVertically(1)
                                && target.canScrollVertically(-1) -> {
                            //由于滑动会有些许误差,这里可以让 ScrollView 边界在 [contentHeight,contentHeight*2]内,即划过界了,那么将其划回来
                            //到达底部,并且滑动过界了
                            val scrollViewNeedScrollY = contentHeight - scrollY
                            scrollBy(0, scrollViewNeedScrollY)
                            consumed[1] = scrollViewNeedScrollY
                        }
                    }
                }
            }
        } else {
            super.onNestedPreScroll(target, dx, dy, consumed, type)
        }
    }

fling 速度互相转移

在 NestScrolledView 中,希望 HeaderView、RecyclerView、FooterView 不同部分滑动 fling 时可以将惯性滚动速度转移到不同的区域中,那么其实只要想办法在 fling 过程中,rv 的上边界和下边界的节点传递速度即可,这样可以将父容器速度传递给子容器

  • 只考虑 fling 的情况,在 HeaderView 区域触发下滚,滚动到 rv 区域时,将速度传输给 rv
  • 只考虑 fling 的情况,在 FooterView 区域触发上滚,滚动到 rv 区域时,将速度传输给 rv
       override fun computeScroll() {
        super.computeScroll()
        if (!scroller.isFinished) {
            val currVelocity = scroller.currVelocity.toInt()
            if (scroller.startY < scroller.finalY
                && scroller.startY < contentHeight
                && scrollY > contentHeight
            ) {
                //在 HeaderView 区域触发下滚
                scroller.abortAnimation()
                // 容器的 fling 速度交给 rv
                recyclerView.fling(0, currVelocity)
            } else if (scroller.startY > scroller.finalY && scrollY > contentHeight) {
                //在 footerView 区域触发上滚
                scroller.abortAnimation()
                recyclerView.fling(0, -currVelocity)
            }
        }
    }

定制懒饭的效果

懒饭的效果类似于 HeaderView 和 rv+FooterView 是两个上下的页面,所以我们要切断他们的 fling 联系

HeaderView 向上 fling ,控制 fling 不让其传输 rv 中去

  • 注释掉相关的联合滚动的 fling 机制
    override fun computeScroll() {
        super.computeScroll()
        if (!scroller.isFinished) {
            val currVelocity = scroller.currVelocity.toInt()
            if (scroller.startY < scroller.finalY
                && scroller.startY < contentHeight
                && scrollY > contentHeight
            ) {
                //在 HeaderView 区域触发下滚
//                scroller.abortAnimation()
//                scrollTo(0, contentHeight)
                // 容器的 fling 速度交给 rv
//                recyclerView.fling(0, currVelocity)
            } else if (scroller.startY > scroller.finalY && scrollY > contentHeight) {
                //在 footerView 区域触发上滚
                scroller.abortAnimation()
                recyclerView.fling(0, -currVelocity)
            }
        }
    }

  • 对 fling 做拦截处理,在 fling 开始时,在 headerView 中,并且目的地会滑动到 rv 中的情况强制做结束scroller 滚动处理,重置将 scroller 目的地改为 rv.top 边界 contentHeight
    override fun fling(velocityY: Int) {
        super.fling(velocityY)
        if (scroller.startY < contentHeight && scroller.finalY > contentHeight) {
            scroller.abortAnimation()
            smoothScrollTo(0, contentHeight, 400)
        }
    }

RV 向上 fling 时,不让 rv 的速度传输到 parent 去

  • rv 顶部,fling 模式下,也就是 type 为 TYPE_NON_TOUCH ,并且 rv 不会消耗任何滚动距离,认为是被带着向上滚,将此行为干掉,不让 rv 翻到上一页
     override fun onNestedScroll(
        target: View, dxConsumed: Int, dyConsumed: Int,
        dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray
    ) {
        when {
            type == ViewCompat.TYPE_NON_TOUCH
                    && target.canScrollVertically(1)
                    && !target.canScrollVertically(-1)
                    && dyConsumed != 0
            -> {
                // rv 顶部,fling 模式,并且 rv 不会消耗任何滚动距离,认为是被带着向上滚,将此行为干掉,不让 rv 翻到上一页
                return
            }
        }
        onNestedScrollInternal(dyUnconsumed, type, consumed)
    }

让 HeaderView 和 RV 之间具有回弹效果

  • 在滚动结束时,判断当前滚动到的区域,假设是半屏之外,则翻页,否则,复位
  override fun onScrollStateChanged(newState: ScrollStateEnum) {
        super.onScrollStateChanged(newState)
        if (newState == ScrollStateEnum.SCROLL_STATE_IDLE) {
            if (scrollY >= contentHeight / 2 && scrollY < contentHeight) {
                smoothScrollTo(0, contentHeight)
            } else if (scrollY < contentHeight / 2 && scrollY >= 0) {
                smoothScrollTo(0, 0)
            }
        }
    }

  • 注意点

    • canScrollVertically() 代表的是否能向某个方向滚动,而不是滑动,滚动应该跟滑动方向相反,比如 direction 为正代表向下滚动,也就是向上滑动,其滚动方向跟进度条方向一致
image.png

Compose 实现

compose 实现起来简直傻瓜式,官方提供了 Pager 这个控件,只需要横向一个 Pager ,再竖向一个 Pager 即可

@Composable
fun LazyCookDetailPage() {
    val screenHeight = LocalConfiguration.current.screenHeightDp.dp
    val screenWidth = LocalConfiguration.current.screenWidthDp.dp

    HorizontalPager(
        count = CookDetailConstants.detailEntities.size,
    ) { horizontalPageIndex ->
        VerticalPager(count = 2) { verticalPageIndex ->
            when (verticalPageIndex) {
                0 -> {
                    HeaderPage(screenWidth, screenHeight, horizontalPageIndex)
                }
                1 -> {
                    ContentPage(screenWidth, screenHeight, horizontalPageIndex)
                }
            }
        }
    }
}

  • 界面实现代码
@Composable
private fun ContentPage(
    screenWidth: Dp,
    screenHeight: Dp,
    horizontalPageIndex: Int
) {
    LazyColumn(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .size(screenWidth, screenHeight)
    ) {
        items(CookDetailConstants.detailEntities[horizontalPageIndex].cookDetailSteps) { item ->
            ListItem(item)
        }
        item {
            Box(
                modifier = Modifier
                    .size(screenWidth, screenHeight)
                    .background(color = Color(ColorUtils.getRandomColor())),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = CookDetailConstants.detailEntities[horizontalPageIndex].videoName + " footer",
                    fontSize = 25.sp,
                    textAlign = TextAlign.Center,
                )
            }
        }
    }
}

@Composable
private fun HeaderPage(
    screenWidth: Dp,
    screenHeight: Dp,
    horizontalPageIndex: Int
) {
    Box(
        modifier = Modifier
            .size(screenWidth, screenHeight)
            .background(color = Color(ColorUtils.getRandomColor())),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = CookDetailConstants.detailEntities[horizontalPageIndex].videoName + " header",
            fontSize = 25.sp,
            textAlign = TextAlign.Center,
        )
    }
}

@Composable
private fun ListItem(item: CookDetailStepEntity) {
    Row(
        horizontalArrangement = Arrangement.SpaceAround,
        modifier = Modifier
            .background(
                color = Color(ColorUtils.getRandomColor())
            )
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text(text = item.stepName)
        Spacer(modifier = Modifier.width(16.dp))
        Text(text = item.stepDesc)
    }
}

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容