1.前言
强烈建议先看: Compose官方手势文档,超详细,里面有很多示例,一定要全部看完,对你们有好处
本篇文章源起官方手势指南文档的示例(和👆👆上面的链接不是同一个文档)
,当时看到这个例子的时候,就想写这篇文章了,因为用Compose实现起来简直太简单了,想着拿来就用,不过.........官方示例有问题
,原以为复制粘贴运行一气呵成,看来是我想多了,那么先分析一下官方示例这么写有什么问题吧
2.问题分析
我们来看一下官方的,多点触控:平移、缩放、旋转,示例存在的问题
@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) }
// 回调接收来自前一个事件的变更,在lambda中更新状态值
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()
)
}
一眼望去,嗯,没啥大问题,运行一下:嗯,有点失望,缩放平移之后,中心点不是缩放平移后的中心点
,看官方示例运行的效果,很明显双指移动的时候不跟随手指
有问题的效果
来跟着我们一起来分析一下上面示例的源码实现,为啥组合起来就有毛病呢
,请往下看
3.源码分析
我们以前手撕过Compose UI创建布局绘制流程+原理,感兴趣的同学可以去看看学习一下
我们双指捏合缩放平移,会触发AndroidComposeView的dispatchDraw方法调用执行,在调用layoutNode绘制之前,会执行measureAndLayout(),经过一系列方法调用,会执行到SimpleGraphicsLayerModifier
//androidx.compose.ui.graphics.SimpleGraphicsLayerModifier
private class SimpleGraphicsLayerModifier(
private val scaleX: Float,
private val scaleY: Float,
......
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
// 定义图层参数,在发生状态更改时跳过重组和重新布局
private val layerBlock: GraphicsLayerScope.() -> Unit = {
scaleX = this@SimpleGraphicsLayerModifier.scaleX
scaleY = this@SimpleGraphicsLayerModifier.scaleY
......
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
placeable.placeWithLayer(0, 0, layerBlock = layerBlock)
}
}
......
}
官方示例中,我们看到使用了Modifier.graphicsLayer,它里面返回的就是SimpleGraphicsLayerModifier,在执行placeable.placeWithLayer方法之后,会触发LayoutNodeWrapper的placeAt方法执行
//androidx.compose.ui.node.LayoutNodeWrapper
override fun placeAt(
position: IntOffset,
zIndex: Float,
layerBlock: (GraphicsLayerScope.() -> Unit)?
) {
onLayerBlockUpdated(layerBlock)
......
}
onLayerBlockUpdated(layerBlock) 里面会触发如下方法执行:
//androidx.compose.ui.node.LayoutNodeWrapper
private fun updateLayerParameters() {
......
layer.updateLayerProperties(
scaleX = graphicsLayerScope.scaleX,
scaleY = graphicsLayerScope.scaleY,
alpha = graphicsLayerScope.alpha,
translationX = graphicsLayerScope.translationX,
translationY = graphicsLayerScope.translationY,
......
)
......
}
我使用了Android10.0机器测试的,所以此处的layer使用的是RenderNodeLayer
//androidx.compose.ui.platform.RenderNodeLayer
override fun updateLayerProperties(
scaleX: Float,
scaleY: Float,
alpha: Float,
translationX: Float,
translationY: Float,
shadowElevation: Float,
......
) {
renderNode.scaleX = scaleX
renderNode.scaleY = scaleY
......
renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width
renderNode.pivotY = transformOrigin.pivotFractionY * renderNode.height
......
if (!drawnWithZ && renderNode.elevation > 0f) {
invalidateParentLayer()
}
matrixCache.invalidate()
}
入参的transformOrigin = TransformOrigin.Center
,看到这里,我们发现此处RenderNode里面的pivotX和pivotY中心点计算出来:永远是renderNode宽高的一半
renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width
renderNode.pivotY = transformOrigin.pivotFractionY * renderNode.height
我们看到renderNode的实际宽高是不变的,中心点不会变,原本以为
是中心点的问题,实际上是双指缩放移动的时候,rememberTransformableState
回调的offsetChange
计算方式有问题,无法达到我们要的效果,我们来看一下Modifier.transformable
源码:
//androidx.compose.foundation.gestures.TransformableKt
fun Modifier.transformable(
state: TransformableState,
lockRotationOnZoomPan: Boolean = false,
enabled: Boolean = true
) = composed(
......
forEachGesture {
detectZoom(updatePanZoomLock, updatedState)
}
......
)
进detectZoom方法
看里面做了什么
//androidx.compose.foundation.gestures.TransformableKt
private suspend fun PointerInputScope.detectZoom(
panZoomLock: State<Boolean>,
state: State<TransformableState>
) {
......
val panChange = event.calculatePan()
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
panMotion > touchSlop
) {
pastTouchSlop = true
lockedToPanZoom = panZoomLock.value && rotationMotion < touchSlop
}
if (pastTouchSlop) {
val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
panChange != Offset.Zero
) {
transformBy(zoomChange, panChange, effectiveRotation)
}
......
}
......
}
我们看上面的pastTouchSlop
为true的条件,条件之一就是:panMotion > touchSlop
,如果条件都不满足
的话,那么本次的panChange
值就无法传递回来
,我们不使用这里面的panChange值,下面我们自己来检测手势拖拽事件,计算移动的偏移量,请继续往下看👇👇
4.问题修复
分析完上面的内容之后,我们不能再使用 rememberTransformableState
回调提供的offsetChange
值了,我们需要重新计算Offset值,那么我们需要用Modifier.pointerInput 来接收触摸返回的数据,PointerInputScope有一个扩展方法可以同时检测“水平
”和“垂直
”方向的拖拽回调PointerInputScope.detectDragGestures
如果只想检测一个方向可以使用以下两个方法:
PointerInputScope.detectHorizontalDragGestures PointerInputScope.detectVerticalDragGestures
在onDrag回调方法里面回返回:“当前位置” - “前一个位置”
的偏移量,我们来看最终修复的方法如下:
@Composable
fun TransformableExample() {
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
//不要使用此处的offsetChange
}
Box(
Modifier
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
// 返回正确的移动位置,跟随手指
offset +=dragAmount
}
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y,
)
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}
修复后的效果
另外我们还可以使用其他方式实现,下面我们给大家提供其他的实现方式源码:
@Composable
fun TransformableExample2() {
var zoom by remember { mutableStateOf(1f) }
var angle by remember { mutableStateOf(0f) }
val offsetX = remember { mutableStateOf(0f) }
val offsetY = remember { mutableStateOf(0f) }
Box(
Modifier
.rotate(angle)
.scale(zoom)
.offset {
IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt())
}
.background(Color.Blue)
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
do {
val event = awaitPointerEvent()
val offset = event.calculatePan()
offsetX.value += offset.x
offsetY.value += offset.y
val rotation = event.calculateRotation()
angle += rotation
zoom *= event.calculateZoom()
} while (event.changes.any { it.pressed })
}
}
}
.fillMaxSize()
)
}
如果使用Modifier的scale/rotate/offset
需要注意一点,顺序很重要:
1.如果offset发生在rotate之前,rotate会对offset造成影响。
实际效果会出现
: 当出现拖动手势时,组件会以当前角度为坐标轴进行偏移。
2.如果offset发生在scale之前,scale也会对offset造成影响。实际效果会出现
: UI组件在拖动时不跟手
所以使用Modifier的时候
,关于偏移、缩放与旋转,我们建议的调用顺序是rotate -> scale -> offset
往期文章推荐:
1.正确实践Jetpack SplashScreen API —— 在所有Android系统上使用总结,内含原理分析
2.Jetpack Compose处理“导航栏、状态栏、键盘” 影响内容显示的问题集锦
3.Android跨进程传大图思考及实现——附上原理分析
4.闲聊Android悬浮的“系统文本选择菜单”和“ActionMode解析”——附上原理分析
5.Jetpack Compose UI创建布局绘制流程+原理 —— 内含概念详解(满满干货)
6.Jetpack App Startup如何使用及原理分析
7.源码分析 | ThreadedRenderer空指针问题,顺便把Choreographer认识一下
8.源码分析 | 事件是怎么传递到Activity的?
9.聊聊CountDownLatch 源码
10.Android正确的保活方案,不要掉进保活需求死循环陷进