0

I have a ZoomableLayout which allows its contents to be zoomed and panned. In this layout, I wish to add a container which holds multiple children that can be panned (children do not need to be zoomed). The zooming and panning of the container works fine. In fact, the panning of the children also works when the container is not zoomed (i.e. scale = 1). The issue occurs when the container is fully zoomed and I try to pan the children.

Zoom pan demo

I have a feeling it might be due to graphicsLayer only changing the visual position of the composable without changing the actual position but I couldn't get it to work.

Things I've tried:

  • Using Modifier.layout for zoom and pan the container: Couldn't really get this to work because both the content and children were not resizing properly
  • Using padding instead of offset to pan the children (this doesn't work because I require negative offsets to not fail e.g. when children are panned out of the container)

Here's my current code:

ZoomableLayout

@Composable
fun ZoomableLayout(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit,
) {

    val offsetState = remember { mutableStateOf(Offset.Zero) }
    val zoomState = remember { mutableStateOf(1f) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTransformGestures { centroid, pan, gestureZoom, _ ->
                    // Handle panning and zooming to centroid
                    handleGestures(zoomState, offsetState, centroid, pan, gestureZoom)
                }
            }
            .then(modifier),
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .align(Alignment.Center)
                .graphicsLayer(
                    // Update content's pan and zoom
                    transformOrigin = TransformOrigin(0f, 0f),
                    scaleX = zoomState.value,
                    scaleY = zoomState.value,
                    translationX = -offsetState.value.x * zoomState.value,
                    translationY = -offsetState.value.y * zoomState.value,
                ),
            contentAlignment = Alignment.Center
        ) {
            content()
        }
    }
}

private fun handleGestures(
    zoomState: MutableState<Float>,
    offsetState: MutableState<Offset>,
    centroid: Offset,
    pan: Offset,
    gestureZoom: Float
) {
    val oldZoom = zoomState.value
    val offset = offsetState.value

    // Limit to a certain min and max zoom
    val newZoom = (oldZoom * gestureZoom).coerceIn(0.8f, 2f)
    // Zoom to the centroid of the gesture instead of the center of the composable
    val newOffset = (offset + centroid / oldZoom) - (centroid / newZoom + pan / oldZoom)

    offsetState.value = newOffset
    zoomState.value = newZoom
}

Content with Children

@Composable
fun ContentWithChildren() {
    val offset1 = remember {
        mutableStateOf(IntOffset(x = 50, 100))
    }

    val offset2 = remember {
        mutableStateOf(IntOffset(x = 200, y = 100))
    }

    Surface(
        modifier = Modifier
            .width(400.dp)
            .height(200.dp),
        color = Color.LightGray
    ) {
        Box() {
            Child(offsetState = offset1, color = Color.Green)
            Child(offsetState = offset2, color = Color.Blue)
        }
    }
}

@Composable
fun Child(offsetState: MutableState<IntOffset>, color: Color) {
    Surface(
        modifier = Modifier
            .size(50.dp)
            .offset { offsetState.value }
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    offsetState.value += IntOffset(dragAmount.x.toInt(), dragAmount.y.toInt())
                }
            },
        color = color
    ) { }
}

Usage

@Preview
@Composable
fun ZoomScrollComposable() {
    ZoomableLayout {
        ContentWithChildren()
    }
}
Wilfred
  • 9
  • 1
  • 5
  • Order of modifiers matter. Offset and graphicsLayer does not change where a Composable is laid out. It changes interaction pointer after those modifiers. Touch bounds, border, background, etc moves as you change offset but position of a Composable against siblings remain where it's laid out. You can refer this question more details. https://stackoverflow.com/questions/72922205/how-to-have-natural-pan-and-zoom-with-modifier-graphicslayer-pointerinput – Thracian Jun 01 '23 at 09:21
  • Not tried this with compose yet, but changing the scale does not change the position of input events, in a custom layout with zoom in the old view system I had to modify the input events before passing them on to the children to adjust for pan and scale at the graphics level. (so you will probably find a location on screen which is more upwards and leftwards where the children were originally drawn pre zoom that you can touch that will move your children) – Andrew Jun 01 '23 at 10:52
  • @Andrew in jetpack compose depending on which modifier is first it does. You can check the image at the bottom in the link i shared. If graphicsLayer is before gesture scale you apply to composable also applies to drag amount as well. moving your finger 10px moves Composable 20px when scale is 2. And when you translate it also translate interaction/gesture bounds as well. If you can easily verify this by adding a border before Modifier.graphicsLayer and one after that to see how order changes where interaction position moves to. It also applies to offset as well. – Thracian Jun 01 '23 at 13:35
  • If you set offset or translation a Composables click/gesture bounds change. You can also verify this by adding 2 clickables, one before and another after setting some offset or translationX/Y with graphicsLayer. Yet, offset or graphicsLayer do not change a Composable's position relative to other siblings. But padding does. Adding padding to a Composable pushes other siblings unlike offset or graphicsLayer – Thracian Jun 01 '23 at 13:39
  • @Thracian offset and translation might but `scale` does not affect the click/gesture bound as this only affects the draw layer. So if you draw an item a for example the coordinates 1,1 (ignoring the size of the object for simplicity) and change the scale only so that it is drawn at 2,2 the click bounds are still at 1,1. So if you click at 2,2 then your click won't be sent to the object. – Andrew Jun 01 '23 at 16:55
  • It doesn't work like that. You should test it out, i posted a reproducible example, how order of modifiers effect where touch positions are moved, rotated and scaled. In Jetpack Compose if Modifier.graphicsLayer is before pointerInput touch area is translated, rotated and scaled accordingly. If you scale a 100x100px composable with scale of 2 then touchable region is 200x200px, but when you touch (200,200) position the offset you get is (100,100). Rotation, scale, and translation changes touch area. If it was same was View system the question i posted would have been simpler. – Thracian Jun 01 '23 at 17:55
  • I also have a tutorial here about graphicsLayer that shows how it works. https://github.com/SmartToolFactory/Jetpack-Compose-Tutorials/blob/master/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_1_3OffsetGraphicsLayer.kt – Thracian Jun 01 '23 at 17:56
  • @Thracian Thanks for the reply. I've seen the post that you linked. My use case seems to be slightly different. The zooming functionality of the ZoomableLayout works so there's no issue with that. But even though my pointerInput() is placed after offset() in the Child, the panning doesn't work when the parent container is scaled. – Wilfred Jun 02 '23 at 01:57
  • @Andrew Thanks for your reply as well. I've done something similar in the old view system and had to convert the coordinates of the view like you mentioned. Just not exactly sure how this works in Jetpack Compose. – Wilfred Jun 02 '23 at 01:59

0 Answers0