順手修復了Jetpack Compose官方文件中的一個多點觸控示例的Bug

語言: CN / TW / HK

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建立佈局繪製流程+原理,感興趣的同學可以去看看學習一下

我們雙指捏合縮放平移,會觸發AndroidComposeViewdispatchDraw方法呼叫執行,在呼叫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方法之後,會觸發LayoutNodeWrapperplaceAt方法執行

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, state: State ) { ...... 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回撥方法裡面回返回:“當前位置” - “前一個位置”的偏移量,我們來看最終修復的方法如下:

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() ) } 如果使用Modifierscale/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正確的保活方案,不要掉進保活需求死迴圈陷進