skip to Main Content

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

Answers


  1. 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

    Login or Signup to reply.
  2. 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)
                }
            }
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search