ComposePinchGrid
A Google Photos-style pinch-to-resize grid for Compose Multiplatform. Pinch to change column count with haptic feedback, breathing scale animation, and smooth transitions. Built on Compose Foundation — no Material dependency.

| Android | iOS | Desktop (JVM) | Web (Wasm) |
|---|
| ✓ | ✓ | ✓ | ✓ |
Demo
Full quality video: assets/demo.mp4
Installation
dependencies {
implementation("io.github.aldefy:pinch-grid:1.0.0-alpha02")
}
Quick Start
@Composable
fun PhotoGrid(photos: List<Photo>) {
val state = rememberPinchGridState()
PinchGrid(state = state) {
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = photo.url,
modifier = Modifier.aspectRatio(1f),
contentScale = ContentScale.Crop,
)
}
}
}
That's it. Pinch to resize, haptic on snap, scroll position preserved.
API
PinchGrid
Every parameter has a tuned default — override only what you need.
State
val state = rememberPinchGridState(
initialColumnCount = 3,
minColumns = 1,
maxColumns = 5,
)
state.columnCount
state.scaleProgress
state.isZoomingIn
state.previousColumnCount
state.snapToColumn()
Gesture Configuration
The gesture feel is highly configurable. All parameters have tuned defaults:
Threshold Fraction
Controls how much pinch is needed to trigger a column change. Lower = more sensitive.
PinchGrid(
state = state,
thresholdFraction = 0.45f,
) { }
Defaults Reference
Configuration Examples
PinchGrid(state = state) { }
PinchGrid(
state = state,
thresholdFraction = 0.25f,
pinchOutThresholdMultiplier = 0.75f,
) { }
PinchGrid(
state = state,
breathingScaleIntensity = ,
hapticEnabled = ,
transitionSpec = ColumnTransitionSpec.None,
) { }
PinchGrid(
state = state,
breathingScaleIntensity = ,
breathingReturnDuration = ,
transitionSpec = ColumnTransitionSpec.Crossfade(durationMillis = ),
) { }
Asymmetric Thresholds
Pinch-out (spreading fingers) naturally produces less scale change than pinch-in. The PinchOutThresholdMultiplier compensates — at 0.85f, zooming in requires 15% less finger movement than zooming out, making both directions feel equally responsive.
Dead Zone
The 0.01f dead zone filters micro-movements. Without it, tiny finger tremors while holding a pinch cause the grid to jitter. You shouldn't need to change this.
Transition Specs
PinchGrid(
state = state,
transitionSpec = ColumnTransitionSpec.None,
) { }
PinchGrid(
state = state,
transitionSpec = ColumnTransitionSpec.Crossfade(durationMillis = 200),
) { }
Breathing Scale
During a pinch gesture, the grid subtly scales up (zooming in) or down (zooming out) following your fingers. This provides real-time visual feedback before the column count snaps. The effect uses graphicsLayer — zero recompositions, pure GPU transform at 60fps.
Control via breathingScaleIntensity (default 0.10f = ±10% scale, 0f = disabled) and breathingReturnDuration (default 150ms).
You can use state.scaleProgress and state.isZoomingIn to apply custom per-item transforms:
items(photos, key = { it.id }) { photo ->
val itemScale = when (state.isZoomingIn) {
true -> 1f + (state.scaleProgress * 0.1f)
false -> 1f - (state.scaleProgress * 0.1f)
null -> 1f
}
AsyncImage(
model = photo.url,
modifier = Modifier
.graphicsLayer { scaleX = itemScale; scaleY = itemScale }
.aspectRatio(1f),
)
}
Haptic Feedback
Fires automatically on every column snap. Disable with hapticEnabled = false.
| Platform | Implementation |
|---|
| Android | View.performHapticFeedback(CLOCK_TICK) |
Programmatic Control
val state = rememberPinchGridState()
Button(onClick = { state.snapToColumn(state.columnCount - 1) }) { Text("Zoom In") }
Button(onClick = { state.snapToColumn(state.columnCount + 1) }) { Text("Zoom Out") }
PinchGrid(
state = state,
onColumnChanged = { newCount -> analytics.log("columns_changed", newCount) },
) { }
Scroll Position Preservation
When the column count changes, the grid maintains the user's scroll position by snapshotting firstVisibleItemIndex before the change and restoring it after. For best results, provide stable key values to your items:
items(photos, key = { it.id }) { photo -> }
Sample App
The included sample app demonstrates all features with 50 random photos, a live FPS counter, and an interactive threshold tuning slider.
./gradlew :sample:installDebug
./gradlew :sample:run
Building
./gradlew :pinch-grid:build
./gradlew :pinch-grid:apiDump
./gradlew :pinch-grid:publishAllPublicationsToLocalStagingRepository \
-Psigning.gnupg.keyName=F30A3C2E
License
Copyright 2026 Adit Lal
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https:
Unless applicable law agreed to writing, software
distributed under the License distributed an BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express implied.
See the License the specific language governing permissions
limitations under the License.