滚动

滚动修饰符

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

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))
        }
    }
}

可滚动修饰符

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 元素,反之亦然时,可能会遇到问题。最明显的问题是当您滚动子元素并到达其起始或结束边界时,您期望父元素接管滚动。但是,这种预期的行为可能不会发生,或者可能无法按预期工作。

此问题是可滚动组合项中内置预期结果。可滚动组合项具有“默认嵌套滚动”规则,这意味着任何可滚动容器都必须参与嵌套滚动链,作为父元素通过 NestedScrollConnection,以及作为子元素通过 NestedScrollDispatcher。然后,当子元素位于边界时,子元素将为父元素驱动嵌套滚动。例如,此规则允许 Compose Pager 和 Compose LazyRow 良好地协同工作。但是,当与 ViewPager2RecyclerView 进行互操作性滚动时,由于这些元素未实现 NestedScrollingParent3,因此无法实现从子元素到父元素的连续滚动。

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

包含子 ComposeView 的协作父 View

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

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

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

一个常见的用例是使用 CoordinatorLayoutCollapsingToolbarLayout 和子组合项,本例中显示了这一点

<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 中,您需要设置子组合项和所需的 NestedScrollConnection

open class MainActivity : ComponentActivity() {
    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 的父组合项

此场景涵盖了在 Compose 端实现嵌套滚动互操作性 API 的情况——当您有一个包含子 AndroidView 的父组合项时。AndroidView 实现 NestedScrollDispatcher,因为它充当 Compose 滚动父元素的子元素,也实现 NestedScrollingParent3,因为它充当 View 滚动子元素的父元素。然后,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) {
            // ...
        }
    }
    // ...
}

此示例显示了如何将 API 与 scrollable 修饰符一起使用

@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 层。这使元素能够参与嵌套滚动,但它不会自动启用元素的滚动。对于不能自动滚动的组合项(例如 BoxColumn),此类组件上的滚动增量不会在嵌套滚动系统中传播,并且增量将无法到达 rememberNestedScrollInteropConnection() 提供的 NestedScrollConnection,因此这些增量将无法到达父 View 组件。要解决此问题,请确保您还将可滚动修饰符设置为这些类型的嵌套组合项。您可以参考前面关于 嵌套滚动 的部分以获取更多详细信息。

包含子 ComposeView 的非协作父 View

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