滚动

滚动修饰符

The verticalScrollhorizontalScroll 修饰符提供了最简单的方式,当元素内容的边界大于其最大尺寸约束时,允许用户滚动元素。使用 verticalScrollhorizontalScroll 修饰符,你不需要平移或偏移内容。

@Composable
private fun ScrollBoxes() {
    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .verticalScroll(rememberScrollState())
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

A simple vertical list responding to scroll
gestures

The ScrollState 允许你更改滚动位置或获取其当前状态。要使用默认参数创建它,请使用 rememberScrollState().

@Composable
private fun ScrollBoxesSmooth() {
    // Smoothly scroll 100px on first composition
    val state = rememberScrollState()
    LaunchedEffect(Unit) { state.animateScrollTo(100) }

    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .padding(horizontal = 8.dp)
            .verticalScroll(state)
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

可滚动修饰符

The scrollable 修饰符与滚动修饰符的不同之处在于,scrollable 检测滚动手势并捕获增量,但不会自动偏移其内容。这将委托给用户通过 ScrollableState ,这是此修饰符正常工作所必需的。

在构造 ScrollableState 时,你必须提供一个 consumeScrollDelta 函数,该函数将在每个滚动步骤(通过手势输入、平滑滚动或弹跳)上调用,并带有以像素为单位的增量。此函数必须返回消耗的滚动距离量,以确保在嵌套元素具有 scrollable 修饰符的情况下正确传播事件。

以下代码段检测手势并显示偏移量的数值,但不会偏移任何元素

@Composable
private fun ScrollableSample() {
    // actual composable state
    var offset by remember { mutableStateOf(0f) }
    Box(
        Modifier
            .size(150.dp)
            .scrollable(
                orientation = Orientation.Vertical,
                // Scrollable state: describes how to consume
                // scrolling delta and update offset
                state = rememberScrollableState { delta ->
                    offset += delta
                    delta
                }
            )
            .background(Color.LightGray),
        contentAlignment = Alignment.Center
    ) {
        Text(offset.toString())
    }
}

A UI element detecting the finger press and displaying the numeric value for
the finger's
location

嵌套滚动

嵌套滚动是一个系统,其中多个相互包含的滚动组件通过对单个滚动手势做出反应并传达它们的滚动增量(变化)来协同工作。

嵌套滚动系统允许在可滚动且层次结构上链接的组件之间进行协调(最常见的是共享同一个父级)。此系统链接滚动容器,并允许与正在传播和共享的滚动增量进行交互。

Compose 提供了多种方式来处理可组合项之间的嵌套滚动。嵌套滚动的典型示例是另一个列表中的列表,而更复杂的情况是 折叠工具栏.

自动嵌套滚动

简单嵌套滚动不需要你执行任何操作。启动滚动操作的手势会自动从子级传播到父级,以便当子级无法进一步滚动时,该手势将由其父元素处理。

Compose 的一些组件和修饰符默认支持自动嵌套滚动: verticalScrollhorizontalScrollscrollableLazy API 和 TextField。这意味着当用户滚动嵌套组件的内部子级时,前面的修饰符会将滚动增量传播到具有嵌套滚动支持的父级。

以下示例显示了应用了 verticalScroll 修饰符的元素,它们位于也应用了 verticalScroll 修饰符的容器内。

@Composable
private fun AutomaticNestedScroll() {
    val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
    Box(
        modifier = Modifier
            .background(Color.LightGray)
            .verticalScroll(rememberScrollState())
            .padding(32.dp)
    ) {
        Column {
            repeat(6) {
                Box(
                    modifier = Modifier
                        .height(128.dp)
                        .verticalScroll(rememberScrollState())
                ) {
                    Text(
                        "Scroll here",
                        modifier = Modifier
                            .border(12.dp, Color.DarkGray)
                            .background(brush = gradient)
                            .padding(24.dp)
                            .height(150.dp)
                    )
                }
            }
        }
    }
}

Two nested vertical scrolling UI elements, responding to gestures inside and
outside the inner
element

使用 nestedScroll 修饰符

如果你需要在多个元素之间创建高级协调滚动,nestedScroll 修饰符通过定义嵌套滚动层次结构为你提供了更大的灵活性。如上一节所述,某些组件具有内置的嵌套滚动支持。但是,对于那些不能自动滚动的可组合项,例如 BoxColumn,这些组件上的滚动增量不会在嵌套滚动系统中传播,增量也不会到达 NestedScrollConnection 或父组件。为了解决这个问题,你可以使用 nestedScroll 为其他组件(包括自定义组件)赋予这种支持。

嵌套滚动周期

嵌套滚动周期是滚动增量通过嵌套滚动系统中的所有组件(或节点)在层次结构树中上下分发的流程,例如通过使用可滚动组件和修饰符或 nestedScroll

嵌套滚动周期的阶段

当可滚动组件检测到触发事件(例如,手势)时,甚至在实际滚动操作触发之前,生成的增量将被发送到嵌套滚动系统,并经过三个阶段:预滚动、节点消耗和后滚动。

Phases of nested scrolling
cycle

在第一个预滚动阶段,接收触发事件增量的组件会将这些事件向上分发,通过层次结构树,到达最顶层的父级。然后,增量事件会向下冒泡,这意味着增量会从最根部的父级向下传播到开始嵌套滚动周期的子级。

Pre-scroll phase - dispatching
up

这使嵌套滚动父级(使用 nestedScroll 或可滚动修饰符的可组合项)有机会在节点本身消耗增量之前对其进行处理。

Pre-scroll phase - bubbling
down

在节点消耗阶段,节点本身将使用其父级未使用的所有增量。这是实际进行滚动移动并可见的时候。

Node consumption
phase

在此阶段,子级可以选择消耗所有剩余的滚动或部分剩余的滚动。任何剩余的将被发送回上层,以进入后滚动阶段。

最后,在后滚动阶段,节点本身未消耗的任何内容将再次向上发送给其祖先以供消耗。

Post-scroll phase - dispatching
up

后滚动阶段的工作方式类似于预滚动阶段,其中任何父级都可以选择消耗或不消耗。

Post-scroll phase - bubbling
down

与滚动类似,当拖动手势结束时,用户的意图可以转换为用于弹跳(使用动画滚动)可滚动容器的速度。弹跳也是嵌套滚动周期的一部分,并且拖动事件生成的速度会经过类似的阶段:预弹跳、节点消耗和后弹跳。请注意,弹跳动画仅与触摸手势相关联,不会由其他事件触发,例如 a11y 或硬件滚动。

参与嵌套滚动周期

参与周期意味着在层次结构中拦截、消耗和报告增量的消耗。Compose 提供了一组工具来影响嵌套滚动系统的工作方式,以及如何直接与之交互,例如当你需要在可滚动组件开始滚动之前对滚动增量进行处理时。

如果嵌套滚动周期是作用于节点链的系统,则 nestedScroll 修饰符是一种拦截和插入这些变化的方法,并影响在链中传播的数据(滚动增量)。此修饰符可以放置在层次结构中的任何位置,并与树向上方的嵌套滚动修饰符实例进行通信,以便它可以通过此通道共享信息。此修饰符的构建块是 NestedScrollConnectionNestedScrollDispatcher

NestedScrollConnection 提供了一种方法来响应嵌套滚动周期的阶段,并影响嵌套滚动系统。它由四个回调方法组成,每个回调方法代表一个消耗阶段:预/后滚动和预/后弹跳

val nestedScrollConnection = object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        println("Received onPreScroll callback.")
        return Offset.Zero
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        println("Received onPostScroll callback.")
        return Offset.Zero
    }
}

每个回调还提供有关正在传播的增量的信息:available 增量用于该特定阶段,以及在先前阶段消耗的 consumed 增量。如果你想在任何时候停止向上传播增量,可以使用嵌套滚动连接来做到这一点

val disabledNestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return if (source == NestedScrollSource.SideEffect) {
                available
            } else {
                Offset.Zero
            }
        }
    }
}

所有回调都提供有关 NestedScrollSource 类型的信息。

NestedScrollDispatcher 初始化嵌套滚动周期。使用调度程序并调用其方法会触发周期。可滚动容器具有内置的调度程序,它将手势期间捕获的增量发送到系统。出于这个原因,自定义嵌套滚动的大多数用例都涉及使用 NestedScrollConnection 而不是调度程序,以响应已经存在的增量,而不是发送新的增量。查看 NestedScrollDispatcherSample 以了解更多用法。

嵌套滚动互操作

当您尝试将可滚动的 View 元素嵌套在可滚动的 Composable 中,或反之亦然时,可能会遇到问题。最明显的问题是,当您滚动子元素并到达其起点或终点边界,并期望父元素接管滚动时,这种预期行为可能不会发生,或者可能无法按预期工作。

此问题是可滚动 Composable 中内置的期望导致的。可滚动 Composable 遵循“默认嵌套滚动”规则,这意味着任何可滚动容器都必须参与嵌套滚动链,既作为父元素通过 NestedScrollConnection,也作为子元素通过 NestedScrollDispatcher。当子元素处于边界时,子元素将为父元素驱动嵌套滚动。例如,此规则允许 Compose Pager 和 Compose LazyRow 很好地协同工作。但是,当与 ViewPager2RecyclerView 进行互操作滚动时,由于这些元素未实现 NestedScrollingParent3,因此无法实现从子元素到父元素的连续滚动。

为了在可滚动的 View 元素和可滚动的 Composable 之间启用双向嵌套滚动的互操作 API,您可以使用嵌套滚动互操作 API 来缓解以下场景中的这些问题。

协作父 View 包含子 ComposeView

协作父 View 是指已经实现了 NestedScrollingParent3 的元素,因此能够接收来自协作嵌套子 Composable 的滚动增量。在这种情况下,ComposeView 将充当子元素,并且需要(间接)实现 NestedScrollingChild3。协作父元素的一个示例是 androidx.coordinatorlayout.widget.CoordinatorLayout

如果需要在可滚动的 View 父容器和嵌套的可滚动子 Composable 之间进行嵌套滚动互操作,可以使用 rememberNestedScrollInteropConnection()

rememberNestedScrollInteropConnection() 允许并记住 NestedScrollConnection,该连接可实现实现了 NestedScrollingParent3View 父元素和 Compose 子元素之间的嵌套滚动互操作性。这应该与 nestedScroll 修饰符一起使用。由于 Compose 端默认启用了嵌套滚动,因此您可以使用此连接来启用 View 端的嵌套滚动,并在 View 和 Composable 之间添加必要的粘合逻辑。

一个常见的用例是使用 CoordinatorLayoutCollapsingToolbarLayout 和一个子 Composable,如本示例所示。

<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:fitsSystemWindows="true">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/collapsing_toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <!--...-->

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

在您的 Activity 或 Fragment 中,您需要设置您的子 Composable 和所需的 NestedScrollConnection

open class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalComposeUiApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                // Add the nested scroll connection to your top level @Composable element
                // using the nestedScroll modifier.
                LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) {
                    items(20) { item ->
                        Box(
                            modifier = Modifier
                                .padding(16.dp)
                                .height(56.dp)
                                .fillMaxWidth()
                                .background(Color.Gray),
                            contentAlignment = Alignment.Center
                        ) {
                            Text(item.toString())
                        }
                    }
                }
            }
        }
    }
}

包含子 AndroidView 的父 Composable

这种情况涵盖了 Compose 端嵌套滚动互操作 API 的实现 - 当您有一个包含子 AndroidView 的父 Composable 时。由于 AndroidView 充当 Compose 滚动父元素的子元素,因此它实现了 NestedScrollDispatcher,并且由于它充当 View 滚动子元素的父元素,因此它还实现了 NestedScrollingParent3。然后,Compose 父元素将能够接收来自嵌套的可滚动子 View 的嵌套滚动增量。

以下示例演示了如何在这种情况中实现嵌套滚动互操作,以及一个 Compose 可折叠工具栏。

@Composable
private fun NestedScrollInteropComposeParentWithAndroidChildExample() {
    val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() }
    val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }

    // Sets up the nested scroll connection between the Box composable parent
    // and the child AndroidView containing the RecyclerView
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // Updates the toolbar offset based on the scroll to enable
                // collapsible behaviour
                val delta = available.y
                val newOffset = toolbarOffsetHeightPx.value + delta
                toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
                return Offset.Zero
            }
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .nestedScroll(nestedScrollConnection)
    ) {
        TopAppBar(
            modifier = Modifier
                .height(ToolbarHeight)
                .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }
        )

        AndroidView(
            { context ->
                LayoutInflater.from(context)
                    .inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply {
                        with(findViewById<RecyclerView>(R.id.main_list)) {
                            layoutManager = LinearLayoutManager(context, VERTICAL, false)
                            adapter = NestedScrollInteropAdapter()
                        }
                    }.also {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(it, true)
                    }
            },
            // ...
        )
    }
}

private class NestedScrollInteropAdapter :
    Adapter<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() {
    val items = (1..10).map { it.toString() }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): NestedScrollInteropViewHolder {
        return NestedScrollInteropViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.list_item, parent, false)
        )
    }

    override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) {
        // ...
    }

    class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) {
        fun bind(item: String) {
            // ...
        }
    }
    // ...
}

此示例演示了如何在使用 scrollable 修饰符的情况下使用该 API。

@Composable
fun ViewInComposeNestedScrollInteropExample() {
    Box(
        Modifier
            .fillMaxSize()
            .scrollable(rememberScrollableState {
                // View component deltas should be reflected in Compose
                // components that participate in nested scrolling
                it
            }, Orientation.Vertical)
    ) {
        AndroidView(
            { context ->
                LayoutInflater.from(context)
                    .inflate(android.R.layout.list_item, null)
                    .apply {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(this, true)
                    }
            }
        )
    }
}

最后,此示例演示了如何将嵌套滚动互操作 API 与 BottomSheetDialogFragment 一起使用,以实现成功的拖动和关闭行为。

class BottomSheetFragment : BottomSheetDialogFragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false)

        rootView.findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                LazyColumn(
                    Modifier
                        .nestedScroll(nestedScrollInterop)
                        .fillMaxSize()
                ) {
                    item {
                        Text(text = "Bottom sheet title")
                    }
                    items(10) {
                        Text(
                            text = "List item number $it",
                            modifier = Modifier.fillMaxWidth()
                        )
                    }
                }
            }
            return rootView
        }
    }
}

请注意,rememberNestedScrollInteropConnection() 将在您附加它的元素中安装一个 NestedScrollConnectionNestedScrollConnection 负责将增量从 Compose 层面传输到 View 层面。这使元素能够参与嵌套滚动,但不会自动启用元素的滚动。对于不可自动滚动的 Composable,例如 BoxColumn,这些组件上的滚动增量不会在嵌套滚动系统中传播,并且增量不会到达由 rememberNestedScrollInteropConnection() 提供的 NestedScrollConnection,因此这些增量不会到达父 View 组件。要解决此问题,请确保您还将可滚动修饰符设置为这些类型的嵌套 Composable。您可以参考上一节关于 嵌套滚动 的内容,以获取更详细的信息。

非协作父 View 包含子 ComposeView

非协作 View 是指未在 View 端实现必要的 NestedScrolling 接口的 View。请注意,这意味着与这些 View 的嵌套滚动互操作性无法开箱即用。非协作 ViewRecyclerViewViewPager2