KMPMedia already clips a photo, GIF or video to any shape. 1.15.0 makes that shape interactive: grab it to drag, pinch to zoom, twist to rotate — then let go and it flings on with momentum and springs back inside its bounds. It's the interactivity half of Bet 4 — Interactivity primitives on the roadmap, promoted out of the demo app into one reusable modifier.
Everything lives in a new com.solidkey.painpoints.gesture package and is purely additive — nothing
existing changed, no new dependency, identical on Android & iOS.
val interaction = rememberOGInteractionState(OGInteractionConfig.PanZoom)
OGImageView(
source = photo,
clipShape = TriangleShape(TriangleDirection.UP),
modifier = Modifier
.size(240.dp)
.ogInteractive(interaction, hitArea = OGHitArea.TRIANGLE_UP),
)Modifier.ogInteractive(state, hitArea):
- Applies the state's pan / zoom / rotation through a single
graphicsLayer— a GPU-layer transform, so motion never triggers recomposition. - Drives that transform from a custom
awaitEachGestureloop — one- or two-finger drag pans, pinch zooms (clamped tominScale..maxScale), twist rotates — and on release carries the pan on with an exponential-decay momentum fling, then springs back inside the configured bounds.
If you don't need to read the transform yourself, the convenience overload remembers its own state:
Modifier.ogInteractive(OGInteractionConfig.All, hitArea = OGHitArea.CIRCLE)A clipped composable is still a rectangle. Without a hit area, a drag started in an empty corner
of a triangle still grabs it. Pass an OGHitArea and the gesture only starts if the finger lands
inside the silhouette; touches in the transparent corners are ignored and fall through to
whatever is behind.
.ogInteractive(state, hitArea = OGHitArea.of(OGShapeType.TRIANGLE_UP))
// or a free-form lasso, reusing the exact clip outline:
.ogInteractive(state, hitArea = OGHitArea.polygon(myLassoShape))OGHitArea is described entirely in normalized 0..1 coordinates and is pure data + pure math
(even-odd ray cast / analytic tests), so it is unit-tested with no Compose runtime — exactly like the
shape and depth maths. Built-ins mirror the OGShapeType vocabulary: RECT, CIRCLE,
TRIANGLE_UP, TRIANGLE_DOWN, DIAMOND, plus polygon(...) for a lasso and of(OGShapeType).
OGInteractionConfig toggles which gestures are live and tunes the physics. Three presets cover the
common cases:
| Preset | Pan | Zoom | Rotate |
|---|---|---|---|
DragOnly |
✅ | ❌ | ❌ |
PanZoom |
✅ | ✅ | ❌ |
All |
✅ | ✅ | ✅ |
Notable knobs:
minScale/maxScale— pinch-zoom clamp (default1f..5f).fling/flingFriction— momentum on release; higher friction stops the fling sooner.maxPanFractionX/maxPanFractionY— pan limit as a fraction of the content's own size (e.g.0.75f= three-quarters of a width each way from centre;Float.POSITIVE_INFINITY= unbounded). Because it's a fraction, the same config works at any resolution. The content springs back inside the limit on release.settleDampingRatio/settleStiffness— the spring that settles the pan back (lower damping = bouncier).
OGInteractionState exposes offset (pan px), scale and rotation as Animatables, so you can
read them for a HUD, drive other effects from them, or animate everything back to the resting
identity transform:
val o = state.offset.value // Offset, px
val s = state.scale.value // zoom factor
val r = state.rotation.value // degrees
scope.launch { state.reset() } // spring back to identity- The transform rides one
graphicsLayer(the same primitive the depth / animation code uses), so updating pan / zoom / rotation is a GPU-layer change with no recomposition. - The gesture loop is event-driven — nothing runs per frame.
- The hit-test is one pure ray-cast on the down event only.
- Fling and settle are plain Compose
Animatableanimations (exponentialDecay+spring).
One subtlety worth calling out: the pan / zoom / rotation are accumulated synchronously inside
the gesture loop and snapped to absolute values. A naive per-event snapTo(value + delta) races
under a fast fling — concurrent snap coroutines read a stale value and lose deltas (a fast right-fling
came out under-counted during development). Accumulating the running totals in the loop and snapping
absolutes fixes it.
A second subtlety, for the same reason the gesture is fast — it runs inside the transformed
graphicsLayer — is that calculatePan() reports the drag in the content's own rotated + scaled
frame, while translationX/Y move the layer in its parent (screen) frame. Applied raw, a drag on a
rotated layer drifts off at an angle (and lags the finger when zoomed). So the pan delta and the fling
velocity are mapped back to parent space (scale · R(rotation) · delta) before they're applied — a
drag follows the finger at any rotation / zoom. The shape-aware down-test stays in the local frame on
purpose: the clip shape lives in that same frame, so that's the correct space to hit-test against.
- Pure maths unit-tested on JVM + iOS (16 tests): the
OGHitArearay-cast (inside / on-edge / transparent-corner for circle, triangles, diamond and a lasso) and the scale / pan clamps. - Live-verified on Android: drag in all directions, pan bounds + spring settle clamping to the configured limit, and shape-aware grab/ignore (swipe an empty corner of a triangle → ignored; swipe inside → grabs).
The gesture-driving code is Compose UI (one file), the maths and the state model are shared
commonMain.