懶飯詳情頁嵌套效果仿寫(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)
    }
}

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容