順手修復了Jetpack Compose官方文件中的一個多點觸控示例的Bug
theme: arknights highlight: androidstudio
- 小知識,大挑戰!本文正在參與“程式設計師必備小知識”創作活動。
- 本文已參與 「掘力星計劃」 ,贏取創作大禮包,挑戰創作激勵金。
1.前言
強烈建議先看: Compose官方手勢文件,超詳細,裡面有很多示例,一定要全部看完,對你們有好處
本篇文章源起官方手勢指南文件的示例(和👆👆上面的連結不是同一個文件)
,當時看到這個例子的時候,就想寫這篇文章了,因為用Compose實現起來簡直太簡單了,想著拿來就用,不過.........官方示例有問題
,原以為複製貼上執行一氣呵成,看來是我想多了,那麼先分析一下官方示例這麼寫有什麼問題吧
2.問題分析
我們來看一下官方的,多點觸控:平移、縮放、旋轉,示例存在的問題
kotlin
@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
kotlin
//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 = [email protected]
scaleY = [email protected]
......
}
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方法執行
kotlin
//androidx.compose.ui.node.LayoutNodeWrapper
override fun placeAt(
position: IntOffset,
zIndex: Float,
layerBlock: (GraphicsLayerScope.() -> Unit)?
) {
onLayerBlockUpdated(layerBlock)
......
}
onLayerBlockUpdated(layerBlock) 裡面會觸發如下方法執行:
kotlin
//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
kotlin
//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寬高的一半
kotlin
renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width
renderNode.pivotY = transformOrigin.pivotFractionY * renderNode.height
我們看到renderNode的實際寬高是不變的,中心點不會變,原本以為
是中心點的問題,實際上是雙指縮放移動的時候,rememberTransformableState
回撥的offsetChange
計算方式有問題,無法達到我們要的效果,我們來看一下Modifier.transformable
原始碼:
kotlin
//androidx.compose.foundation.gestures.TransformableKt
fun Modifier.transformable(
state: TransformableState,
lockRotationOnZoomPan: Boolean = false,
enabled: Boolean = true
) = composed(
......
forEachGesture {
detectZoom(updatePanZoomLock, updatedState)
}
......
)
進detectZoom方法
看裡面做了什麼
```kotlin
//androidx.compose.foundation.gestures.TransformableKt
private suspend fun PointerInputScope.detectZoom(
panZoomLock: State
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回撥方法裡面回返回:“當前位置” - “前一個位置”
的偏移量,我們來看最終修復的方法如下:
kotlin
@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()
)
}
修復後的效果
另外我們還可以使用其他方式實現,下面我們給大家提供其他的實現方式原始碼:
kotlin
@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正確的保活方案,不要掉進保活需求死迴圈陷進
- 鴻蒙ArkUI如何開發跨平臺應用?
- HarmonyOS玩轉ArkUI動效 - 水母動畫
- Compose挑燈夜看 - 照亮手機螢幕裡面的書本內容
- 順手修復了Jetpack Compose官方文件中的一個多點觸控示例的Bug
- 正確實踐Jetpack SplashScreen API —— 在所有Android系統上使用總結,內含原理分析
- Jetpack Compose處理“導航欄、狀態列、鍵盤” 影響內容顯示的問題集錦
- 閒聊Android懸浮的“系統文字選擇選單”和“ActionMode解析”——附上原理分析
- Jetpack Compose實現bringToFront功能——附上原理分析
- Android跨程序傳大圖思考及實現——附上原理分析