Soap Bubble Drag

An iridescent soap bubble that follows your finger with stretch and squash, rendered live by an AGSL thin film interference shader on top of a kinematic spring.

Compose APIs
AGSL RuntimeShadergraphicsLayerAnimatablewithFrameNanosspringpointerInput
Try tweaking
LOOK_PRESETMAX_ORB_RADIUS_DPMIN_ORB_RADIUS_DPSPRING_STIFFNESSSPRING_DAMPINGDEFORMATION_FACTORPOP_DURATION_MSTHICKNESS_BASE / COLOR_INTENSITY / EDGE_FADE_END (AGSL)

Most Compose animations move pixels. This one moves light. A soap bubble drag combines three layers of motion at once: a kinematic spring that drags the bubble around the card, a velocity driven stretch and squash that deforms its silhouette, and an AGSL RuntimeShader that paints thin film interference across the bubble surface so the rainbow follows the deformation in real time. The result reads as physical because every layer reacts to the same input, and the surface optics happen on the GPU at frame rate rather than as a baked image.

In this article, you'll explore how pointerInput drives an Animatable<Offset> for the bubble position, how a withFrameNanos loop produces a smoothed velocity that becomes a deformation vector, how graphicsLayer { renderEffect = ... } injects an AGSL shader that reads the deformation per frame, and how a LOOK_PRESET flag plus a few animateFloatAsState declarations let you toggle between soap, neon, and slime looks with a single edit at the top of the file.

The original soap bubble physics and AGSL thin film shader are by Kyriakos Georgiopoulos. This adaptation hoists every tunable into local vals and keys the shader source into remember(SHADER_SRC) so HotSwan literal patching makes the whole sample hot reloadable.

How the example is structured

The composable lives in AnimationExample22.kt as a single function, AnimationExample22. On screen, you see a rounded card with a bubble resting at the bottom. Drag it upward and it stretches as it accelerates and squashes as it slows. Release before the top and it springs back. Drag past the top and it locks into place, swapping the title text from "Drag the bubble." to "Thin film". Tap the bubble and it pops. A small sun and moon button in the corner toggles a circular reveal between light and dark themes that the shader instantly recolors against.

The composable starts with one flag at the very top:

val LOOK_PRESET = 3

That single integer selects one of seven preset look tables — soap, magenta, slime, psychedelic, ghost, fire, cyber. The Kotlin side then unpacks the row into five floats that are passed to the shader as uniforms:

val lookValues = remember(LOOK_PRESET) {
  when (LOOK_PRESET) {
    1 -> floatArrayOf(0.0f, 1.0f, 0.0f, 0.8f, 0.0f)
    2 -> floatArrayOf(0.05f, 0.2f, 1.0f, 0.4f, 0.0f)
    3 -> floatArrayOf(1.0f, 0.45f, 0.75f, 1.0f, 2.5f)
    // ...
  }
}

The columns are interferenceAmount, baseTintR, baseTintG, baseTintB, hueShift. remember(LOOK_PRESET) rebuilds the table only when the flag changes, and the per channel animateFloatAsState calls below ease between the old and new values so toggling presets feels like the bubble is morphing through colors rather than snapping to a new look.

Below the preset, every other tunable is a named local val. Card height, orb radii, snap thresholds, spring stiffness, deformation clamps, pop timings, theme palette colors, and the entire AGSL SHADER_SRC string sit at the top of the function. Placing them there is deliberate. With Compose Hot Reload, editing any of those values, saving, and watching the bubble re-render takes less than a second, and the bubble keeps its current drag position because the state holders survive the recomposition.

Driving the bubble with an Animatable

The bubble position is an Animatable<Offset> stored in a small BubbleState class:

val bubblePos = Animatable(Offset(centerX, bottomOrbCenterY), Offset.VectorConverter)
val deformationAnim = Animatable(Offset.Zero, Offset.VectorConverter)
val popAnim = Animatable(0f)
val themeRevealProgress = Animatable(1f)

Offset.VectorConverter is the bridge that lets Animatable interpolate a 2D position with the same spring and tween machinery the rest of the animation system uses. When you call bubblePos.animateTo(target, spring), the Animatable runs a coroutine that produces a new Offset each frame until the spring settles, and reads on bubblePos.value recompose any composable that consumes them.

Drag is wired through a Modifier.pointerInput block that uses detectDragGestures. On every drag delta the input either snaps the bubble to the new position freely (after the user has unlocked it by reaching the top) or constrains it to the vertical track (before unlock):

detectDragGestures(
  onDragStart = { isUnlocked = state.isAtTop(snapUnlockThresholdPx) },
  onDragEnd = {
    scope.launch {
      val targetY = if (state.bubblePos.value.y < state.midPoint) {
        state.topOrbCenterY
      } else {
        state.bottomOrbCenterY
      }
      state.bubblePos.animateTo(Offset(state.centerX, targetY), SnapBackSpring)
    }
  },
) { change, dragAmount ->
  change.consume()
  // ... snapTo or animateTo depending on isUnlocked
}

Two springs do the snapping work:

private val SnapBackSpring = spring<Offset>(
  dampingRatio = 0.65f,
  stiffness = Spring.StiffnessLow,
)

private val UnlockedSnapSpring = spring<Offset>(
  dampingRatio = 0.45f,
  stiffness = Spring.StiffnessLow,
)

SnapBackSpring returns the bubble to whichever orb is closer with a slightly damped settle. UnlockedSnapSpring has lower damping, so once the bubble is free it carries a bit more bounce when it docks. The dampingRatio difference is small but easy to feel.

Velocity, stretch, and squash on a frame loop

A spring that moves a position is not by itself enough to make the bubble feel alive. The deformation comes from a separate state, deformationAnim, that tracks where the bubble should bulge based on how fast it is moving. That value is computed inside a LaunchedEffect that runs withFrameNanos in a tight loop:

LaunchedEffect(state, deformationFactor, deformationClamp, velocitySmoothing, stiffness, damping) {
  var previousActualPos = state.bubblePos.value
  val startTime = withFrameNanos { it }
  var lastFrameTime = startTime
  var smoothedVelocity = Offset.Zero
  var defVelocity = Offset.Zero

  while (true) {
    val frameTime = withFrameNanos { it }
    val dt = ((frameTime - lastFrameTime) / 1_000_000_000f).coerceAtMost(0.032f)
    lastFrameTime = frameTime

    state.shaderTime[0] = (frameTime - startTime) / 1_000_000_000f

    val rawVelocity = state.bubblePos.value - previousActualPos
    smoothedVelocity = Offset(
      x = smoothedVelocity.x + (rawVelocity.x - smoothedVelocity.x) * velocitySmoothing,
      y = smoothedVelocity.y + (rawVelocity.y - smoothedVelocity.y) * velocitySmoothing,
    )

    val targetDeformation = Offset(
      x = (smoothedVelocity.x * deformationFactor).coerceIn(-deformationClamp, deformationClamp),
      y = (smoothedVelocity.y * deformationFactor).coerceIn(-deformationClamp, deformationClamp),
    )

    val currentDef = state.deformationAnim.value
    val forceX = (targetDeformation.x - currentDef.x) * stiffness - defVelocity.x * damping
    val forceY = (targetDeformation.y - currentDef.y) * stiffness - defVelocity.y * damping
    defVelocity = Offset(defVelocity.x + forceX * dt, defVelocity.y + forceY * dt)
    val nextDef = Offset(currentDef.x + defVelocity.x * dt, currentDef.y + defVelocity.y * dt)

    state.deformationAnim.snapTo(nextDef)
    previousActualPos = state.bubblePos.value
  }
}

A few things to notice. First, withFrameNanos returns the choreographer frame time in nanoseconds, so dt is the real time elapsed since the previous frame, clamped to 32 ms so a long frame does not produce a violent jump. Second, the raw per frame velocity is filtered through an exponential smoothing pass with velocitySmoothing as the coefficient. Without that filter the deformation jitters because pointer input is noisy. Third, the deformation itself is run through a simple mass spring damper. stiffness pulls toward the target, damping removes energy, and dt integrates them. The shader reads state.deformationAnim.value per frame, so what the eye sees is a soft delayed version of the bubble's velocity, which is what gives the bubble its rubber feel.

The same loop also advances state.shaderTime[0]. The AGSL shader uses that time as the input to a noise function, so the iridescent swirls drift slowly even when the bubble is at rest.

The AGSL thin film shader

On Android 13 (Tiramisu, API 33) and above, Compose can attach an AGSL RuntimeShader to a layer through graphicsLayer { renderEffect = ... }. AGSL is a SkSL dialect: GLSL-like syntax, restricted to fragment shaders that read from one or more bound composable samplers. The trick that makes the bubble work is that the shader receives the rest of the screen as a sampler called composable, so it can refract and reflect the actual UI underneath:

private fun Modifier.bubbleShaderLayer(
  state: BubbleState,
  shader: RuntimeShader?,
  maxRadiusPx: Float,
  minRadiusPx: Float,
  // ... animated look uniforms
): Modifier = graphicsLayer {
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) {
    shader.setFloatUniform("touchCenter", state.bubblePos.value.x, state.bubblePos.value.y)
    shader.setFloatUniform("radius", state.radiusFor(maxRadiusPx, minRadiusPx))
    shader.setFloatUniform("progress", state.progress)
    shader.setFloatUniform("deformation", state.deformationAnim.value.x, state.deformationAnim.value.y)
    shader.setFloatUniform("popProgress", state.popAnim.value)
    shader.setFloatUniform("sysTime", state.shaderTime[0])
    shader.setFloatUniform("interferenceAmount", interferenceAmount.value)
    shader.setFloatUniform("baseTint", tintR.value, tintG.value, tintB.value)
    shader.setFloatUniform("hueShift", hueShift.value)

    renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "composable")
      .asComposeRenderEffect()
  }
}

Reading state.bubblePos.value, state.deformationAnim.value, and state.shaderTime[0] inside graphicsLayer ties the layer's invalidation to those state objects, so the shader uniforms refresh on every animation tick. Outside the bubble's radius, the shader returns the unmodified background. Inside, it does several things at once.

It bends UV coordinates along the deformation axis to stretch the bubble. It computes a sphere normal from the squashed UV and uses that to refract the background sample with separate offsets per RGB channel, which is the chromatic aberration around the bubble's rim. It computes thin film interference using the standard physics: optical path difference OPD = 2 * n_film * thickness * cos(thetaT), and the per channel oscillation 0.5 + 0.5 * cos(2π * OPD / lambda) for the three primary wavelengths (650 / 532 / 450 nm). The thickness term is itself perturbed by two layers of value noise and a vertical gradient, which is why the swirl flows downward like real soap film under gravity.

float opd = 2.0 * n_film * thickness * cosThetaT;
float lambda_R = 650.0;
float lambda_G = 532.0;
float lambda_B = 450.0;

float TWO_PI = 6.2831853;
float oscR = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_R);
float oscG = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_G);
float oscB = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_B);

The interference color is then blended toward baseTint by interferenceAmount. At 1.0 you get the full rainbow soap bubble. At 0.0 the bubble becomes a solid colored orb (the magenta or slime presets work that way). The shader also applies a YIQ rotation by hueShift so the psychedelic preset can twist the spectrum without recomputing the physics.

The reason the whole shader is a Kotlin string keyed into remember(SHADER_SRC) is hot reload. Edit THICKNESS_BASE, COLOR_INTENSITY, or EDGE_FADE_END inside SHADER_SRC, save, and HotSwan rebuilds the RuntimeShader because SHADER_SRC changed, which invalidates the remember and produces a new shader on the next frame. The bubble keeps its position the entire time.

Look presets and animated uniforms

Toggling LOOK_PRESET between, say, 0 (soap) and 2 (slime) does not snap. The five preset numbers are read into the shader through animateFloatAsState, each with the same low stiffness spring:

val lookSpring = remember {
  spring<Float>(dampingRatio = 0.85f, stiffness = Spring.StiffnessVeryLow)
}
val animInterference = animateFloatAsState(lookValues[0], lookSpring, label = "lookInterference")
val animTintR = animateFloatAsState(lookValues[1], lookSpring, label = "lookTintR")
val animTintG = animateFloatAsState(lookValues[2], lookSpring, label = "lookTintG")
val animTintB = animateFloatAsState(lookValues[3], lookSpring, label = "lookTintB")
val animHueShift = animateFloatAsState(lookValues[4], lookSpring, label = "lookHueShift")

Reading each State<Float> inside the graphicsLayer block is what causes the layer to invalidate on every animation frame. The shader's uniforms then change continuously, and the bubble fades from one look to the next. It is the same pattern as animateColorAsState, just decomposed into per channel floats because the shader takes a float3 tint plus a couple of scalar mix amounts.

Tweaking the constants on a running device

Each constant has a distinct effect. Walking through the most useful ones one by one:

  • LOOK_PRESET: the visual switch. 0 is the realistic soap bubble, 1 is a solid magenta orb, 2 is slime, 3 is a brighter rainbow with a hue twist, 4 is a faint ghost, 5 is fire, 6 is cyan. Try toggling between 0 and 3 while dragging — the rainbow shifts continuously because each preset uniform is on its own spring.
  • MAX_ORB_RADIUS_DP / MIN_ORB_RADIUS_DP: the bubble grows or shrinks as it travels between the bottom and top orb positions. With 180.dp to 88.dp the size delta reads as the bubble compressing into the upper docked state. Bring them together to roughly 120.dp and the bubble feels uniform throughout the drag.
  • SPRING_STIFFNESS / SPRING_DAMPING: governs the deformation spring, not the position spring. 1500f / 34.8f produces a quick rebound that visibly catches up to the bubble. Lower the stiffness to 400f and the deformation lingers. Drop the damping under 20f and the bubble starts to wobble several times after release.
  • DEFORMATION_FACTOR / DEFORMATION_CLAMP: scales the velocity to deformation magnitude and caps it. Push the factor to 0.05f and the bubble looks like it is being yanked rather than dragged. Lower the clamp to 0.2f and even fast drags look subdued.
  • VELOCITY_SMOOTHING: how much per frame velocity is smoothed before being fed into the deformation. 0.15f is enough to remove pointer jitter; 1.0f removes the smoothing entirely and the bubble starts to vibrate on small movements.
  • POP_DURATION_MS / POP_DELAY_MS: tap-to-pop animation. 150 ms is short enough to read as a burst. The 1500 ms delay is the time the popped state persists before the bubble respawns at the bottom. Shorten the delay to 300 ms and the bubble feels disposable; lengthen it to 3000 ms and the screen reads as paused.
  • AGSL THICKNESS_BASE / COLOR_INTENSITY / EDGE_FADE_END: optical knobs inside the shader source. THICKNESS_BASE shifts which colors dominate the rainbow (smaller numbers favor blue ends, larger ones favor red). COLOR_INTENSITY multiplies the film color. EDGE_FADE_END controls how much of the rim fades to a plain Fresnel reflection — lower it to about 0.05 and the rainbow runs all the way to the edge.

Because all these values live as named locals in AnimationExample22.kt and the AGSL string is keyed into remember, every edit hot reloads. The bubble does not re-spawn, the theme does not flip, the drag state does not reset. You change a number, save, and the next frame is the new look.

Conclusion

In this article, you've explored a soap bubble drag built from three composed layers: an Animatable<Offset> driven by pointerInput for position, a custom withFrameNanos loop that converts position into a smoothed velocity and a critically damped deformation, and an AGSL RuntimeShader attached through graphicsLayer { renderEffect = ... } that reads the deformation per frame to render thin film interference on top of the live UI underneath. You walked through how remember(LOOK_PRESET) and per channel animateFloatAsState calls let a single integer at the top of the file morph the bubble between rainbow soap, neon magenta, slime, psychedelic, ghost, fire, and cyan looks without ever snapping.

Understanding these internals helps you treat shaders as just another State consumer in Compose. AGSL is not a separate world. The shader is a function that takes uniforms; the uniforms are state objects that participate in the same recomposition graph as everything else. If you can express a value as a State<Float> and read it inside graphicsLayer, you can animate it through a shader. The bubble is the demonstration case, but the same wiring applies to background blurs, color grades, ripple effects, and any other AGSL effect you want to drive from a Compose state holder.

Whether you're building a chat avatar with a refractive bubble overlay, an onboarding flow with an iridescent CTA, or an experimental motion piece that reacts to drag velocity, the pattern stays the same: hoist the tunables to the top of the function, key the shader source into remember, and let animateFloatAsState or Animatable carry the state into the shader's uniforms. Hot reload turns optical experiments into a few seconds of iteration each, which is the fastest way to develop a feel for how thin film interference, refraction, and stretch and squash actually combine on screen.

As always, happy coding!