compose gestures, zoom in zoom out move and rotation

1.6k Views Asked by At

I hope to drag using single touch and multi touch. And I also hope to my image rotate and zoom in zoom out. I write this code, however, it didn't follow exactly my finger touch and verbose. Most over, it throws null point error sometimes. How can I modify my code to solve this issue.

simplify code. prevent null point error. follow my finger naturally.

Thanks for reading.

@Composable
fun DraggableView(

) {

    // set up all transformation states
    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
        offset += offsetChange
    }

    Box(
        modifier = Modifier
            .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)
            .fillMaxSize()
    ) {
        var offsetX by remember { mutableStateOf(0f) }
        var offsetY by remember { mutableStateOf(0f) }




//        val coroutineScope = rememberCoroutineScope()
        Box(
            Modifier
                .offset { IntOffset((offsetX * scale).roundToInt(), (offsetY * scale).roundToInt()) }

                .background( Color.Blue)
                .size(300.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consumeAllChanges()
                        offsetX += dragAmount.x //* scale
                        offsetY += dragAmount.y //* scale
                    }
                }
        ) {

            val image = loadPicture().value


            image?.let { img ->
                Image(
                    bitmap = img.asImageBitmap(),
                    contentDescription = "content",
                    modifier = Modifier
                        .fillMaxWidth(),
                    contentScale = ContentScale.Crop
                )
            }

        }
    }
}
2

There are 2 best solutions below

0
On

Image zoom, pan and rotation can be done way simpler and robust way using detectTransformGestures

By using this sample code from official page you will have natural zoom which will be invoked from centroid(center of pointers) instead of center of screen

var zoom by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var angle by remember { mutableStateOf(0f) }

val imageModifier = Modifier
    .fillMaxSize()
    .pointerInput(Unit) {
        detectTransformGestures(
            onGesture = { gestureCentroid, gesturePan, gestureZoom, gestureRotate ->
                val oldScale = zoom
                val newScale = (zoom * gestureZoom).coerceIn(0.5f..5f)

                // For natural zooming and rotating, the centroid of the gesture should
                // be the fixed point where zooming and rotating occurs.
                // We compute where the centroid was (in the pre-transformed coordinate
                // space), and then compute where it will be after this delta.
                // We then compute what the new offset should be to keep the centroid
                // visually stationary for rotating and zooming, and also apply the pan.
                offset = (offset + gestureCentroid / oldScale).rotateBy(gestureRotate) -
                        (gestureCentroid / newScale + gesturePan / oldScale)
                angle += gestureRotate
                zoom = newScale
            }
        )
    }
 
    .graphicsLayer {
        translationX = -offset.x * zoom
        translationY = -offset.y * zoom
        scaleX = zoom
        scaleY = zoom
        rotationZ = angle
        TransformOrigin(0f, 0f).also { transformOrigin = it }
    }

Rotate function

/**
 * Rotates the given offset around the origin by the given angle in degrees.
 *
 * A positive angle indicates a counterclockwise rotation around the right-handed 2D Cartesian
 * coordinate system.
 *
 * See: [Rotation matrix](https://en.wikipedia.org/wiki/Rotation_matrix)
 */
fun Offset.rotateBy(angle: Float): Offset {
    val angleInRadians = angle * PI / 180
    return Offset(
        (x * cos(angleInRadians) - y * sin(angleInRadians)).toFloat(),
        (x * sin(angleInRadians) + y * cos(angleInRadians)).toFloat()
    )
}

And apply this modifier to an image to zoom, pan and rotate

0
On

detectTransformGestures is simple, but far from perfect. At least, it doesn't support single touch (although you can put down two fingers and then pick up one to trigger the transformation).

This is my implementation of zoomable, from my app. This is used to make the Images inside the Pager zoomable. It's still WIP because it is not aware of the size of the image, so the boundary size is incorrect. But other than that there should be no problem.

/* Zoom logic */
private const val maxScale = 3.0f
private const val midScale = 1.5f
private const val minScale = 1.0f

private fun Modifier.zoomable(
    onLongPress: (PointerInputScope.(Offset) -> Unit) = {},
    onTap: (PointerInputScope.(Offset) -> Unit) = {},
): Modifier = composed {
    val scope = rememberCoroutineScope()
    val scale = remember { Animatable(1f) }
    val translation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
    this
        .clipToBounds()
        .pointerInput(Unit) {
            val decay = splineBasedDecay<Offset>(this)
            customDetectTransformGestures(
                onGesture = { centroid, pan, zoom ->
                    val targetScale = (scale.value * zoom).coerceIn(minScale, maxScale)

                    val realZoom = targetScale / scale.value
                    val center = size.toSize().center
                    val targetTranslation =
                        translation.value * realZoom - (centroid - center) * (realZoom - 1) + pan

                    val bound = center * (targetScale - 1f)
                    translation.updateBounds(-bound, bound)

                    runBlocking {
                        scale.snapTo(targetScale)
                        translation.snapTo(targetTranslation)
                    }

                    targetTranslation.x > -bound.x && targetTranslation.x < bound.x
                },
                onFling = { velocity ->
                    scope.launch {
                        translation.animateDecay(Offset(velocity.x, velocity.y), decay)
                    }
                },
            )
        }
        .pointerInput(Unit) {
            detectTapGestures(
                onDoubleTap = { centroid ->
                    val targetScale = when {
                        scale.value >= maxScale - 1e-4f -> minScale
                        scale.value >= midScale - 1e-4f -> maxScale
                        scale.value >= minScale - 1e-4f -> midScale
                        else -> minScale
                    }

                    val realZoom = targetScale / scale.value
                    val center = size.toSize().center
                    val targetTranslation =
                        translation.value * realZoom - (centroid - center) * (realZoom - 1)

                    val bound = center * (targetScale - 1f)
                    translation.updateBounds(-bound, bound)

                    scope.launch {
                        scale.animateTo(targetScale)
                    }
                    scope.launch {
                        translation.animateTo(targetTranslation)
                    }
                },
                onLongPress = { onLongPress(it) },
                onTap = { onTap(it) },
            )
        }
        .graphicsLayer(
            scaleX = scale.value,
            scaleY = scale.value,
            translationX = translation.value.x,
            translationY = translation.value.y,
        )
}

private suspend fun PointerInputScope.customDetectTransformGestures(
    onGesture: (centroid: Offset, pan: Offset, zoom: Float) -> Boolean,
    onFling: (velocity: Velocity) -> Unit = {},
) {
    forEachGesture {
        awaitPointerEventScope {
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var isFirstOnGesture = true

            val velocityTracker = VelocityTracker()
            var shouldStartFling = true

            awaitFirstDown(requireUnconsumed = false)
            do {
                val event = awaitPointerEvent()
                val canceled = event.changes.any { it.isConsumed }
                if (!canceled) {
                    val zoomChange = event.calculateZoom()
                    val panChange = event.calculatePan()

                    if (!pastTouchSlop) {
                        zoom *= zoomChange
                        pan += panChange

                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize
                        val panMotion = pan.getDistance()

                        if (zoomMotion > touchSlop ||
                            panMotion > touchSlop
                        ) {
                            pastTouchSlop = true
                        }
                    }

                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        if (event.changes.size >= 2) {
                            velocityTracker.resetTracking()
                        } else if (centroid.isSpecified) {
                            val change = event.changes.firstOrNull()
                            if (change?.pressed == true) {
                                velocityTracker.addPosition(
                                    change.uptimeMillis,
                                    centroid,
                                )
                            }
                        }
                        if (
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            val inBound = onGesture(centroid, panChange, zoomChange)
                            if (isFirstOnGesture && !inBound && zoomChange == 1f) {
                                shouldStartFling = false
                                break
                            }
                            isFirstOnGesture = false
                        }
                        event.changes.forEach {
                            if (it.positionChanged()) {
                                it.consume()
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.any { it.pressed })

            if (shouldStartFling) {
                val velocity = velocityTracker.calculateVelocity()
                onFling(velocity)
            }
        }
    }
}