基于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:专门处理外层列表和内层列表之间的协同工作,通过精确判断滚动方向和内层是否到顶,决定滚动事件的流向,从而实现头部收缩/展开和内页滚动之间的完美联动。
相关推荐
林栩link3 小时前
【车载Android】使用自定义插件实现多语言自动化适配
android
消失的旧时光-19438 小时前
Flutter 响应式 + Clean Architecture / MVU 模式 实战指南
android·flutter·架构
404未精通的狗8 小时前
(数据结构)栈和队列
android·数据结构
恋猫de小郭9 小时前
今年各大厂都在跟进的智能眼镜是什么?为什么它突然就成为热点之一?它是否是机会?
android·前端·人工智能
游戏开发爱好者811 小时前
iOS 混淆工具链实战 多工具组合完成 IPA 混淆与加固 无源码混淆
android·ios·小程序·https·uni-app·iphone·webview
豆豆豆大王16 小时前
Android 数据持久化(SharedPreferences)
android
Paper_Love16 小时前
RK3588-android-reboot命令内核调用流程
android
介一安全16 小时前
【Frida Android】基础篇12:Native层hook基础——调用原生函数
android·网络安全·逆向·安全性测试·frida·1024程序员节
2501_9160088917 小时前
用多工具组合把 iOS 混淆做成可复用的工程能力(iOS混淆|IPA加固|无源码混淆|Ipa Guard|Swift Shield)
android·开发语言·ios·小程序·uni-app·iphone·swift