Skip to content

ComposePinchGrid

A Google Photos-style pinch-to-resize grid built on Compose Foundation. Pinch to change column count with haptic feedback, breathing scale, and smooth transitions. No Material dependency.

Demo

ComposePinchGrid demo — pinch to resize grid columns on Android


The Problem

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:

  • LazyVerticalGrid has no built-in pinch gesture
  • transformable conflicts with scroll — pinch fights with single-finger scrolling
  • Platform haptics require expect/actual boilerplate for Android, iOS, Desktop, Web
  • Scroll position jumps when column count changes mid-scroll
  • Gesture tuning — dead zones, asymmetric thresholds, breathing animations — is surprisingly hard to get right

PinchGrid solves all of this in a single composable.

What makes PinchGrid different

DIY ApproachProblem
Roll your own with transformableConflicts with LazyVerticalGrid scroll. Pinch and scroll fight each other.
Roll your own with detectTransformGesturesSame scroll conflict. Also doesn’t cooperate with nested scrolling.
PinchGridRaw pointerInput with PointerEventPass.Initial — intercepts two-finger pinch before the grid’s scroll handler. Single-finger scroll passes through untouched. KMP from day one.

Quick Start

Install

build.gradle.kts
dependencies {
implementation("io.github.aldefy:pinch-grid:1.0.0-alpha02")
}

Use

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


Why PinchGrid?

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.

AndroidiOSDesktop (JVM)Web (Wasm)

Full API

@Composable
fun 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.


State

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 state
state.columnCount // current column count
state.scaleProgress // 0f–1f, how close to next snap
state.isZoomingIn // true = spreading, false = pinching, null = idle
state.previousColumnCount // for transition animation
// Programmatic control
state.snapToColumn(2) // change from code (buttons, keyboard, a11y)

rememberPinchGridState uses rememberSaveable — column count survives configuration changes.


Gesture Configuration

The gesture feel is highly configurable. Every parameter has a tuned default, but you can override any of them.

Threshold Fraction

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 */ }

All Configurable Parameters

ParameterDefaultWhat it does
thresholdFraction0.45fScale change needed to snap. Lower = more sensitive
deadZone0.01fMicro-movement filter. Prevents jitter from finger tremors
pinchOutThresholdMultiplier0.85fMakes pinch-out 15% easier than pinch-in
breathingScaleIntensity0.10fHow much the grid scales during pinch. 0f = disabled
breathingReturnDuration150Milliseconds to animate breathing back to 1.0 on release
hapticEnabledtrueToggle platform haptic feedback on column snap
transitionSpecNoneNone (instant) or Crossfade(durationMillis)
gestureEnabledtrueToggle pinch gesture (programmatic control still works)
initialColumnCount3Starting columns (via state)
minColumns1Zoom-in limit (via state)
maxColumns5Zoom-out limit (via state)

Configuration Presets

// Instant reflow, breathing, haptic — the default
PinchGrid(state = state) { /* content */ }

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

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 */ }
TransitionBest for
NonePhoto galleries, media grids — instant feedback feels right
CrossfadeContent grids, dashboards — smoother but slightly delayed

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 graphicsLayerzero recompositions, pure GPU transform at 60fps.

  • During gesture: scale tracks scaleProgress instantly (0ms tween)
  • On release: animates back to 1.0 over breathingReturnDuration (default 150ms)
  • Control intensity: breathingScaleIntensity (default 0.10f = ±10%, set 0f to disable)

Custom per-item transforms

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),
)
}

Haptic Feedback

Fires automatically on every column snap. Disable with hapticEnabled = false.

PlatformImplementation
AndroidView.performHapticFeedback(CLOCK_TICK)
iOSUISelectionFeedbackGenerator.selectionChanged()
DesktopNo-op
WebNo-op

Uses Kotlin expect/actual — the right implementation is selected at compile time.


Programmatic Control

For Desktop (no pinch), accessibility, or button-driven UIs:

val state = rememberPinchGridState()
// Zoom buttons
Button(onClick = { state.snapToColumn(state.columnCount - 1) }) {
Text("Zoom In")
}
Button(onClick = { state.snapToColumn(state.columnCount + 1) }) {
Text("Zoom Out")
}
// Disable gesture, keep programmatic control
PinchGrid(
state = state,
gestureEnabled = false,
) { /* content */ }

snapToColumn respects minColumns/maxColumns, triggers haptic feedback, and fires onColumnChanged.


Scroll Position Preservation

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 -> /* ... */ }

Architecture

Why raw 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 resumes

Performance

  • Breathing scale: graphicsLayer only — draw phase, zero recompositions
  • Column change: single mutableIntStateOf update triggers grid recomposition
  • Haptic: fires inline, no coroutine overhead
  • Scroll restore: LaunchedEffect + snapshotFlow — standard Compose pattern

Sample App

The included sample demonstrates all features with 50 picsum.photos images, a live FPS counter, and an interactive threshold tuning slider.

Terminal window
# Run on connected Android device
./gradlew :sample:installDebug

License

Copyright 2026 Adit Lal — Apache License 2.0