滚动修饰符
verticalScroll
和 horizontalScroll
修饰符提供了最简单的方法,允许用户在元素内容的边界大于其最大尺寸约束时滚动元素。使用verticalScroll
和horizontalScroll
修饰符,您无需转换或偏移内容。
@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)) } } }
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()) } }
嵌套滚动
嵌套滚动是一个系统,其中彼此包含的多个滚动组件通过对单个滚动手势做出反应并传达其滚动增量(更改)来协同工作。
嵌套滚动系统允许在可滚动且按层次结构链接(通常通过共享相同的父级)的组件之间进行协调。此系统链接滚动容器,并允许与在它们之间传播和共享的滚动增量进行交互。
Compose 提供了多种处理可组合项之间嵌套滚动的方法。嵌套滚动的典型示例是在另一个列表内的列表,更复杂的情况是折叠工具栏。
自动嵌套滚动
简单的嵌套滚动不需要您采取任何操作。启动滚动操作的手势会自动从子级传播到父级,这样当子级无法再滚动时,手势将由其父元素处理。
Compose 的一些组件和修饰符开箱即用地支持自动嵌套滚动:verticalScroll
,horizontalScroll
,scrollable
,Lazy
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) ) } } } } }
使用nestedScroll
修饰符
如果您需要在多个元素之间创建高级协调滚动,nestedScroll
修饰符通过定义嵌套滚动层次结构为您提供了更大的灵活性。如上一节所述,某些组件具有内置的嵌套滚动支持。但是,对于不可自动滚动的可组合项(例如Box
或Column
),此类组件上的滚动增量不会在嵌套滚动系统中传播,并且增量不会到达NestedScrollConnection
或父组件。要解决此问题,您可以使用nestedScroll
向其他组件(包括自定义组件)赋予此类支持。
嵌套滚动周期
嵌套滚动周期是滚动增量通过作为嵌套滚动系统一部分的所有组件(或节点)的层次结构树向上和向下分发的流程,例如通过使用可滚动组件和修饰符或nestedScroll
。
嵌套滚动周期的阶段
当可滚动组件检测到触发事件(例如手势)时,甚至在实际滚动操作触发之前,生成的增量就会发送到嵌套滚动系统,并经过三个阶段:预滚动、节点消耗和后滚动。
在第一个预滚动阶段,接收触发事件增量的组件会将这些事件向上分发,通过层次结构树,到达最顶层的父级。然后,增量事件将向下冒泡,这意味着增量将从最顶层的父级向下传播到启动嵌套滚动周期的子级。
这使嵌套滚动的父级(使用nestedScroll
或可滚动修饰符的可组合项)有机会在节点本身消耗增量之前对增量执行某些操作。
在节点消耗阶段,节点本身将使用其父级未使用的任何增量。此时,滚动移动实际上已经完成并且可见。
在此阶段,子级可以选择消耗所有剩余滚动或部分剩余滚动。剩下的任何内容都将被发送回上层,以经过后滚动阶段。
最后,在后滚动阶段,节点本身未消耗的任何内容都将再次发送到其祖先以供消耗。
后滚动阶段的工作方式与预滚动阶段类似,其中任何父级都可以选择消耗或不消耗。
与滚动类似,当拖动手势结束时,用户的意图可能会转换为用于抛掷(使用动画滚动)可滚动容器的速度。抛掷也是嵌套滚动周期的一部分,拖动事件生成的速度会经历类似的阶段:预抛掷、节点消耗和后抛掷。请注意,抛掷动画仅与触摸手势相关联,不会由其他事件(例如 a11y 或硬件滚动)触发。
参与嵌套滚动周期
参与周期意味着拦截、消耗和报告沿层次结构的增量消耗。Compose 提供了一组工具来影响嵌套滚动系统的工作方式以及如何直接与之交互,例如,当您需要在可滚动组件甚至开始滚动之前对滚动增量执行某些操作时。
如果嵌套滚动周期是一个作用于节点链的系统,则nestedScroll
修饰符是一种拦截和插入这些更改以及影响在链中传播的数据(滚动增量)的方法。此修饰符可以放置在层次结构中的任何位置,并且它与树上的嵌套滚动修饰符实例进行通信,以便它可以通过此通道共享信息。此修饰符的构建块是NestedScrollConnection
和NestedScrollDispatcher
。
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
良好地协同工作。但是,当与 ViewPager2
或 RecyclerView
进行互操作性滚动时,由于这些元素未实现 NestedScrollingParent3
,因此无法实现从子元素到父元素的连续滚动。
为了启用可滚动 View
元素和可滚动组合项(双向嵌套)之间的嵌套滚动互操作性 API,您可以使用嵌套滚动互操作性 API 来缓解以下场景中的这些问题。
包含子 ComposeView
的协作父 View
协作父 View
是指已实现 NestedScrollingParent3
并因此能够接收来自协作嵌套子组合项的滚动增量的元素。在这种情况下,ComposeView
将充当子元素,并且需要(间接)实现 NestedScrollingChild3
。协作父元素的一个示例是 androidx.coordinatorlayout.widget.CoordinatorLayout
。
如果您需要在可滚动 View
父容器和嵌套的可滚动子组合项之间进行嵌套滚动互操作性,可以使用 rememberNestedScrollInteropConnection()
。
rememberNestedScrollInteropConnection()
允许并记住 NestedScrollConnection
,该连接启用在实现 NestedScrollingParent3
的 View
父元素和 Compose 子元素之间的嵌套滚动互操作性。这应与 nestedScroll
修饰符结合使用。由于嵌套滚动在 Compose 端默认启用,因此您可以使用此连接来启用 View
端的嵌套滚动,并在 Views
和组合项之间添加必要的粘合逻辑。
一个常见的用例是使用 CoordinatorLayout
、CollapsingToolbarLayout
和子组合项,本例中显示了这一点
<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()
将在您附加它的元素中安装 NestedScrollConnection
。NestedScrollConnection
负责将增量从 Compose 层传输到 View
层。这使元素能够参与嵌套滚动,但它不会自动启用元素的滚动。对于不能自动滚动的组合项(例如 Box
或 Column
),此类组件上的滚动增量不会在嵌套滚动系统中传播,并且增量将无法到达 rememberNestedScrollInteropConnection()
提供的 NestedScrollConnection
,因此这些增量将无法到达父 View
组件。要解决此问题,请确保您还将可滚动修饰符设置为这些类型的嵌套组合项。您可以参考前面关于 嵌套滚动 的部分以获取更多详细信息。
包含子 ComposeView
的非协作父 View
非协作 View 是指在 View
端未实现必要的 NestedScrolling
接口的 View。请注意,这意味着与这些 Views
的嵌套滚动互操作性无法开箱即用。非协作 Views
是 RecyclerView
和 ViewPager2
。
推荐内容
- 注意:禁用 JavaScript 时显示链接文本
- 了解手势
- 将
CoordinatorLayout
迁移到 Compose - 在 Compose 中使用 Views