高级事件处理 API
Compose 中的手势操作或事件处理全部都是以 Modifier 修饰符的形式提供的,事件处理按照层次可以划分为高级事件处理API和低级事件处理API。
其中高级事件处理API是位于更上层的API,它们都是基于更底层的低级事件处理API实现的,也是开发中比较常用的。高级事件处理API的分类大概如下图所示:
点击事件
监听点击事件非常简单,使用 clickable
和 combinedClickable
修饰符即可满足需求:
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableExample() {
Column{
Box(Modifier
.clickable {
println("clickable") }
.size(30.dp)
.background(Color.Red))
Box(Modifier
.size(50.dp)
.background(Color.Blue)
.combinedClickable(
onLongClick = {
println("combinedClickable --> onLongClick") },
onDoubleClick = {
println("combinedClickable --> onDoubleClick") },
onClick = {
println("combinedClickable --> onClick") }
))
}
}
当点击事件发生时会为被点击的组件施加一个水波纹效果动画的蒙层,这是Material Design中的默认效果,如果不希望点击时有这个效果,可以使用低级别的Api detectTapGestures
。
另外, clickable
和 combinedClickable
可以传入一个 enable
参数作为一个可变状态,可以通过该状态来动态控制是否启用点击监听。
Draggable拖动
Draggable可以监听拖动手势偏移量,然后可以根据偏移量定制UI拖动交换效果。但是值得注意的是,Draggable修饰符只支持监听水平方向或垂直方向的偏移,如希望监听任意方向,则可以使用detectDragGestures
方法。
使用Draggable至少需要传入2个参数 draggableState
和 orientation
:
draggableState
: 通过它可以获取到拖动手势的偏移量,并且也允许我们动态控制发生偏移行为orientation
:监听拖动的方向,只能是水平或垂直
如下代码实现一个简单的滑块拖动效果
@Composable
fun DraggableExample() {
var offsetX by remember {
mutableStateOf(0f) }
val boxSlideSize = 50.dp
val maxLengthPx = with(LocalContext.current) {
resources.displayMetrics.widthPixels - boxSlideSize.toPx()
}
// 创建并获取一个DraggableState实例
val draggableState = rememberDraggableState {
// 使用回调方法回传的参数对状态偏移量进行累加,并限制范围
offsetX = (offsetX + it).coerceIn(0f, maxLengthPx)
}
Box(
Modifier
.fillMaxWidth()
.height(boxSlideSize)
.background(Color.LightGray)
) {
Box(
Modifier
.size(boxSlideSize)
.offset {
IntOffset(offsetX.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = draggableState
)
.background(Color.Red)
)
}
}
由于Modifier是链式执行的,因此这里
offset
修饰符应该放在draggable
和background
之前。
运行效果:
错误示例1(draggable在offset前面):第二次拖动时UI控件拖动只能拖动初始位置才生效,不会跟随UI控件而移动监听,原因是每次拖动时draggable都监听的都是初始位置,不是偏移后位置。
错误示例2(background在offset前面):UI控件不会跟手,原因在于每次绘制时background都在初始位置绘制,不是偏移后位置。
另外,draggable
还有几个参数:
enabled
:是否启用拖拽,方便动态控制,默认值是true。interactionSource
:可以用来收集拖拽的状态,默认值是null。startDragImmediately
:是否立即开始拖动,默认值是false,如果设为true可防止其他手势检测器对“down”事件做出反应。这是为了允许最终用户通过按下操作来“捕获”动画组件。当你拖动的值正在设定/动画时,设置它很有用。onDragStarted
:一个挂起函数,开始拖动时回调。onDragStopped
:一个挂起函数,停止拖动时回调。reverseDirection
:是否反方向执行拖拽效果,默认值是false。
其中interactionSource
可以这样使用:
val interactionSource = remember{
MutableInteractionSource() }
Box(
Modifier
.draggable(
orientation = Orientation.Horizontal,
state = draggableState,
interactionSource = interactionSource,
).background(Color.Red)
)
val isDragged by interactionSource.collectIsDraggedAsState()
Text(if(isDragged) "正在拖动" else "静止")
Swipeable滑动
使用方式跟Draggable
差不多,但是Swipeable
可以通过锚点设置吸附效果。
使用Swipeable至少需要传入4个参数:
State
: 手势状态,通过它可以实时获取当前手势的偏移信息Anchors
: 锚点,用于记录不同状态对应数值的映射关系Orientation
: 手势方向,只支持水平或垂直thresholds
: 不同锚点之间吸附效果的临界阈值,常用的阈值有FixedThreshold(Dp)
和FractionalThreshold(Float)
两种
以下代码使用Swipeable
创建一个简单的开关效果:
enum class Status{
CLOSE, OPEN } // 定义两个枚举项表示开关状态
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeableDemo() {
val blockSize = 48.dp
val blockSizePx = blockSize.toPx()
// 创建并获取一个SwipeableState实例
val swipeableState = rememberSwipeableState(initialValue = Status.CLOSE)
// 定义锚点,锚点以Pair表示,每个状态对应一个锚点
val anchors = mapOf(
0f to Status.CLOSE,
blockSizePx*2 to Status.OPEN
)
Box(
Modifier
.size(height = blockSize, width = blockSize * 3)
.clip(RoundedCornerShape(50))
.background(Color.Gray)
) {
Box(
Modifier
.offset {
IntOffset(swipeableState.offset.value.toInt(), 0) }
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = {
from, to ->
// 从关闭到开启状态时,滑块移动超过30%距离自动吸附到开启状态
if (from == Status.CLOSE) {
FractionalThreshold(0.3f)
} else {
// 从开启状态到关闭状态时,滑块移动超过50%才会自动吸附到关闭状态
FractionalThreshold(0.5f)
}
},
orientation = Orientation.Horizontal
)
.size(blockSize)
.clip(RoundedCornerShape(50))
.background(Color.Red)
)
}
}
由于Modifier是链式执行的,因此这里
swipeable
修饰符应该放在draggable
和background
之前。
运行效果:
注意:
Modifier.swipeable
这个修饰符在compose.material
中可以正常使用,而在compose.material3
库中被隐藏了。但是Cmpose提供了一个SwipeToDismiss
这个Composable组件来专门做滑动删除的效果。
transformable多点触控
transformable
修饰符可以监听双指拖动、缩放或旋转手势
@Composable
fun TransformableExample() {
val boxSize = 200.dp
var offset by remember {
mutableStateOf(Offset.Zero) }
var rotationAngle by remember {
mutableStateOf(0f) }
var scale by remember {
mutableStateOf(1f) }
// 创建并获取一个TransformableState实例
val transformableState = rememberTransformableState {
zoomChange: Float, panChange: Offset, rotationChange: Float ->
scale *= zoomChange
offset += panChange
rotationAngle += rotationChange
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Image(
painter = painterResource(id = R.drawable.ic_sky),
contentScale = ContentScale.Crop,
contentDescription = null,
modifier = Modifier
.size(boxSize)
.rotate(rotationAngle) // 注意rotate的顺序应该先于offset
.offset {
IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.scale(scale)
.transformable(
state = transformableState,
// 该值为true时,发生双指拖动或缩放时,不会同时监听旋转手势信息
lockRotationOnZoomPan = true
)
)
}
}
这里注意rotate的顺序应该先于offset,如果先调用了offset再调用rotate, 则组件会先偏移再旋转,这会导致组件最终位置不可预期。
运行效果:
Scrollable滚动
主要用于列表场景,结合LazyColumn
和LazyRow
来使用
horizontalScroll 与 verticalScroll
horizontalScroll
主要结合Row
组件来使用,使其支持水平滚动,horizontalScroll
只需要传入一个 scrollState
即可。我们可以使用 rememberScrollState
快速创建一个 scrollState
实例并传入即可。
@Composable
fun HorizontalScrollExample() {
val scrollState = rememberScrollState()
Row(
Modifier
.padding(10.dp)
.border(BorderStroke(1.dp, Color.Blue))
.height(50.dp)
.horizontalScroll(scrollState)
) {
repeat(50) {
Text("item $it", Modifier.padding(10.dp))
Divider(Modifier.width(1.dp).fillMaxHeight())
}
}
}
verticalScroll
与horizontalScroll
使用类似,主要结合Column
组件使用。
@Composable
fun VerticalScrollExample() {
val scrollState = rememberScrollState()
Column(
Modifier
.height(300.dp)
.verticalScroll(scrollState)
) {
repeat(50) {
Text("item $it", Modifier.padding(10.dp))
Divider()
}
}
}
verticalScroll
与 horizontalScroll
这两个修饰符除了在Row
和Column
组件上使用外,在其他组件上也可以应用,如 Box
组件等。
低级别 scrollable 修饰符
horizontalScroll
和verticalScroll
都是基于scrollable
实现的, scrollable
修饰符除了传入一个scrollState
外,还需要传入Orientation
(水平或垂直)
以下代码通过 scrollable
修饰符的滚动监听能力,自己来定制实现类似 horizontalScroll
修饰符的功能:
@Composable
fun ScrollableExample1() {
Column(Modifier.padding(10.dp)) {
val scrollState = rememberScrollState()
Row(
Modifier
.border(BorderStroke(1.dp, Color.Blue))
.height(50.dp)
.offset(x = -scrollState.value.toDp()) // 滚动位置增大时应该向左偏移,所以这里设为负数
.scrollable(scrollState, Orientation.Horizontal, reverseDirection = true)
) {
repeat(50) {
Text("item $it", Modifier.padding(10.dp))
Divider(Modifier.width(1.dp).fillMaxHeight())
}
}
Text(text = "scrollState.value: ${
scrollState.value}")
}
}
注意: scrollable
的滚动位置范围为0~MAX_VALUE
, 默认当手指在组件上向右滑动时,滚动位置会增大,向左滑动时,滚动位置会减小,直到减小到0
。 由于滚动位置默认初始值为0
,所以默认我们只能向右滑来增大滚动位置。如果将reverseDirection
参数设置为true
时,那么此时手指向左滑滚动位置会增大,向右滑滚动位置会减小。
因此这里将reverseDirection
设为true
允许我们从初始位置向左滑以查看Row
组件右侧超出屏幕的内容部分。
补充提示: 在使用 rememberScrollState
创建 ScrollState
实例时我们是可以通过 initial
参数来指定组件初始滚动位置的
class ScrollState(initial: Int) : ScrollableState {
var value: Int by mutableStateOf(initial, structuralEqualityPolicy())
private set
suspend fun animateScrollTo(...)
suspend fun scrollTo(...)
...
}
上面的代码运行后我们会发现,当进行左滑时,原本位于屏幕外的内容进入屏幕时右边出现一片空白,这是因为Row
组件的默认测量策略导致超出屏幕的子组件宽度测量结果为零。
此时需要使用layout
修饰符来自定义布局,我们需要创建一个新的约束,用于测量组件的真实宽度,主动设置组件应有的宽高尺寸,并根据组件的滚动偏移量来摆放组件内容。
@Composable
fun ScrollableExample2() {
Column(Modifier.padding(10.dp)) {
val scrollState = rememberScrollState()
Row(
Modifier
.border(BorderStroke(1.dp, Color.Blue))
.height(50.dp)
.clipScrollableContainer(Orientation.Horizontal) // 留出父组件设置的padding空间
.scrollable(scrollState, Orientation.Horizontal, reverseDirection = true)
.layout {
measurable, constraints ->
println("constraints: $constraints")
// 约束中默认最大宽度为父组件所允许的最大宽度,此处为屏幕宽度
// 将最大宽度设置为无限大
val childConstraints = constraints.copy(
maxWidth = Constraints.Infinity
)
println("childConstraints: $childConstraints")
val placeable = measurab