Compose 提供了多种 API,可帮助您检测用户互动生成的手势。API 涵盖各种用例:
其中一些级别较高,旨在覆盖最常用的手势。例如,
clickable
修饰符可用于轻松检测点击,此外它还提供无障碍功能,并在点按时显示视觉指示(例如涟漪)。还有一些不太常用的手势检测器,它们在较低级别提供更大的灵活性,例如
PointerInputScope.detectTapGestures
或PointerInputScope.detectDragGestures
,但不提供额外功能。
点按并按下
clickable
修饰符允许应用检测对已应用该修饰符的元素的点击。
@Composable
fun ClickableSample() {
val count = remember { mutableStateOf(0) }
// content that you want to make clickable
Text(
text = count.value.toString(),
modifier = Modifier.clickable { count.value += 1 }
)
}
当需要更大灵活性时,您可以通过 pointerInput
修饰符提供点按手势检测器:
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = { /* Called when the gesture starts */ },
onDoubleTap = { /* Called on Double Tap */ },
onLongPress = { /* Called on Long Press */ },
onTap = { /* Called on Tap */ }
)
}
滚动
滚动修饰符
verticalScroll
和 horizontalScroll
修饰符提供一种最简单的方法,可让用户在元素内容边界大于最大尺寸约束时滚动元素。利用 verticalScroll
和 horizontalScroll
修饰符,您无需转换或偏移内容。
@Composable
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
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
修饰符。
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
向其他组件(包括自定义组件)提供此类支持。
嵌套滚动互操作性(从 Compose 1.2.0 开始)
当您尝试在可滚动的可组合项中嵌套可滚动的 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 的父级可组合项
此场景涵盖了当您的父级可组合项包含子级 AndroidView
时,对 Compose 端嵌套滚动互操作 API 的实现。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
接口。请注意,这意味着这类 Views
不能直接支持嵌套滚动互操作性。非协作式 Views
是 RecyclerView
和 ViewPager2
。
拖动
draggable
修饰符是向单一方向拖动手势的高级入口点,并且会报告拖动距离(以像素为单位)。
请务必注意,此修饰符与 scrollable
类似,仅检测手势。您需要保存状态并在屏幕上表示,例如通过 offset
修饰符移动元素:
var offsetX by remember { mutableStateOf(0f) }
Text(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
offsetX += delta
}
),
text = "Drag me!"
)
如果您需要控制整个拖动手势,请考虑改为通过 pointerInput
修饰符使用拖动手势检测器。
Box(modifier = Modifier.fillMaxSize()) {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Blue)
.size(50.dp)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
滑动
您可以使用 swipeable
修饰符拖动元素,释放后,这些元素通常朝一个方向定义的两个或多个锚点呈现动画效果。其常见用途是实现“滑动关闭”模式。
请务必注意,此修饰符不会移动元素,而只检测手势。您需要保存状态并在屏幕上表示,例如通过 offset
修饰符移动元素。
在 swipeable
修饰符中必须提供可滑动状态,且该状态可以通过 rememberSwipeableState()
创建和记住。此状态还提供了一组有用的方法,用于以程序化方式为锚点添加动画效果(请参阅 snapTo
、animateTo
、performFling
和 performDrag
),同时为属性添加动画效果,以观察拖动进度。
可以将滑动手势配置为具有不同的阈值类型,例如 FixedThreshold(Dp)
和 FractionalThreshold(Float)
,并且对于每个锚点的起始与终止组合,它们可以是不同的。
为了获得更大的灵活性,您可以配置滑动越过边界时的 resistance
,还可以配置 velocityThreshold
,即使尚未达到位置 thresholds
,velocityThreshold 仍将以动画方式向下一个状态滑动。
@Composable
fun SwipeableSample() {
val width = 96.dp
val squareSize = 48.dp
val swipeableState = rememberSwipeableState(0)
val sizePx = with(LocalDensity.current) { squareSize.toPx() }
val anchors = mapOf(0f to 0, sizePx to 1) // Maps anchor points (in px) to states
Box(
modifier = Modifier
.width(width)
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
.background(Color.LightGray)
) {
Box(
Modifier
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
.size(squareSize)
.background(Color.DarkGray)
)
}
}
多点触控:平移、缩放、旋转
如需检测用于平移、缩放和旋转的多点触控手势,您可以使用 transformable
修饰符。此修饰符本身不会转换元素,只会检测手势。
@Composable
fun TransformableSample() {
// set up all transformation states
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
Box(
Modifier
// apply other transformations like rotation and zoom
// on the pizza slice emoji
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
// add transformable to listen to multitouch transformation events
// after offset
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}
如果您需要将缩放、平移和旋转与其他手势结合使用,可以使用 PointerInputScope.detectTransformGestures
检测器。