- Small knowledge, big challenge! This article is participating in the creation activity of “Essential Tips for Programmers”.
- This article has participated in the “Digitalstar Project” and won a creative gift package to challenge the creative incentive money.
1. Introduction
It is highly recommended to start by looking at the Compose official gesture document, which is extremely detailed and contains many examples. Be sure to read them all
This article is based on an example of the official gesture guide document (which is not the same document as the link at 👆👆). When I saw this example, I wanted to write this article because Compose is so easy to implement that I just wanted to use it, but……… The official example has a problem, originally thought that copy and paste run smoothly, it seems that I think too much, so first analyze the official example so write what is the problem
2. Problem analysis
Let’s take a look at the official, multi-touch: pan, zoom, rotate example
@Composable
fun TransformableSample(a) {
// set up all transformation states
var scale by remember { mutableStateOf(1f)}var rotation by remember { mutableStateOf(0f)}var offset by remember { mutableStateOf(Offset.Zero) }
// The callback receives changes from the previous event, updating the status value in the 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()
)
}
Copy the code
Well, I’m a little disappointed. After panning and scaling, the center point is not the same as the center point after panning and scaling. Looking at the official example, it is obvious that the two fingers do not follow the fingers when moving
Problematic effect
Follow us to analyze the source code implementation of the above example, why the combination of problems, please read down
3. Source code analysis
Compose UI: Compose UI: Compose UI: Compose UI: Compose UI: Compose UI: Compose UI: Compose UI
When we pinch and pan, the dispatchDraw method call from AndroidComposeView is executed, and measureAndLayout() is executed before layoutNode is drawn. After a series of method calls, Will perform to the SimpleGraphicsLayerModifier
//androidx.compose.ui.graphics.SimpleGraphicsLayerModifier
private class SimpleGraphicsLayerModifier(
private val scaleX: Float.private val scaleY: Float,...) : LayoutModifier, InspectorValueInfo(inspectorInfo) {// Define layer parameters to skip reorganization and relayout when state changes occur
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)
}
}
......
}
Copy the code
Official example, we see the using Modifier. GraphicsLayer, it returns is SimpleGraphicsLayerModifier, on a placeable. After placeWithLayer method, The placeAt method of LayoutNodeWrapper is triggered
//androidx.compose.ui.node.LayoutNodeWrapper
override fun placeAt(
position: IntOffset,
zIndex: Float,
layerBlock: (GraphicsLayerScope. () - >Unit)? {
onLayerBlockUpdated(layerBlock)
......
}
Copy the code
OnLayerBlockUpdated (layerBlock)
//androidx.compose.ui.node.LayoutNodeWrapper
private fun updateLayerParameters(a){... layer.updateLayerProperties( scaleX = graphicsLayerScope.scaleX, scaleY = graphicsLayerScope.scaleY, alpha = graphicsLayerScope.alpha, translationX = graphicsLayerScope.translationX, translationY = graphicsLayerScope.translationY, ...... ) . }Copy the code
I tested it on Android10.0, so the layer here uses 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()
}
Copy the code
TransformOrigin = transformOrigin.Center. We see that the pivotX and pivotY centers in the RenderNode are calculated to always be half the width and height of the RenderNode
renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width renderNode.pivotY = transformOrigin.pivotFractionY * renderNode.heightCopy the code
We see high renderNode actual width is constant, center will not change, thought is a central problem, is actually a double refers to zoom mobile, rememberTransformableState callback offsetChange calculation has a problem, can’t achieve the result that we want, Let’s look at the Modifier. Transformable source code:
//androidx.compose.foundation.gestures.TransformableKt
fun Modifier.transformable(
state: TransformableState,
lockRotationOnZoomPan: Boolean = false,
enabled: Boolean = true
) = composed(
......
forEachGesture {
detectZoom(updatePanZoomLock, updatedState)
}
......
)
Copy the code
What’s happening in the detectZoom method
//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) } ...... }... }Copy the code
PastTouchSlop is true if pastTouchSlop is true PanMotion > touchSlop, if the conditions are not met, then the panChange value of this time will not be passed back, we do not use the panChange value here, next we will detect the gesture drag event, calculate the offset of movement, please continue to see 👇👇
4. Troubleshooting
After analyzing the content of the above, we can’t use rememberTransformableState callback offsetChange value, we need to calculate the Offset value, Then we need to use the Modifier. PointerInput to receive the data returned by the touch, PointerInputScope have an extension method can detect “level” and “vertical” at the same time the direction of the drag and drop the callback PointerInputScope. DetectDragGestures
If you only want to detect one direction, you can use the following two methods:
PointerInputScope.detectHorizontalDragGestures PointerInputScope.detectVerticalDragGestures
The onDrag callback returns the offset of “current position” – “previous position”. The final fix is as follows:
@Composable
fun TransformableExample(a) {
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
// Do not use offsetChange here
}
Box(
Modifier
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
// Return the correct movement position, following the finger
offset +=dragAmount
}
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y,
)
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}
Copy the code
The effect after repair
In addition, we can also use other ways to achieve, the following we provide you with other implementation source:
@Composable
fun TransformableExample2(a) {
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()
)
}
Copy the code
If you use Modifier scale/rotate/offset, note that the order is important:
1. If offset occurs before rotate, rotate affects the offset. The real effect is that when the drag gesture occurs, the component is offset by the current Angle. 2. If offset occurs before scale, scale also affects offset. The effect is that the UI component does not drag so when using Modifier, the recommended order of calls for offset, scale, and rotation is rotate -> scale -> offset
Previous articles recommended: 1. Practice the Jetpack SplashScreen API correctly — Summary for all Android systems Jetpack Compose processing “navigation bar, status bar, keyboard” affect content display problem collection 3.Android cross-process transmission thinking and implementation — attached principle analysis 4. 5.Jetpack Compose UI creation layout drawing process + principle – contains a detailed explanation of the concept (full of stuff) Startup How to use and working Principle 7. Source code analysis | ThreadedRenderer null pointer, the Choreographer meet 8, by the way. Source code analysis | events is how to the Activity? 9. CountDownLatch source code 10