基于Jetpack Compose 实现列表嵌套滚动联动机制 (完整源码解析)

之前基于Compose大致实现了列表悬停juejin.cn/post/756195... 但联动不是很丝滑,这篇文章将来协调这些不同层次和类型的滚动视图,实现它们之间的滚动联动。

本文将基于一套定制的 Kotlin 类来实现一个通用的、支持多种滚动视图(包括 LazyListLazyGrid 甚至 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
    }
  }
}

总结

这套方案通过分层的设计模式,实现了复杂嵌套滚动场景下的状态统一管理行为精准协调

  1. UniversalScrollCoordinator :抽象并管理所有内嵌滚动视图的状态(包括 LazyListStateWebView)。
  2. CustomNestedScrollConnection:作为基础连接器,实现 Tab 页面内部各种滚动视图的滚动和惯性消耗逻辑。
  3. ListNestedScrollConnection:专门处理外层列表和内层列表之间的协同工作,通过精确判断滚动方向和内层是否到顶,决定滚动事件的流向,从而实现头部收缩/展开和内页滚动之间的完美联动。
相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui