草庐IT

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

minminaya 2023-03-28 原文

信息收集

对懒饭 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)
    }
}

有关懒饭详情页嵌套效果仿写(View/Compose 实现)的更多相关文章

  1. ruby-on-rails - Rails 编辑表单不显示嵌套项 - 2

    我得到了一个包含嵌套链接的表单。编辑时链接字段为空的问题。这是我的表格:Editingkategori{:action=>'update',:id=>@konkurrancer.id})do|f|%>'Trackingurl',:style=>'width:500;'%>'Editkonkurrence'%>|我的konkurrencer模型:has_one:link我的链接模型:classLink我的konkurrancer编辑操作:defedit@konkurrancer=Konkurrancer.find(params[:id])@konkurrancer.link_attrib

  2. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  3. ruby-on-rails - 渲染另一个 Controller 的 View - 2

    我想要做的是有2个不同的Controller,client和test_client。客户端Controller已经构建,我想创建一个test_clientController,我可以使用它来玩弄客户端的UI并根据需要进行调整。我主要是想绕过我在客户端中内置的验证及其对加载数据的管理Controller的依赖。所以我希望test_clientController加载示例数据集,然后呈现客户端Controller的索引View,以便我可以调整客户端UI。就是这样。我在test_clients索引方法中试过这个:classTestClientdefindexrender:template=>

  4. ruby - 将散列转换为嵌套散列 - 2

    这道题是thisquestion的逆题.给定一个散列,每个键都有一个数组,例如{[:a,:b,:c]=>1,[:a,:b,:d]=>2,[:a,:e]=>3,[:f]=>4,}将其转换为嵌套哈希的最佳方法是什么{:a=>{:b=>{:c=>1,:d=>2},:e=>3,},:f=>4,} 最佳答案 这是一个迭代的解决方案,递归的解决方案留给读者作为练习:defconvert(h={})ret={}h.eachdo|k,v|node=retk[0..-2].each{|x|node[x]||={};node=node[x]}node[

  5. ruby-on-rails - 如何在我的 Rails 应用程序 View 中打印 ruby​​ 变量的内容? - 2

    我是一个Rails初学者,但我想从我的RailsView(html.haml文件)中查看Ruby变量的内容。我试图在ruby​​中打印出变量(认为它会在终端中出现),但没有得到任何结果。有什么建议吗?我知道Rails调试器,但更喜欢使用inspect来打印我的变量。 最佳答案 您可以在View中使用puts方法将信息输出到服务器控制台。您应该能够在View中的任何位置使用Haml执行以下操作:-puts@my_variable.inspect 关于ruby-on-rails-如何在我的R

  6. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  7. ruby-on-rails - 如何在 Rails View 上显示错误消息? - 2

    我是rails的新手,想在form字段上应用验证。myviewsnew.html.erb.....模拟.rbclassSimulation{:in=>1..25,:message=>'Therowmustbebetween1and25'}end模拟Controller.rbclassSimulationsController我想检查模型类中row字段的整数范围,如果不在范围内则返回错误信息。我可以检查上面代码的范围,但无法返回错误消息提前致谢 最佳答案 关键是您使用的是模型表单,一种显示ActiveRecord模型实例属性的表单。c

  8. Ruby——嵌套类和子类是一回事吗? - 2

    下面例子中的Nested和Child有什么区别?是否只是同一事物的不同语法?classParentclassNested...endendclassChild 最佳答案 不,它们是不同的。嵌套:Computer之外的“Processor”类只能作为Computer::Processor访问。嵌套为内部类(namespace)提供上下文。对于ruby​​解释器Computer和Computer::Processor只是两个独立的类。classComputerclassProcessor#Tocreateanobjectforthisc

  9. ruby - 模块嵌套代码风格偏好 - 2

    我的假设是moduleAmoduleBendend和moduleA::Bend是一样的。我能够从thisblog找到解决方案,thisSOthread和andthisSOthread.为什么以及什么时候应该更喜欢紧凑语法A::B而不是另一个,因为它显然有一个缺点?我有一种直觉,它可能与性能有关,因为在更多命名空间中查找常量需要更多计算。但是我无法通过对普通类进行基准测试来验证这一点。 最佳答案 这两种写作方法经常被混淆。首先要说的是,据我所知,没有可衡量的性能差异。(在下面的书面示例中不断查找)最明显的区别,可能也是最著名的,是你的

  10. ruby-on-rails - 使用回形针的嵌套形式 - 2

    我有一个名为posts的模型,它有很多附件。附件模型使用回形针。我制作了一个用于创建附件的独立模型,效果很好,这是此处说明的View(https://github.com/thoughtbot/paperclip):@attachment,:html=>{:multipart=>true}do|form|%>posts中的嵌套表单如下所示:prohibitedthispostfrombeingsaved:@attachment,:html=>{:multipart=>true}do|at_form|%>附件记录已创建,但它是空的。文件未上传。同时,帖子已成功创建...有什么想法吗?

随机推荐