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.
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()
}
}