之前基于Compose大致实现了列表悬停juejin.cn/post/756195... 但联动不是很丝滑,这篇文章将来协调这些不同层次和类型的滚动视图,实现它们之间的滚动联动。
本文将基于一套定制的 Kotlin 类来实现一个通用的、支持多种滚动视图(包括 LazyList、LazyGrid 甚至 WebView)的 Compose 嵌套滚动联动方案,并提供完整的源码解析。
1. 核心模型:滚动协调器 (ScrollCoordinator)
实现嵌套滚动的关键在于责任分离 和状态统一管理。我们首先定义接口和数据结构来统一管理不同页面的滚动状态。
1.1 滚动协调器接口与数据结构
ScrollCoordinator 接口定义了统一的滚动行为规范。
ScrollCoordinator.kt 核心代码:
Kotlin
kotlin
// 滚动页面类型枚举
enum class ScrollPageType {
SUB_LAZY_LIST, // 嵌套的 LazyList 状态
LAZY_LIST, //
WEB_VIEW, //
CUSTOM //
}
// 滚动页面信息
data class ScrollPageInfo(
val pageIndex: Int,
val pageType: ScrollPageType,
val scrollState: Any,
val canScrollChecker: ((direction: Int) -> Boolean)? = null,
)
// SubListState: 用于管理 Tab 内部还有多 Tab 的复杂场景
data class SubListState(
var currentPageIndex: Int = 0,
val listStateMap: MutableMap<Int, LazyListState> = mutableMapOf(),
) {
fun getCurrentListState(): LazyListState? {
return listStateMap[currentPageIndex]
}
suspend fun resetAllScrollStates() {
listStateMap.forEach {
// 清除之前的滚动状态
it.value.scrollToItem(0)
}
}
}
interface ScrollCoordinator {
/** 注册滚动状态 */
fun registerScrollState(pageIndex: Int, scrollState: Any)
/** 设置当前页面索引 */
fun setCurrentPageIndex(index: Int)
/** 检查是否可以滚动 */
fun canScrollVertically(direction: Int): Boolean
/** 处理滚动偏移 */
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset
/** 重置所有页面的滚动状态 */
fun resetAllScrollStates()
/** 清理资源 */
fun cleanup()
}
1.2 通用滚动协调器实现 (UniversalScrollCoordinator)
UniversalScrollCoordinator 实现了 ScrollCoordinator 接口,并负责注册、管理和查询不同页面的滚动状态和能力。
UniversalScrollCoordinator.kt 核心代码:
Kotlin
kotlin
class UniversalScrollCoordinator(
private val scope: CoroutineScope,
): ScrollCoordinator {
private val scrollPages = mutableMapOf<Int, ScrollPageInfo>()
private var currentPageIndex = 0
override fun registerScrollState(pageIndex: Int, scrollState: Any) {
// 根据状态类型判断 PageType
val pageType = when(scrollState) {
is ScrollableState -> ScrollPageType.LAZY_LIST
is SubListState -> ScrollPageType.SUB_LAZY_LIST
is WebView -> ScrollPageType.WEB_VIEW
else -> ScrollPageType.CUSTOM
}
// 抽象不同组件的滚动能力检查
val canScrollChecker: (Int) -> Boolean = when(scrollState) {
is ScrollableState -> {direction ->
if (direction > 0) scrollState.canScrollForward else scrollState.canScrollBackward
}
is SubListState -> {direction ->
if (direction > 0) scrollState.getCurrentListState()?.canScrollForward ?: false else scrollState.getCurrentListState()?.canScrollBackward
?: false
}
is WebView -> {direction ->
scrollState.canScrollVertically(direction)
}
else -> {_ -> false}
}
scrollPages[pageIndex] = ScrollPageInfo(
pageIndex = pageIndex,
pageType = pageType,
scrollState = scrollState,
canScrollChecker = canScrollChecker
)
}
override fun setCurrentPageIndex(index: Int) {
currentPageIndex = index
}
override fun canScrollVertically(direction: Int): Boolean {
// 通过 Checker 统一检查当前页面的滚动能力
return scrollPages[currentPageIndex]?.canScrollChecker?.invoke(direction) ?: false
}
// ... onPreScroll 为空实现,留给 CustomNestedScrollConnection 实现
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return Offset.Companion.Zero
}
// 实现所有滚动状态的重置逻辑
override fun resetAllScrollStates() {
scope.launch {
scrollPages.values.forEach {pageInfo ->
if (pageInfo.scrollState is SubListState) {
pageInfo.scrollState.resetAllScrollStates()
} else if (pageInfo.scrollState is ScrollableState) {
if (pageInfo.scrollState is LazyListState) {
pageInfo.scrollState.scrollToItem(0)
} else if (pageInfo.scrollState is LazyGridState) {
pageInfo.scrollState.scrollToItem(0)
}
} else if (pageInfo.scrollState is WebView) {
pageInfo.scrollState.scrollTo(0, 0)
}
}
}
}
override fun cleanup() {
scrollPages.clear()
}
fun getScrollState(pageIndex: Int): Any? {
return scrollPages[pageIndex]?.scrollState
}
fun getCurrentPageInfo(): ScrollPageInfo? {
return scrollPages[currentPageIndex]
}
}
2. 基础嵌套滚动连接器 (CustomNestedScrollConnection)
CustomNestedScrollConnection 是所有嵌套滚动逻辑的基础,它实现了 Compose 的 NestedScrollConnection 接口,并依赖 UniversalScrollCoordinator 来处理内层页面的滚动。
CustomNestedScrollConnection.kt 核心代码:
Kotlin
kotlin
open class CustomNestedScrollConnection(
scope: CoroutineScope,
): NestedScrollConnection {
// private val logger = LoggerFactory.getLogger("CustomNestedScrollConnection") // 隐去 Log
private val scrollCoordinator = UniversalScrollCoordinator(scope) //
var currentTabIndex = 0
set(value) {
field = value
scrollCoordinator.setCurrentPageIndex(value)
}
// ... 注册、查询、清理等辅助方法
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val currentPageInfo = getCurrentPageInfo()
val currentScrollState = getCurrentScrollState()
// 根据当前页面类型处理滚动
when(currentPageInfo?.pageType) {
ScrollPageType.WEB_VIEW -> {
// WebView页面:手动调用WebView的滚动
(currentScrollState as? WebView)?.let {webView ->
val deltaY = available.y.toInt()
if (deltaY != 0) {
webView.scrollBy(0, - deltaY) // 注意方向
return available // 完全消费
}
}
return Offset.Zero
}
ScrollPageType.LAZY_LIST -> {
// LazyList页面:让Compose自己处理滚动
(currentScrollState as? ScrollableState)?.apply {
if (available.y > 0 && canScrollBackward) {
dispatchRawDelta(- available.y)
return available
} else if (available.y < 0 && canScrollForward) {
dispatchRawDelta(- available.y)
return available
}
}
return Offset.Zero
}
ScrollPageType.SUB_LAZY_LIST -> {
// 嵌套的 LazyList 页面:让Compose自己处理滚动
(currentScrollState as? SubListState)?.getCurrentListState()?.apply {
if (available.y > 0 && canScrollBackward) {
dispatchRawDelta(- available.y)
return available
} else if (available.y < 0 && canScrollForward) {
dispatchRawDelta(- available.y)
return available
}
}
return available
}
else -> {
return Offset.Zero
}
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val currentScrollState = getCurrentScrollState()
val currentPageInfo = getCurrentPageInfo()
// 尝试在WebView上执行Fling
if (currentPageInfo?.pageType == ScrollPageType.WEB_VIEW) {
(currentScrollState as? WebView)?.let {webView ->
val velocityY = available.y.toInt()
if (velocityY != 0) {
// 检查 WebView 是否能够在对应方向上继续滚动
val canScrollUp = webView.canScrollVertically(- 1)
val canScrollDown = webView.canScrollVertically(1)
if ((velocityY < 0 && canScrollUp) || (velocityY > 0 && canScrollDown)) {
// WebView 可以在对应方向上滚动,执行 fling
webView.flingScroll(0, - velocityY)
return available
} else {
// WebView 无法在对应方向上滚动,返回全部速度让其他组件处理
return Velocity.Zero
}
}
}
}
return super.onPreFling(available)
}
// ... 辅助函数 (canScrollVertically, registerSubListState, etc.)
fun canScrollVertically(direction: Int): Boolean {
return scrollCoordinator.canScrollVertically(direction)
}
fun registerSubListState(pageIndex: Int, state: SubListState) {
scrollCoordinator.registerScrollState(pageIndex, state)
}
fun registerSubListState(pageIndex: Int, state: ScrollableState) {
scrollCoordinator.registerScrollState(pageIndex, state)
}
fun registerWebView(pageIndex: Int, webView: WebView) {
scrollCoordinator.registerScrollState(pageIndex, webView)
}
fun resetAllScrollStates() {
scrollCoordinator.resetAllScrollStates()
}
fun cleanup() {
scrollCoordinator.cleanup()
}
private fun getCurrentScrollState(): Any? {
return scrollCoordinator.getScrollState(currentTabIndex)
}
private fun getCurrentPageInfo(): ScrollPageInfo? {
return scrollCoordinator.getCurrentPageInfo()
}
}
3. 外层与内层列表联动 (ListNestedScrollConnection)
ListNestedScrollConnection 继承自 CustomNestedScrollConnection,专注于处理外层 LazyListState 与内层滚动视图之间的联动逻辑。
ListNestedScrollConnection.kt 核心代码:
Kotlin
kotlin
open class ListNestedScrollConnection(
private val scope: CoroutineScope,
private val outerListState: LazyListState, // 外层列表的状态
): CustomNestedScrollConnection(
scope = scope
) {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// delta < 0 向上滚动, delta > 0 向下滚动
val delta = available.y
if (delta < 0) {
// 向上滚动:优先滚动外层
if (outerListState.canScrollForward) {
val consumed = outerListState.dispatchRawDelta(- delta) // 外层消耗
val unconsumed = delta + consumed
if (unconsumed == 0f) {
return available
} else {
// 未消费的传递给内层处理
return super.onPreScroll(Offset(available.x, unconsumed), source)
}
}
} else if (delta > 0) {
// 向下滚动:优先让内层滚动
// 检查内层是否已到顶部
if (! canScrollVertically(- 1)) { //
// 内层已到顶部,可以滚动外层
if (outerListState.canScrollBackward) {
val consumed = outerListState.dispatchRawDelta(- delta)
val unconsumed = delta + consumed
if (unconsumed == 0f) {
return available
} else {
// 未消费的传递给内层处理
return super.onPreScroll(Offset(available.x, unconsumed), source)
}
}
}
}
// 默认情况:让内层先滚动 (例如:向下滚动时,内层还未到顶)
return super.onPreScroll(available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
val delta = available.y
// delta < 0 向上滚动, delta > 0 向下滚动
if (delta < 0) {
// 向上滚动:优先滚动外层列表,直到完全滚出屏幕
if (outerListState.canScrollForward) {
val maxScrollDistance = calculateMaxScrollDistance(outerListState, delta) // 估算最大距离
if (abs(maxScrollDistance) >= abs(delta)) {
// 外层可以完全消费这个速度
outerListState.animateScrollBy(- delta)
return available
} else {
// 外层只能部分消费,计算剩余速度
outerListState.animateScrollBy(- maxScrollDistance)
val remainingVelocity = delta - maxScrollDistance
return super.onPreFling(Velocity(0f, remainingVelocity))
}
}
} else if (delta > 0) {
// 向下滚动:优先让内层滚动到顶部,然后再滚动外层
if (! canScrollVertically(- 1)) {
// 内层已经滚动到顶部,可以滚动外层
if (outerListState.canScrollBackward) {
val maxScrollDistance = calculateMaxScrollDistance(outerListState, delta)
if (abs(maxScrollDistance) >= abs(delta)) {
// 外层可以完全消费这个速度
outerListState.animateScrollBy(- delta)
return available
} else {
// 外层只能部分消费
outerListState.animateScrollBy(- maxScrollDistance)
val remainingVelocity = delta - maxScrollDistance
return super.onPreFling(Velocity(0f, remainingVelocity))
}
}
}
}
return super.onPreFling(available)
}
/**
* 估算外层列表最大滚动距离
*/
private fun calculateMaxScrollDistance(listState: LazyListState, requestedDelta: Float): Float {
val layoutInfo = listState.layoutInfo
val firstVisibleIndex = listState.firstVisibleItemIndex
val firstVisibleOffset = listState.firstVisibleItemScrollOffset
val totalItemsCount = layoutInfo.totalItemsCount
return when {
requestedDelta < 0 -> {
// 向上滚动,计算到底部的距离
if (firstVisibleIndex == totalItemsCount - 1) {
// 已经是最后一项,计算剩余可滚动距离
val lastItemSize = layoutInfo.visibleItemsInfo.lastOrNull()?.size ?: 0
val viewportHeight = layoutInfo.viewportSize.height
min(0f, - (lastItemSize - viewportHeight + firstVisibleOffset).toFloat())
} else {
requestedDelta // 还有更多项目,可以滚动
}
}
requestedDelta > 0 -> {
// 向下滚动,计算到顶部的距离
if (firstVisibleIndex == 0) {
min(requestedDelta, firstVisibleOffset.toFloat())
} else {
requestedDelta // 还有更多项目,可以滚动
}
}
else -> 0f
}
}
}
总结
这套方案通过分层的设计模式,实现了复杂嵌套滚动场景下的状态统一管理 和行为精准协调:
UniversalScrollCoordinator:抽象并管理所有内嵌滚动视图的状态(包括LazyListState和WebView)。CustomNestedScrollConnection:作为基础连接器,实现 Tab 页面内部各种滚动视图的滚动和惯性消耗逻辑。ListNestedScrollConnection:专门处理外层列表和内层列表之间的协同工作,通过精确判断滚动方向和内层是否到顶,决定滚动事件的流向,从而实现头部收缩/展开和内页滚动之间的完美联动。