信息收集
对懒饭 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 导致复用失效的问题
问题本质上其实就是因为高度不确定导致复用失效了,那其实指定 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 为正代表向下滚动,也就是向上滑动,其滚动方向跟进度条方向一致
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)
}
}