信息收集
對懶飯 APP 的視頻頁和詳情頁做布局抓取分析
上面兩圖分別是菜譜視頻播放頁和菜譜詳情頁踪宠,他們之間通過上下滑可以互相切換秆剪,如上 gif 所示躯畴,但是比較奇怪的是布局層級中菜譜詳情頁和菜譜視頻播放頁他們所處的容器是這樣的
菜譜視頻播放頁
<ViewPager>
<RecyclerView>
<ViewPager>
<RecyclerView>
</RecyclerView>
</ViewPager>
</RecyclerView>
</ViewPager>
菜譜詳情頁
<ViewPager> //猜測是左右滑動不同菜譜使用,類似畫廊效果
<RecyclerView>//猜測是用來做上下滑動容器使用
<ViewPager>//不知道干啥用的
<RecyclerView>//猜測是用來做視頻+詳情的上下滑動容器使用矛物,這里包含了視頻控件
<RecyclerView/>//菜譜的各個用料列表,最內(nèi)層
</RecyclerView>
</ViewPager>
</RecyclerView>
</ViewPager>
相當詭異跪但,第一直覺是怎么會套了那么多層 ViewPager 和 RecyclerView 呢履羞?可能對 ViewPager 做了什么修改吧,或者可能采取了 Fragment 分塊的策略屡久,把各個塊全部分割來開發(fā)了忆首?或者可能是把 RecyclerView 當成了 NestedScrollView 來做滑動容器使用?當然可能也許是使用了RecycleView + SnapHelper 被环?具體本人也沒細究糙及,感興趣的同學(xué)可以反編譯看看。本篇主要講下怎么用嵌套滾動仿寫這個效果
仿寫
View 嵌套滾動實現(xiàn)
省略各個細節(jié)筛欢,這里主要的是視頻和詳情頁的交互
<ViewPager>//左右切換容器
<VideoView/>//視頻播放頁
<RecyclerView/>//詳情頁
</ViewPager>
加上嵌套容器
<ViewPager>//左右切換容器
<CookDetailContainerLayout>//嵌套容器浸锨,通常為 NestedScrollView 的擴展類
<VideoView/>//視頻播放頁
<RecyclerView/>//詳情頁
<CookDetailContainerLayout/>
</ViewPager>
第一個問題:解決 NestedScrollView 嵌套 RecyclerView 導(dǎo)致復(fù)用失效的問題
問題本質(zhì)上其實就是因為高度不確定導(dǎo)致復(fù)用失效了版姑,那其實指定 RecyclerView 的高度即可
我們的頁面根本上最終布局大致這樣
根據(jù)示意圖柱搜,將 RecyclerView 高度設(shè)置為屏幕高度 - 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 塊布局可以互相協(xié)作,互相 fling
滑動嵌套
主要是 3 塊布局滑動漠酿,由于主體布局在 NestedScrollView 中冯凹,本身已經(jīng)具備了滑動的條件,第一步我們先讓 RecyclerView 能完美的嵌套在 NestedScrollView 中炒嘲。
- 向下滾時
- 假設(shè)將要滾動到的距離
scrollY + dy
小于 HeaderView 高度contentHeight
宇姚,并且 rv 不能向下滾動,可以向上滾動夫凸,說明 rv 到達頂部邊界點浑劳,這個時候讓 NestedScrollView 消耗滾動偏移量,并且讓 NestedScrollView 滾動 - 因為純 move 事件會存在 deltaY 偏移超過屏幕的情況(比如快速拖動屏幕夭拌,這種機制也是為下拉刷新場景服務(wù)所用)魔熏,這種情況需要對邊界進行調(diào)整,比如這里的鸽扁,假設(shè)將要滾動到的距離
scrollY + dy
大于 HeaderView 高度contentHeight
蒜绽,rv 不能向下滾動,可以向上滾動桶现,這種情況是NestedScrollView
滑過界了躲雅,需要將其進行校正,校正距離其實也好辦骡和,只需要校正實際高度-當前的scrollY 即可(contentHeight - scrollY
)
- 假設(shè)將要滾動到的距離
- 向上滾就不闡述了相赁,其實就是和向下滾相反
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] 區(qū)域內(nèi)不能向下滾動相寇,可以向上滾動,說明到達頂部
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] 區(qū)域內(nèi)不能向下滾動绵脯,可以向上滾動佳励,說明到達底部
//到達底部,并且滑動不會過界
consumed[1] = dy
scrollBy(0, dy)
}
scrollY + dy < contentHeight
&& !target.canScrollVertically(1)
&& target.canScrollVertically(-1) -> {
//由于滑動會有些許誤差桨嫁,這里可以讓 ScrollView 邊界在 [contentHeight,contentHeight*2]內(nèi)植兰,即劃過界了份帐,那么將其劃回來
//到達底部璃吧,并且滑動過界了
val scrollViewNeedScrollY = contentHeight - scrollY
scrollBy(0, scrollViewNeedScrollY)
consumed[1] = scrollViewNeedScrollY
}
}
}
}
} else {
super.onNestedPreScroll(target, dx, dy, consumed, type)
}
}
fling 速度互相轉(zhuǎn)移
在 NestScrolledView 中,希望 HeaderView废境、RecyclerView畜挨、FooterView 不同部分滑動 fling 時可以將慣性滾動速度轉(zhuǎn)移到不同的區(qū)域中,那么其實只要想辦法在 fling 過程中噩凹,rv 的上邊界和下邊界的節(jié)點傳遞速度即可巴元,這樣可以將父容器速度傳遞給子容器
- 只考慮 fling 的情況,在 HeaderView 區(qū)域觸發(fā)下滾驮宴,滾動到 rv 區(qū)域時逮刨,將速度傳輸給 rv
- 只考慮 fling 的情況,在 FooterView 區(qū)域觸發(fā)上滾堵泽,滾動到 rv 區(qū)域時修己,將速度傳輸給 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 區(qū)域觸發(fā)下滾
scroller.abortAnimation()
// 容器的 fling 速度交給 rv
recyclerView.fling(0, currVelocity)
} else if (scroller.startY > scroller.finalY && scrollY > contentHeight) {
//在 footerView 區(qū)域觸發(fā)上滾
scroller.abortAnimation()
recyclerView.fling(0, -currVelocity)
}
}
}
定制懶飯的效果
懶飯的效果類似于 HeaderView 和 rv+FooterView 是兩個上下的頁面,所以我們要切斷他們的 fling 聯(lián)系
HeaderView 向上 fling 迎罗,控制 fling 不讓其傳輸 rv 中去
- 注釋掉相關(guān)的聯(lián)合滾動的 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 區(qū)域觸發(fā)下滾
// scroller.abortAnimation()
// scrollTo(0, contentHeight)
// 容器的 fling 速度交給 rv
// recyclerView.fling(0, currVelocity)
} else if (scroller.startY > scroller.finalY && scrollY > contentHeight) {
//在 footerView 區(qū)域觸發(fā)上滾
scroller.abortAnimation()
recyclerView.fling(0, -currVelocity)
}
}
}
- 對 fling 做攔截處理睬愤,在 fling 開始時,在 headerView 中纹安,并且目的地會滑動到 rv 中的情況強制做結(jié)束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 的速度傳輸?shù)?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 之間具有回彈效果
- 在滾動結(jié)束時球恤,判斷當前滾動到的區(qū)域,假設(shè)是半屏之外荸镊,則翻頁咽斧,否則,復(fù)位
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() 代表的是否能向某個方向滾動躬存,而不是滑動张惹,滾動應(yīng)該跟滑動方向相反,比如 direction 為正代表向下滾動岭洲,也就是向上滑動宛逗,其滾動方向跟進度條方向一致
Compose 實現(xiàn)
compose 實現(xiàn)起來簡直傻瓜式,官方提供了 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)
}
}
}
}
}
- 界面實現(xiàn)代碼
@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)
}
}