Google Photos feel
Instant column reflow with breathing scale animation. Tuned thresholds, dead zones, and asymmetric pinch compensation — feels like a first-party app.

Every photo gallery app needs a pinch-to-resize grid. Google Photos, Samsung Gallery, Apple Photos — they all have it. But if you’re building with Compose Multiplatform, you’re on your own:
transformable conflicts with scroll — pinch fights with single-finger scrollingexpect/actual boilerplate for Android, iOS, Desktop, WebPinchGrid solves all of this in a single composable.
| DIY Approach | Problem |
|---|---|
Roll your own with transformable | Conflicts with LazyVerticalGrid scroll. Pinch and scroll fight each other. |
Roll your own with detectTransformGestures | Same scroll conflict. Also doesn’t cooperate with nested scrolling. |
| PinchGrid | Raw pointerInput with PointerEventPass.Initial — intercepts two-finger pinch before the grid’s scroll handler. Single-finger scroll passes through untouched. KMP from day one. |
dependencies { implementation("io.github.aldefy:pinch-grid:1.0.0-alpha02")}@Composablefun 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.
Google Photos feel
Instant column reflow with breathing scale animation. Tuned thresholds, dead zones, and asymmetric pinch compensation — feels like a first-party app.
Zero Material dependency
Built on Compose Foundation only. Works with Material 2, Material 3, or your own design system.
Multiplatform
Android, iOS, Desktop (JVM), and Web (Wasm) from a single API. True KMP.
Haptic feedback
Platform-native haptics on every column snap. Android CLOCK_TICK, iOS UISelectionFeedbackGenerator. No-op on Desktop/Web.
| Android | iOS | Desktop (JVM) | Web (Wasm) |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
@Composablefun PinchGrid( state: PinchGridState, modifier: Modifier = Modifier, gridState: LazyGridState = rememberLazyGridState(), contentPadding: PaddingValues = PaddingValues(), verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(0.dp), horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(0.dp), // Gesture tuning thresholdFraction: Float = PinchGridDefaults.ThresholdFraction, // 0.45f deadZone: Float = PinchGridDefaults.DeadZone, // 0.01f pinchOutThresholdMultiplier: Float = PinchGridDefaults.PinchOutThresholdMultiplier, // 0.85f // Visual feedback breathingScaleIntensity: Float = PinchGridDefaults.BreathingScaleIntensity, // 0.10f breathingReturnDuration: Int = PinchGridDefaults.BreathingReturnDuration, // 150ms hapticEnabled: Boolean = PinchGridDefaults.HapticEnabled, // true // Transition & control transitionSpec: ColumnTransitionSpec = PinchGridDefaults.TransitionSpec, // None gestureEnabled: Boolean = true, onColumnChanged: ((newCount: Int) -> Unit)? = null, content: LazyGridScope.() -> Unit,)Every parameter has a tuned default — override only what you need.
val state = rememberPinchGridState( initialColumnCount = 3, // start with 3 columns minColumns = 1, // full-width single item (zoom in limit) maxColumns = 5, // dense grid (zoom out limit))
// Read current statestate.columnCount // current column countstate.scaleProgress // 0f–1f, how close to next snapstate.isZoomingIn // true = spreading, false = pinching, null = idlestate.previousColumnCount // for transition animation
// Programmatic controlstate.snapToColumn(2) // change from code (buttons, keyboard, a11y)rememberPinchGridState uses rememberSaveable — column count survives configuration changes.
The gesture feel is highly configurable. Every parameter has a tuned default, but you can override any of them.
Controls how much pinch is needed to trigger a column change. Lower = more sensitive.
PinchGrid( state = state, thresholdFraction = 0.45f, // responsive but not accidental) { /* content */ }PinchGrid( state = state, thresholdFraction = 0.2f, // small pinch triggers change) { /* content */ }PinchGrid( state = state, thresholdFraction = 0.7f, // requires deliberate pinch) { /* content */ }| Parameter | Default | What it does |
|---|---|---|
thresholdFraction | 0.45f | Scale change needed to snap. Lower = more sensitive |
deadZone | 0.01f | Micro-movement filter. Prevents jitter from finger tremors |
pinchOutThresholdMultiplier | 0.85f | Makes pinch-out 15% easier than pinch-in |
breathingScaleIntensity | 0.10f | How much the grid scales during pinch. 0f = disabled |
breathingReturnDuration | 150 | Milliseconds to animate breathing back to 1.0 on release |
hapticEnabled | true | Toggle platform haptic feedback on column snap |
transitionSpec | None | None (instant) or Crossfade(durationMillis) |
gestureEnabled | true | Toggle pinch gesture (programmatic control still works) |
initialColumnCount | 3 | Starting columns (via state) |
minColumns | 1 | Zoom-in limit (via state) |
maxColumns | 5 | Zoom-out limit (via state) |
// Instant reflow, breathing, haptic — the defaultPinchGrid(state = state) { /* content */ }// Larger fingers need less movementPinchGrid( state = state, thresholdFraction = 0.25f, pinchOutThresholdMultiplier = 0.75f,) { /* content */ }// Pure column switching, no effectsPinchGrid( state = state, breathingScaleIntensity = 0f, hapticEnabled = false, transitionSpec = ColumnTransitionSpec.None,) { /* content */ }// Aggressive breathing + crossfadePinchGrid( state = state, breathingScaleIntensity = 0.20f, breathingReturnDuration = 300, transitionSpec = ColumnTransitionSpec.Crossfade(250),) { /* content */ }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.
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.
Google Photos style — instant column reflow, no animation. This is the default because it matches user expectations for photo grid apps.
PinchGrid( state = state, transitionSpec = ColumnTransitionSpec.None,) { /* content */ }Smooth opacity transition between old and new column layouts. Uses AnimatedContent with fadeIn/fadeOut internally.
PinchGrid( state = state, transitionSpec = ColumnTransitionSpec.Crossfade(durationMillis = 200),) { /* content */ }| Transition | Best for |
|---|---|
None | Photo galleries, media grids — instant feedback feels right |
Crossfade | Content grids, dashboards — smoother but slightly delayed |
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.
scaleProgress instantly (0ms tween)breathingReturnDuration (default 150ms)breathingScaleIntensity (default 0.10f = ±10%, set 0f to disable)You can use state.scaleProgress and state.isZoomingIn for custom item scaling:
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), )}Fires automatically on every column snap. Disable with hapticEnabled = false.
| Platform | Implementation |
|---|---|
| Android | View.performHapticFeedback(CLOCK_TICK) |
| iOS | UISelectionFeedbackGenerator.selectionChanged() |
| Desktop | No-op |
| Web | No-op |
Uses Kotlin expect/actual — the right implementation is selected at compile time.
For Desktop (no pinch), accessibility, or button-driven UIs:
val state = rememberPinchGridState()
// Zoom buttonsButton(onClick = { state.snapToColumn(state.columnCount - 1) }) { Text("Zoom In")}Button(onClick = { state.snapToColumn(state.columnCount + 1) }) { Text("Zoom Out")}
// Disable gesture, keep programmatic controlPinchGrid( state = state, gestureEnabled = false,) { /* content */ }snapToColumn respects minColumns/maxColumns, triggers haptic feedback, and fires onColumnChanged.
When the column count changes, the grid snapshots firstVisibleItemIndex before the change and restores it after. This prevents the jarring scroll-jump that happens with a naive GridCells.Fixed() swap.
For best results, provide stable key values:
items(photos, key = { it.id }) { photo -> /* ... */ }pointerInput instead of transformable?transformable is the obvious choice for pinch gestures, but it conflicts with LazyVerticalGrid scroll. Both compete for the same pointer events — a single-finger scroll gets intercepted as the start of a transform, causing the grid to freeze instead of scroll.
PinchGrid uses awaitEachGesture with awaitFirstDown and only consumes events when two or more pointers are detected. Single-finger scroll passes through completely untouched.
User touches screen └─ 1 finger → LazyVerticalGrid handles scroll (PinchGrid ignores) └─ 2 fingers → PinchGrid handles zoom (consumes events, scroll stops) └─ Fingers lift → PinchGrid resets, scroll resumesgraphicsLayer only — draw phase, zero recompositionsmutableIntStateOf update triggers grid recompositionLaunchedEffect + snapshotFlow — standard Compose patternThe included sample demonstrates all features with 50 picsum.photos images, a live FPS counter, and an interactive threshold tuning slider.
# Run on connected Android device./gradlew :sample:installDebugCopyright 2026 Adit Lal — Apache License 2.0