点击事件
监听点击事件非常简单,使用 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都在初始位置绘制,不是偏移后位置。
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之前。
运行效果:
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水平滚动
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()
}
}
}
低级别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 = measurable.measure(childConstraints) // 使用新的约束进行组件测量
// 计算 当前组件宽度 与 父组件所允许的最大宽度 中取一个最小值
// 如果组件超出屏幕,此时width为屏幕宽度。如果没有超出,则为组件本身宽度
val width = placeable.width.coerceAtMost(constraints.maxWidth)
// 计算 当前组件高度 与 父组件所允许的最大高度 中取一个最小值
val height = placeable.height.coerceAtMost(constraints.maxHeight)
val scrollDistance = placeable.width - width // 计算可滚动的最大距离
layout(width, height) { // 主动设置组件的宽高
// 根据可滚动的最大距离来计算当前的滚动位置
val scroll = scrollState.value.coerceIn(0, scrollDistance)
val offsetX = -scroll // 根据滚动位置向左偏移
placeable.placeRelativeWithLayer(offsetX, 0) // 摆放组件内容
}
}
) {
repeat(50) {
Text("item $it", Modifier.padding(10.dp))
Divider(Modifier.width(1.dp).fillMaxHeight())
}
}
Text(text = "scrollState.value: ${scrollState.value}")
}
}
运行效果:
nestedScroll 嵌套滑动
nestedScroll 修饰符对标Android传统原生View体系中的NestedScrollView组件,主要用于处理嵌套滑动的场景,为父布局劫持消费子布局滑动手势提供了可能。
使用 nestedScroll 参数列表中有一个必选参数 connection 和一个可选参数 dispatcher
- connection:嵌套滑动手势处理的核心逻辑,内部回调可以在子布局获得滑动事件前预先消费掉部分或全部手势偏移量,也可以获取子布局消费后剩下的手势偏移量。
- dispatcher:调度器,内部包含用于父布局的 NestedScrollConnection , 可以调用 dispatch* 系列方法来通知父布局发生滑动
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null
)
NestedScrollConnection提供了四个回调方法。
方法 | 说明 | 参数 | 返回值 |
---|---|---|---|
onPreScroll | 可以预先劫持滑动事件,消费后再交由子布局 | available:当前可用的滑动事件偏移量 source:滑动事件的类型 | 当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero |
onPostScroll | 可以获取子布局处理后剩下的滑动事件 | consumed:之前被消费的所有滑动事件偏移量 available:当前剩下还可用的滑动事件偏移量 source:滑动事件的类型 | 当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理 |
onPreFling | 获取 Fling 动作开始时的速度 | available:Fling 开始时的速度 | 当前组件消费的速度,如果不想消费可返回 Velocity.Zero |
onPostFling | 获取 Fling 动作结束时的速度 | consumed:之前消费的所有速度 available:当前剩下还可用的速度 | 当前组件消费的速度,如果不想消费可返回Velocity.Zero,剩下速度会继续交由当前布局的父布局进行处理 |
Fling含义:当我们手指在滑动列表时,如果是快速滑动并抬起,则列表会根据惯性继续飘一段距离后停下,这个行为就是 Fling 惯性滑动,onPreFling 在你手指刚抬起时便会回调,而 onPostFling 会在飘一段距离停下后回调。
使用nestedScroll实现下拉刷新
效果:
在这个示例中存在着加载动画和列表数据。当我们手指向下滑时,此时如果列表顶部没有数据则会逐渐出现加载动画。与之相反,当我们手指向上滑时,此时如果加载动画还在,则加载动画逐渐向上消失,直到加载动画完全消失后,列表才会被向上滑动。
为实现这个滑动刷新的需求,我们可以设计如下方案。我们首先需要将加载动画和列表数据放到一个父布局中统一管理。
- 当我们手指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。父布局可以消费列表消费剩下的滑动手势事件(增大加载指示器的偏移量)。
- 当我们手指向上滑时,我们希望滑动手势首先被父布局消费(为加载动画减小偏移),如果加载动画本身仍未出现时,则不进行消费。然后将剩下的滑动手势交给子布局列表进行消费。
使用 nestedScroll 修饰符最重要的就是根据自己的业务场景来定制 NestedScrollConnection 的实现,接下来我们就逐个分析 NestedScrollConnection 中的每个接口该如何进行实现。
实现 onPostScroll
当我们手指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。 onPostScroll
回调时机正好符合当前的需求。
首先需要判断该滑动事件是不是拖动事件,通过 available.y > 0
判断是否是下滑手势,如果都没问题时,通知加载动画增加偏移量。返回值 Offset(x = 0f, y = available.y)
意味着将剩下的所有偏移量全部消费调,不再向外层父布局继续传播了。
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (source == NestedScrollSource.Drag && available.y > 0) {
state.updateOffsetDelta(available.y)
return Offset(x = 0f, y = available.y)
} else {
return Offset.Zero
}
}
实现 onPreScroll
与上面相反,此时我们希望上滑收回加载动画,当我们手指向上滑时,我们希望滑动手势首先被父布局消费(减小加载指示器的偏移量),如果加载指示器还未出现,则不需要进行消费。剩余的滑动手势事件会交给子布局列表继续进行消费。onPreScroll
回调时机正好符合这个需求。
首先仍需要判断该滑动事件是不是拖动事件,通过 available.y < 0
判断是否是上滑手势。此时可能加载指示器还未出现,所以需要额外进行判断。如果未出现,则返回 Offset.Zero
不消费,如果出现了则返回 Offset(x = 0f, y = available.y)
进行消费。
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.Drag && available.y < 0) {
state.updateOffsetDelta(available.y)
return if (state.isSwipeInProgress) Offset(x = 0f, y = available.y) else Offset.Zero
} else {
return Offset.Zero
}
}
实现 onPreFling
接下来,我们需要一个松手时的吸附效果。如果加载指示器已经被拖动并超过一半,则应该吸附到加载状态,否则就收缩回初始状态。onPreFling 会在松手时发生惯性滑动前回调,符合我们当前这个的场景需求。
注意:即使你松手时速度很慢或静止,onPreFling 与 onPostFling都会回调,只是速度数值很小。
这里我们只需要吸引效果,并不希望消费速度,所以返回 Velocity.Zero 即可
override suspend fun onPreFling(available: Velocity): Velocity {
if (state.indicatorOffset > height / 2) {
state.animateToOffset(height)
state.isRefreshing = true
} else {
state.animateToOffset(0.dp)
}
return Velocity.Zero
}
完整源码:
import android.util.Log
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class SmartSwipeRefreshState {
private val mutatorMutex = MutatorMutex()
private val indicatorOffsetAnimatable = Animatable(0.dp, Dp.VectorConverter)
val indicatorOffset get() = indicatorOffsetAnimatable.value
private val _indicatorOffsetFlow = MutableStateFlow(0f)
val indicatorOffsetFlow: Flow<Float> get() = _indicatorOffsetFlow
val isSwipeInProgress by derivedStateOf { indicatorOffset != 0.dp }
var isRefreshing: Boolean by mutableStateOf(false)
fun updateOffsetDelta(value: Float) {
_indicatorOffsetFlow.value = value
}
suspend fun snapToOffset(value: Dp) {
mutatorMutex.mutate(MutatePriority.UserInput) {
indicatorOffsetAnimatable.snapTo(value)
}
}
suspend fun animateToOffset(value: Dp) {
mutatorMutex.mutate {
indicatorOffsetAnimatable.animateTo(value, tween(1000))
}
}
}
private class SwipeRefreshNestedScrollConnection(
val state: SmartSwipeRefreshState,
val height: Dp
): NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
Log.d("NestedScrollConnection", "onPreScroll")
// 上滑时,我们希望滑动手势首先被父布局消费(减小加载指示器的偏移量),
// 如果加载指示器还未出现,则不需要进行消费。剩余的滑动手势事件会交给子布局列表继续进行消费。
return if (source == NestedScrollSource.Drag && available.y < 0) {
state.updateOffsetDelta(available.y)
if (state.isSwipeInProgress) Offset(x = 0f, y = available.y) else Offset.Zero
} else {
Offset.Zero
}
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
Log.d("NestedScrollConnection", "onPostScroll")
// 指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,
// 如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。
return if (source == NestedScrollSource.Drag && available.y > 0) {
state.updateOffsetDelta(available.y)
Offset(x = 0f, y = available.y)
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
Log.d("NestedScrollConnection", "onPreFling")
// 如果加载指示器已经被拖动并超过一半,则应该吸附到加载状态,否则就收缩回初始状态。
if (state.indicatorOffset > height / 2) {
state.animateToOffset(height)
state.isRefreshing = true
} else {
state.animateToOffset(0.dp)
state.isRefreshing = false
}
return super.onPreFling(available)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
Log.d("NestedScrollConnection", "onPostFling")
if (available.y <= 0 && state.indicatorOffset <= 0.dp && state.isRefreshing) {
state.isRefreshing = false
}
return super.onPostFling(consumed, available)
}
}
@Composable
private fun SubcomposeSmartSwipeRefresh(
indicator: @Composable () -> Unit,
content: @Composable (Dp) -> Unit
) {
SubcomposeLayout { constraints: Constraints ->
val indicatorPlaceable = subcompose("indicator", indicator).first().measure(constraints)
val contentPlaceable = subcompose("content") {
content(indicatorPlaceable.height.toDp())
}.map {
it.measure(constraints)
}.first()
Log.d("SmartSwipeRefresh","dp: ${indicatorPlaceable.height.toDp()}")
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.placeRelative(0, 0)
}
}
}
/**
* A smart refresh component can customize your slide refresh animation component.
* @param onRefresh: Refreshing behavior of data when sliding down.
* @param state: The state contains some refresh state info.
* @param loadingIndicator: Specify the refresh animation component.
* @param content: Some slidable components need to be included here.
*/
@Composable
fun SmartSwipeRefresh(
onRefresh: suspend () -> Unit,
state: SmartSwipeRefreshState = remember { SmartSwipeRefreshState() },
loadingIndicator: @Composable () -> Unit = { CircularProgressIndicator() },
content: @Composable () -> Unit
) {
SubcomposeSmartSwipeRefresh(indicator = loadingIndicator) { height ->
val connection = remember(state, height) {
SwipeRefreshNestedScrollConnection(state, height)
}
Box(
Modifier.nestedScroll(connection),
contentAlignment = Alignment.TopCenter
) {
Box(Modifier.offset(y = -height + state.indicatorOffset)) {
loadingIndicator()
}
Box(Modifier.offset(y = state.indicatorOffset)) {
content()
}
}
val density = LocalDensity.current
LaunchedEffect(Unit) {
state.indicatorOffsetFlow.collect {
val currentOffset = with(density) { state.indicatorOffset + it.toDp() }
state.snapToOffset(currentOffset.coerceAtLeast(0.dp).coerceAtMost(height))
}
}
LaunchedEffect(state.isRefreshing) {
if (state.isRefreshing) {
onRefresh()
state.animateToOffset(0.dp)
state.isRefreshing = false
}
}
}
}
使用:
@Composable
fun NestedScrollExample() {
var list by remember { mutableStateOf(('A'..'Z').toList()) }
val lazyListState = rememberLazyListState()
SmartSwipeRefresh(
onRefresh = {
delay(1000)
list = list.shuffled()
lazyListState.scrollToItem(0)
}
) {
LazyColumn(
contentPadding = PaddingValues(15.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
state = lazyListState
) {
items(list, key = { it }) { item ->
Card(
elevation = 8.dp,
modifier = Modifier.fillMaxWidth().height(100.dp),
) {
Box(
Modifier.fillMaxSize().padding(15.dp),
contentAlignment = Alignment.Center
) {
Text(text = "Item $item", fontSize = 20.sp)
}
}
}
}
}
}
PointerInput
前面 Draggable 修饰符、Swipeable 修饰符、Transformable 修饰符以及 NestedScroll 修饰符都是基于低级别的 PointerInput 修饰符进行封装实现的,所以弄清楚 PointerInput 修饰符的使用方法,有助于我们对高级别手势处理修饰符的理解,并且能够帮助我们更好的完成上层开发实现各种复杂的手势需求。
fun Modifier.pointerInput(
vararg keys: Any?,
block: suspend PointerInputScope.() -> Unit
): Modifier = composed(
...
) {
...
remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply {
LaunchedEffect(this, *keys) {
block()
}
}
}
使用 PointerInput 修饰符时我们需要传入两个参数,keys 与 block。
- keys:当 Composable 组件发生重组时,如果传入的 keys 发生了变化,则手势事件处理过程会被中断。
- block:在这个 PointerInputScope 类型作用域代码块中,便可以声明手势事件处理逻辑了。通过 suspend 关键字可知这是一个挂起函数,这意味着在 Compose 中手势处理最终都发生在协程中。
我们在 PointerInputScope 接口声明中能够找到所有可用的手势处理方法,我们可以通过这些方法获取到更加详细的手势信息以及更加细粒度的手势事件处理。
拖动类型基础 API
API名称 | 作用 |
---|---|
detectDragGestures | 监听拖动手势 |
detectDragGesturesAfterLongPress | 监听长按后的拖动手势 |
detectHorizontalDragGestures | 监听水平拖动手势 |
detectVerticalDragGestures | 监听垂直拖动手势 |
这类拖动监听 API 功能上相类似,使用时需要传入参数也比较相近。我们可以根据实际情况来选用不同 API。在使用这些 API 时,我们可以定制在不同时机的处理回调,以 detectDragGestures 为例:
suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)
这里提供了4个回调时机,onDragStart 会在拖动开始时回调,onDragEnd 会在拖动结束时回调,onDragCancel 会在拖动取消时回调,而 onDrag 则会在拖动真正发生时回调。
注意:onDragCancel 触发时机多发生于滑动冲突的场景,子组件可能最开始是可以获取到拖动事件的,当拖动手势事件达到莫个指定条件时可能会被父组件劫持消费,这种场景下便会执行 onDragCancel 回调。所以 onDragCancel 回调主要依赖于实际业务逻辑。
我们可以利用 detectDragGestures 轻松的实现拖动手势监听:
@Composable
fun DragGestureDemo() {
var offset by remember { mutableStateOf(Offset.Zero) }
var dragState by remember { mutableStateOf("") }
Box(contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Box(Modifier
.size(100.dp)
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.background(Color.Green)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
dragState = "拖动开始了~"
},
onDragEnd = {
dragState = "拖动结束了~"
},
onDragCancel = {
dragState = "拖动取消了~"
},
onDrag = { change: PointerInputChange, dragAmount: Offset ->
dragState = "拖动中~$dragAmount"
offset += dragAmount
}
)
}
)
Text(text = dragState,
Modifier.align(Alignment.TopCenter).padding(top = 35.dp),
fontSize = 22.sp
)
}
}
运行效果:
点击类型基础 API
API名称 | 作用 |
---|---|
detectTapGestures | 监听点击手势 |
在 PointerInputScope 中,我们可以使用 detectTapGestures 设置更细粒度的点击监听回调。作为低级别点击监听 API,在发生点击时不会带有像 Clickable 修饰符与 CombinedClickable 修饰符那样会为所修饰的组件施加一个涟漪波纹效果动画的蒙层,我们能够根据需要进行更灵活的上层定制。
detectTapGestures 提供了四个可选事件回调,可以根据需求来设置不同点击事件回调。
suspend fun PointerInputScope.detectTapGestures(
onDoubleTap: ((Offset) -> Unit)? = null, // 双击时回调
onLongPress: ((Offset) -> Unit)? = null, // 长按时回调
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture, // 按下时回调
onTap: ((Offset) -> Unit)? = null // 轻触时回调
)
这几种点击事件回调存在着先后次序的,并不是每次只会执行其中一个。onPress 是最普通的 ACTION_DOWN 事件,你的手指一旦按下便会回调。如果你连着按了两下,则会在执行两次 onPress 后执行 onDoubleTap。如果你的手指按下后不抬起,当达到长按的判定阈值 (400ms) 会执行 onLongPress。如果你的手指按下后快速抬起,在轻触的判定阈值内(100ms)会执行 onTap 回调。
总的来说, onDoubleTap 回调前必定会先回调 2 次 Press,而 onLongPress 与 onTap 回调前必定会回调 1 次 Press。
detectTapGestures 使用起来非常简单,我们根据需求来设置不同点击事件回调即可。
@Composable
fun TapGestureDemo() {
var dragState by remember { mutableStateOf("") }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(Modifier
.size(100.dp)
.background(Color.Green)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { offset: Offset ->
dragState = "发生双击操作了~"
},
onLongPress = { offset: Offset ->
dragState = "发生长按操作了~"
},
onPress = { offset: Offset ->
dragState = "发生按下操作了~"
},
onTap = { offset: Offset ->
dragState = "发生轻触操作了~"
}
)
}
)
Text(text = dragState,
Modifier.align(Alignment.TopCenter).padding(top = 35.dp),
fontSize = 22.sp
)
}
}
变换类型基础 API
API名称 | 作用 |
---|---|
detectTransformGestures | 监听拖动、缩放与旋转手势 |
使用 detectTransformGestures 可以获取到双指拖动、缩放与旋转手势操作中更具体的手势信息,例如重心。
与 Tranformable 修饰符一样,detectTransformGestures 方法提供了两个参数。
- panZoomLock(可选): 当拖动或缩放手势发生时是否支持旋转
- onGesture(必须):当拖动、缩放或旋转手势发生时回调
suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)
与 Tranformable 修饰符不同的是,通过这个 API 可以监听单指的拖动手势,和拖动类型基础 API所提供的功能一样,除此之外还支持监听双指缩放与旋转手势。反观 Transfomer 修饰符 只能监听到双指拖动手势,不知设计成这样的行为不一致是否是官方有意为之。
使用起来十分简单,我们仅需根据手势信息来更新状态就可以了。当我们处理旋转、缩放与拖动这类手势时,需要格外的注意 Modifier 调用次序,因为这会影响最终呈现效果。
@Composable
fun TransformGestureDemo() {
val boxSize = 200.dp
var offset by remember { mutableStateOf(Offset.Zero) }
var rotationAngle by remember { mutableStateOf(0f) }
var scale by remember { mutableStateOf(1f) }
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)
.pointerInput(Unit) {
detectTransformGestures(
panZoomLock = true, // 平移或放大时是否可以旋转
onGesture = { centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->
offset += pan
scale *= zoom
rotationAngle += rotation
}
)
}
)
}
}
运行效果:
手势事件作用域 awaitPointerEventScope
我们前面介绍的 GestureDetector 系列 API 本质上仍然是一种封装,既然手势处理是在协程中完成的,所以手势监听必然是通过协程的挂起恢复实现的,以取代传统的回调监听方式。要想深入理解 Compose 手势处理,就需要学习更为底层的手势处理挂起方法。
在 PointerInputScope 中我们使用 awaitPointerEventScope
方法获得 AwaitPointerEventScope
作用域,在 AwaitPointerEventScope
作用域中我们可以使用 Compose 中所有低级别的手势处理挂起方法。当 awaitPointerEventScope
内所有手势事件都处理完成后 awaitPointerEventScope
便会恢复执行将 Lambda
中最后一行表达式的数值作为返回值返回。
suspend fun <R> awaitPointerEventScope(
block: suspend AwaitPointerEventScope.() -> R
): R
我们在 AwaitPointerEventScope 中发现了以下这些基础手势方法,可以发现这些 API 均是挂起函数
API名称 | 作用 |
---|---|
awaitPointerEvent | 手势事件 |
awaitFirstDown | 第一根手指的按下事件 |
drag | 拖动事件 |
horizontalDrag | 水平拖动事件 |
verticalDrag | 垂直拖动事件 |
awaitDragOrCancellation | 单次拖动事件 |
awaitHorizontalDragOrCancellation | 单次水平拖动事件 |
awaitVerticalDragOrCancellation | 单次垂直拖动事件 |
awaitTouchSlopOrCancellation | 有效拖动事件 |
awaitHorizontalTouchSlopOrCancellation | 有效水平拖动事件 |
awaitVerticalTouchSlopOrCancellation | 有效垂直拖动事件 |
事件之源 awaitPointerEvent
之所以称这个 API 为事件之源,因为上层所有手势监听 API 都是基于这个 API 实现的,他的作用类似于传统 View
中的 onTouchEvent()
。无论用户是按下、移动或抬起都将视作一次手势事件,当手势事件发生时 awaitPointerEvent
便会恢复返回监听到的屏幕上所有手指的交互信息。
forEachGesture {
awaitPointerEventScope {
var event = awaitPointerEvent()
Log.d(TAG, "x: ${event.changes[0].position.x}, y: ${event.changes[0].position.y}")
}
}
事件分发
实际上 awaitPointerEvent 存在着一个可选参数 PointerEventPass
,这个参数实际上是用来定制手势事件分发顺序的。
suspend fun awaitPointerEvent(
pass: PointerEventPass = PointerEventPass.Main
): PointerEvent
PointerEventPass 有 3 个枚举值,可以让我们来决定手势的处理阶段。在 Compose 中,手势处理共有3个阶段:
- Initial 阶段:自上而下的分发手势事件
- Main 阶段:自下而上的分发手势事件
- Final 阶段:自上而下的分发手势事件
在 Inital 阶段,手势事件会在所有使用 Inital 参数的组件间自上而下的完成首次分发。利用 Inital 可以使父组件能够预先劫持消费手势事件,这类似于传统 View 中 onInterceptTouchEvent 的作用。
在 Main 阶段,手势事件会在所有使用 Main 参数的组件间自下而上的完成第二次分发。利用 Main 可以使子组件能先于父组件完成手势事件的处理,这有些类似于传统 View 中 onTouchEvent 的作用。
在 Final 阶段,手势事件会在所有使用 Final 参数的组件间自上而下的完成最后一次分发。Final阶段一般用来让组件了解经历过前面几个阶段后的手势事件消费情况,从而确定自身行为。例如按钮组件可以不再手指从按钮上移动开的事件,因为这个事件可能已被父组件滚动器用于滚动消费了。
接下来我们通过一个嵌套组件的手势监听来演示事件的分发过程。
当所有组件的手势监听均默认使用 Main 时,点击中间的Box,事件分发顺序为:第三层 --> 第二层 --> 第一层
而如果第一层组件使用 Inital,第二层组件使用 Final ,第三层组件使用 Main,事件分发顺序为:第一层 --> 第三层 --> 第二层
接下来,我们换作四层嵌套来观察手势事件的分发,其中第一层与第三层使用 Initial,第二层使用 Final,第四层使用 Main,事件分发顺序为:第一层 --> 第三层 --> 第四层 --> 第二层
可以理解为用一根竹签去串不同的手抓饼,第一次是从屏幕里面垂直往外串,但是只串那些标记为"Initial"的饼子,第二次再反过来从屏幕外垂直往里串,但是只串那些标记为"Main"的饼子,最后一次再从屏幕里面垂直往外串,但是只串那些标记为"Final"的饼子。
@Composable
fun NestedBoxDemo() {
var result by remember { mutableStateOf("执行顺序:\n") }
Box(Modifier.myBox(300.dp, Color.Red, PointerEventPass.Initial) {
result += "第一层\n"
},
contentAlignment = Alignment.Center
) {
Box(Modifier.myBox(250.dp, Color.Blue, PointerEventPass.Final) {
result += "第二层\n"
},
contentAlignment = Alignment.Center
) {
Box(Modifier.myBox(200.dp, Color.Green, PointerEventPass.Initial) {
result += "第三层\n"
},
contentAlignment = Alignment.Center
) {
Box(Modifier.myBox(150.dp, Color.Yellow, PointerEventPass.Main) {
result += "第四层\n"
},
contentAlignment = Alignment.Center
) {
Text(text = result)
}
}
}
}
}
fun Modifier.myBox(size: Dp, color: Color, pass: PointerEventPass, block: ()->Unit): Modifier {
return this.size(size).background(color).pointerInput(Unit) {
awaitPointerEventScope {
awaitPointerEvent(pass)
block()
}
}
}
事件消费
我们看到 awaitPointerEvent 返回了一个 PointerEvent 实例。
actual data class PointerEvent internal constructor(
actual val changes: List<PointerInputChange>,
internal val motionEvent: MotionEvent?
)
从 PointerEvent 类的声明中可以看到包含了两个属性 changes 与 motionEvent。
- motionEvent:实际上就是传统 View 系统中的 MotionEvent,由于被声明 internal ,说明官方并不希望我们直接拿来使用。
- changes:其中包含了一次手势交互中所有手指的交互信息。在多指操作时,利用 changes 可以轻松定制多指手势处理。
可以看出单指交互的完整信息被封装在了一个 PointerInputChange 实例中,接下来看看 PointerInputChange 提供了哪些手势信息。
class PointerInputChange(
val id: PointerId, // 手指Id
val uptimeMillis: Long, // 当前手势事件的时间戳
val position: Offset, // 当前手势事件相对组件左上角的位置
val pressed: Boolean, // 当前手势是否按下
val previousUptimeMillis: Long, // 上一次手势事件的时间戳
val previousPosition: Offset, // 上一次手势事件相对组件左上角的位置
val previousPressed: Boolean, // 上一次手势是否按下
val consumed: ConsumedData, // 当前手势是否已被消费
val type: PointerType = PointerType.Touch // 手势类型(鼠标、手指、手写笔、橡皮)
)
利用这些丰富的手势信息,我们可以在上层定制实现各类复杂的交互手势。
可以看到其中的 consumed 成员记录着该事件是否已被消费,我们可以使用 PointerInputChange 提供的 consume 系列 API 来修改这个手势事件的消费标记。
API名称 | 作用 |
---|---|
changedToDown | 是否已经按下(按下手势已消费则返回false) |
changedToDownIgnoreConsumed | 是否已经按下(忽略按下手势已消费标记) |
changedToUp | 是否已经抬起(按下手势已消费则返回false) |
changedToUpIgnoreConsumed | 是否已经抬起(忽略按下手势已消费标记) |
positionChanged | 是否位置发生了改变(移动手势已消费则返回false) |
positionChangedIgnoreConsumed | 是否位置发生了改变(忽略已消费标记) |
positionChange | 位置改变量(移动手势已消费则返回Offset.Zero) |
positionChangeIgnoreConsumed | 位置改变量(忽略移动手势已消费标记) |
isConsumed | 当前手势是否已被消费 |
consumeDownChange | 消费按下手势 |
consumePositionChange | 消费移动手势 |
consumeAllChanges | 消费按下与移动手势 |
isOutOfBounds | 当前手势是否在固定范围内 |
前面提到,我们可以通过设置 PointerEventPass 来定制嵌套组件间手势事件分发顺序。假设分发流程中组件 A 预先获取到了手势信息并进行消费,手势事件仍然会被之后的组件 B 获取得到。组件 B 在使用 positionChange 获取的偏移值时会返回 Offset.ZERO,这是因为此时该手势事件已被标记为已消费的状态。当然组件 B 也可以通过 IgnoreConsumed 系列 API 突破已消费标记的限制获取到手势信息。
我们仍然通过前面使用的嵌套组件示例子来看看手势事件的消费。我们的嵌套组件中第一层组件使用 Inital,第二层组件使用 Final ,第三层组件使用 Main。
我们在第三层组件的手势事件监听中进行消费,因为我们知道手势事件会交由第一层, 再交由第三层,最后交由第二层。第三层组件处于本次手势分发流程的中间位置。
当我们在第三层组件消费了 ACTION_DOWN 后,之后处理的第二层组件接收的手势事件仍是被标记为消费状态的。
@Composable
fun ConsumeDemo() {
var result by remember { mutableStateOf("消费状态:\n") }
Box(Modifier.myBox(300.dp, Color.Red, PointerEventPass.Initial) { event ->
result +="第一层 isConsumed: ${event.changes[0].isConsumed}\n"
},
contentAlignment = Alignment.Center
) {
Box(Modifier.myBox(250.dp, Color.Blue, PointerEventPass.Final) { event ->
result += "第二层 isConsumed: ${event.changes[0].isConsumed}\n"
},
contentAlignment = Alignment.Center
) {
Box(Modifier.myBox(200.dp, Color.Green, PointerEventPass.Main) { event ->
val change = event.changes[0]
if (change.pressed != change.previousPressed) change.consume() // 消费事件
result += "第三层 isConsumed: ${change.isConsumed}\n"
},
contentAlignment = Alignment.Center
) {
Text(text = result)
}
}
}
}
private fun Modifier.myBox(size: Dp, color: Color, pass: PointerEventPass, block: (PointerEvent)->Unit): Modifier {
return this.size(size).background(color).pointerInput(Unit) {
awaitPointerEventScope {
val event = awaitPointerEvent(pass)
block(event)
}
}
}
awaitFirstDown
awaitFirstDown 将等待直到第一根手指 ACTION_DOWN 事件时恢复执行,并将手指按下事件返回。翻阅源码可以看出其内部实现原理并不复杂。
suspend fun AwaitPointerEventScope.awaitFirstDown(
requireUnconsumed: Boolean = true
): PointerInputChange {
var event: PointerEvent
do {
event = awaitPointerEvent() // 监听手势事件
} while (
// 遍历每一根手指的事件信息
!event.changes.fastAll {
// 需要没有被消费过的手势事件
if (requireUnconsumed) {
// 返回该事件是否是一个还没有被消费的DOWN事件
// 当返回 false 时说明是不是DOWN事件或已被消费的DOWN事件
it.changedToDown()
} else {
// 返回该事件是否是一个DOWN事件,忽略是否已被消费
// 当返回 false 时说明是不是DOWN事件
it.changedToDownIgnoreConsumed()
}
}
)
// 返回第一根手指的事件信息
return event.changes[0]
}
forEachGesture
前面提到 Compose 手势操作实际上是在协程中监听处理的,当协程处理完一轮手势交互后便会结束,当进行第二次手势交互时由于负责手势监听的协程已经结束,手势事件便会被丢弃掉。那我们该怎样才能让手势监听协程能够不断地处理每一轮的手势交互呢。我们很容易想到可以在外层嵌套一个 while(true) 进行实现,然而这么做并不优雅,且也存在着一些问题。
当用户出现一连串手势操作时,很难保证各手势之间有清晰分界,即无法保证每一轮手势结束后,所有手指都是离开屏幕的。在传统 View 体系中,一次手指按下、移动到抬起过程中的所有手势事件可以看作是一个完整的手势交互序列。每当用户触摸屏幕交互时,我们可以根据这一次用户输入的手势交互序列中的手势信息进行相应的处理。
当第一轮手势处理结束或者被中断取消后,如果仍有手指留在屏幕。如果采用 while(true) 处理手势,则第二轮手势处理可能会使用第一轮手势交互序列中信息,导致出现不符预期的结果。
Compose 为我们提供了 forEachGesture 方法保证了每一轮手势处理逻辑的一致性。实际上前面我们所介绍的 GestureDetect 系列 API,其内部实现都使用了 forEachGesture。
通过 forEachGesture 的源码可知,每一轮手势处理结束后或本次手势处理被取消时,都会使用 awaitAllPointersUp() 保证所有手指均已抬起。并且同时也会与当前组件的生命周期对齐,当组件离开视图树时,手势监听也会随之结束。
suspend fun PointerInputScope.forEachGesture(block: suspend PointerInputScope.() -> Unit) {
val currentContext = currentCoroutineContext()
while (currentContext.isActive) {
try {
block()
// 挂起等待所有手指抬起
awaitAllPointersUp()
} catch (e: CancellationException) {
if (currentContext.isActive) {
// 手势事件取消时,如果协程还存活则等待手指抬起再进行下一轮监听
awaitAllPointersUp()
throw e
}
}
}
}
awaitEachGesture
注意: forEachGesture
这个API现在已经被标记为Deprecated
(但是你目前仍然可以使用),官方推荐使用 awaitEachGesture(block)
这个Api来代替它,awaitEachGesture
的内部在 awaitPointerEventScope
内直接调用了传入的block
,因此相比 forEachGesture
使用更加方便,不用在forEachGesture
内再套awaitPointerEventScope
了。
suspend fun PointerInputScope.awaitEachGesture(block: suspend AwaitPointerEventScope.() -> Unit) {
val currentContext = currentCoroutineContext()
awaitPointerEventScope {
while (currentContext.isActive) {
try {
block()
// Wait for all pointers to be up. Gestures start when a finger goes down.
awaitAllPointersUp()
} catch (e: CancellationException) {
if (currentContext.isActive) {
// The current gesture was canceled. Wait for all fingers to be "up" before
// looping again.
awaitAllPointersUp()
} else {
// detectGesture was cancelled externally. Rethrow the cancellation exception to
// propagate it upwards.
throw e
}
}
}
}
}
drag
前面提到的 detectDragGestures,以及更为上层的 Draggable 修饰符内部都是使用 drag 挂起方法来实现拖动监听的。通过函数签名可以看到我们不仅需要手指拖动的监听回调,还需传入手指的标识信息,表示监听具体哪根手指的拖动手势。
suspend fun AwaitPointerEventScope.drag(
pointerId: PointerId,
onDrag: (PointerInputChange) -> Unit
)
我们可以先利用 awaitFirstDown 获取到记录着交互信息的 PointerInputChange 实例,其中 id 字段记录着发生 ACTION_DOWN 事件的手指标识信息。通过结合 awaitEachGesture、awaitFirstDown 与 drag,我们便可以实现一个简单的拖动手势监听了。
@Composable
fun BaseDragGestureDemo() {
var offset by remember { mutableStateOf(Offset.Zero) }
Box(Modifier.fillMaxSize(), Alignment.Center) {
Box(Modifier
.size(100.dp)
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.background(Color.Green)
.pointerInput(Unit) {
awaitEachGesture {
val downEvent = awaitFirstDown() // 获取第一根手指的DOWN事件
// 根据手指标识符跟踪多种手势
drag(downEvent.id) {
offset += it.positionChange() // 根据手势位置的变化更新偏移量
}
}
}
)
}
}
awaitDragOrCancellation
与 drag 不同的是,awaitDragOrCancellation 负责监听单次拖动事件。当该手指抬起时,如果有其他手指还在屏幕上,则会选择其中一根手指来继续追踪手势。当最后一根手指离开屏幕时则会返回抬起事件。
当手指拖动事件已经在 Main 阶段被消费,拖动行为会被认为已经取消,此时会返回 null。如果在调用 awaitDragOrCancellation 前,pointId 对应手指没有产生 ACTION_DOWN 事件则也会返回 null。
当然我们也可以使用 awaitDragOrCancellation 来完成 UI 拖动手势处理流程。
@Composable
fun BaseDragGestureDemo2() {
var offset by remember { mutableStateOf(Offset.Zero) }
Box(Modifier.fillMaxSize(), Alignment.Center) {
Box(Modifier
.size(100.dp)
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.background(Color.Green)
.pointerInput(Unit) {
awaitEachGesture {
val downPointer = awaitFirstDown() // 获取第一根手指的DOWN事件
while (true) {
// 根据手指标识符跟踪拖动手势,手指抬起或拖动事件被消费时返回null
val event = awaitDragOrCancellation(downPointer.id)
if (event == null) break // 拖动事件被取消
if (event.changedToUp()) break // 所有手指均已抬起
offset += event.positionChange() // 根据手势位置改变量更新偏移量状态
}
}
}
)
}
}
awaitTouchSlopOrCancellation
awaitTouchSlopOrCancellation 用于定制监听一次有效的拖动行为,这里的有效是开发者自己来定制的。在使用时,我们需要设置一个 pointId ,表示我们希望追踪手势事件的手指标识符。当该手指抬起时,如果有其他手指还在屏幕上,则会选择其中一根手指来继续追踪手势,而如果已经没有手指在屏幕上了则返回 null。如果在调用 awaitTouchSlopOrCancellation 前,pointId 对应手指没有产生 ACTION_DOWN 事件则也会返回 null。
suspend fun AwaitPointerEventScope.awaitTouchSlopOrCancellation(
pointerId: PointerId,
onTouchSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit
)
其中 onTouchSlopReached 回调方法会在超过 ViewConfiguration 中所设定的阈值 touchSlop 时回调。如果根据事件信息我们希望接收这次手势事件,则应该通过 change 调用 consumePositionChange 进行消费,此时 awaitTouchSlopOrCancellation 会恢复执行,并返回当前 PointerInputChange。如果不消费,则会继续挂起检测滑动位移。
利用awaitTouchSlopOrCancellation实现惯性滑动
如果当手指拖动离开屏幕存在初速度时,被拖动的组件会惯性滑动一段距离后停下,这种交互效果被称作 Fling。
既然我们是要拖动组件,当发生拖动手势时组件我们可以设置 offset 移动组件位置。当发生 Fling 时组件会惯性朝着某一方向滑动一段距离后停下,实际上在手指离开屏幕时我们可以根据当前手势速度与组件位置来预先计算出组件最终停留的位置,所以 Fling 本质上只是以种交互动画。既然是动画,我们便可以使用 Animatable 包装组件偏移量信息。
var offset = remember {
Animatable(Offset.Zero, Offset.VectorConverter)
}
对于拖动手势,我们首先需要使用 awaitFirstDown 获取 ACTION_DOWN 手势事件信息。值得注意的是,当上一轮 Fling 未结束本轮手势便开始时。我们可以使用 Animatable 提供的 stop 方法来中断结束上一轮动画。
forEachGesture {
val down = awaitPointerEventScope { awaitFirstDown() }
offset.stop()
...
}
接下来我们可以利用 awaitTouchSlopOrCancellation 检测当前是否为有效拖动手势,当检测成功后便可以使用 drag 来监听具体的拖动手势事件。
forEachGesture {
val down = awaitPointerEventScope { awaitFirstDown() }
offset.stop()
awaitPointerEventScope {
var validDrag: PointerInputChange?
do {
validDrag = awaitTouchSlopOrCancellation(down.id) { change, _ ->
change.consumePositionChange() // 消费位置变化表示接受
}
} while (validDrag != null && !validDrag.isConsumed)
if (validDrag != null) {
// 拖动手势监听
}
}
}
前面我们提到过当手指离开屏幕时,我们需要根据离屏时的位置信息与速度信息来计算组件最终会停留的位置。位置信息我们可以利用 offset 获取得到,而速度信息的获取则需要使用速度追踪器 VelocityTracker。
当发生拖动时,我们首先使用 snapTo 移动组件偏移位置。既然追踪手势速度,我们就需要将手势信息告知 VelocityTracker,通过 addPosition 实时告知 VelocityTracker 当前的手势位置,VelocityTracker 便可以实时计算出当前的手势速度了。
drag(validDrag.id) {
launch {
offset.snapTo(
offset.value + it.positionChange()
)
velocityTracker.addPosition(
it.uptimeMillis,
it.position
)
}
}
当手指离开屏幕时,我们可以利用 VelocityTracker 与 Offset 获取到实时速度信息与位置信息。之后,我们可以利用 splineBasedDecay 创建一个衰值推算器,这可以帮助我们根据当前速度与位置信息推算出组件 Fling 后停留的位置。由于最终位置可能会超出屏幕,所以我们还需设置数值上下界,并采用 animateTo 进行 Fling 动画。由于我们希望的是组件最终会缓缓的停下,所以这里采用的是 LinearOutSlowInEasing 插值器。
val decay = splineBasedDecay<Offset>(this)
var targetOffset = decay.calculateTargetValue(Offset.VectorConverter, offset.value,
Offset(horizontalVelocity, verticalVelocity)).run {
Offset(x.coerceIn(0f, 320.dp.toPx()), y.coerceIn(0f, 320.dp.toPx()))
}
launch {
offset.animateTo(targetOffset, tween(2000, easing = LinearOutSlowInEasing))
}
完整代码:
@Composable
fun DragFlingDemo() {
Box(Modifier.fillMaxSize(),Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
var horizontalVelocity by remember { mutableStateOf(0f) }
var verticalVelocity by remember { mutableStateOf(0f) }
Box(Modifier.fillMaxWidth().height(50.dp), Alignment.Center) {
Text("Velocity", fontSize = 42.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(10.dp))
Box(Modifier.fillMaxWidth().height(50.dp), Alignment.Center) {
val text = "Horizontal: %.2f Vertical: %.2f".format(horizontalVelocity, verticalVelocity)
Text(text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(20.dp))
val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
Box(Modifier.size(350.dp).background(Color.Gray)
.pointerInput(Unit) {
offset.updateBounds(
lowerBound = Offset.Zero,
upperBound = Offset(320.dp.toPx(), 320.dp.toPx())
)
coroutineScope {
awaitEachGesture {
val down = awaitFirstDown()
launch { offset.stop() }
var validDrag: PointerInputChange?
do {
validDrag = awaitTouchSlopOrCancellation(down.id) { change, _ ->
if (change.positionChange() != Offset.Zero) change.consume()
}
} while (validDrag != null && !validDrag.isConsumed)
if (validDrag != null) {
val velocityTracker = VelocityTracker()
var dragAnimJob: Job? = null
drag(validDrag.id) {
dragAnimJob = launch {
offset.snapTo(offset.value + it.positionChange())
velocityTracker.addPosition(it.uptimeMillis, it.position)
horizontalVelocity = velocityTracker.calculateVelocity().x
verticalVelocity = velocityTracker.calculateVelocity().y
}
}
horizontalVelocity = velocityTracker.calculateVelocity().x
verticalVelocity = velocityTracker.calculateVelocity().y
val decay = splineBasedDecay<Offset>(this)
val targetOffset = decay.calculateTargetValue(Offset.VectorConverter,
offset.value, Offset(horizontalVelocity, verticalVelocity)).run {
Offset(x.coerceIn(0f, 320.dp.toPx()), y.coerceIn(0f, 320.dp.toPx()))
}
dragAnimJob?.cancel()
launch {
offset.animateTo(targetOffset, tween(2000, easing = LinearOutSlowInEasing))
}
}
}
}
}
) {
Box(Modifier
.offset { IntOffset(offset.value.x.roundToInt(), offset.value.y.roundToInt()) }
.size(30.dp).clip(CircleShape).background(Color.Green)
)
}
}
}
}
参考资料:
- 《Jetpack Compose从入门到实战》- 机械工业出版社 - 2022年9月