Skip to content

Ripple Refraction (SwiftUI)

A SwiftUI guide for coding agents. Also covers water ripple swiftui, distortion effect, metal shader swiftui, stitchable shader, image displacement swiftui, touch ripple ios, and 1 more.

Show all 7 aliases

water ripple swiftui, distortion effect, metal shader swiftui, stitchable shader, image displacement swiftui, touch ripple ios, shader onboarding ios

Expanding rings that bend an image as they cross it, driven by touch. distortionEffect runs a Metal function per pixel, TimelineView drives the clock, and a DragGesture feeds it touches.

For the technique itself, the displacement maths and when the effect is worth its cost, see the ripple-refraction principle in the ui area. This is the SwiftUI recipe.

The Shader

View.distortionEffect(_:maxSampleOffset:isEnabled:) is iOS 17 and macOS 14 and up. Its contract: take the destination pixel, return the source pixel to sample. Bending that mapping is what bends the image.

#include <metal_stdlib>
using namespace metal;

constant float LIFE  = 2.6;
constant float REACH = 0.75;
constant float THICK = 500.0;
constant float AMP   = 0.05;

[[ stitchable ]] float2 ripple(float2 position,
                               float2 size,
                               float time,
                               device const float *ripples,
                               int count) {
  float2 uv = position / size;
  float aspect = size.x / size.y;
  float2 offset = float2(0.0);

  for (int i = 0; i + 3 < count; i += 4) {   // x, y, birth, strength
    float strength = ripples[i + 3];
    float age = time - ripples[i + 2];
    if (strength <= 0.0 || age < 0.0 || age > LIFE) { continue; }

    // measure in aspect-corrected space so the rings stay circular
    float2 d = (uv - float2(ripples[i], ripples[i + 1])) * float2(aspect, 1.0);
    float dist = length(d);
    if (dist < 1e-5) { continue; }

    float t = age / LIFE;
    float band = dist - REACH * pow(t, 0.7);
    float envelope = exp(-band * band * THICK);

    // one crest and one trough, the profile of a lens
    float lens = band * envelope * sqrt(2.0 * THICK) / 0.606;

    float force = lens
      * pow(1.0 - t, 1.1) * strength
      * (1.0 / (1.0 + dist * 0.5))
      * smoothstep(0.0, 0.08, t)
      * smoothstep(0.0, 0.06, dist);

    offset += normalize(d) / float2(aspect, 1.0) * force * AMP;
  }

  // sampling past the layer returns transparent, which reads as black edges
  return clamp(position + offset * size, float2(0.0), size);
}

Add the .metal file to the app target and Xcode compiles it into the default library, which ShaderLibrary reads. No manual pipeline setup.

The Ripple Buffer

Shader.Argument.floatArray arrives in Metal as device const float *ptr, int count, so pack the ripples flat and stride by 4. A fixed ring buffer keeps the count bounded.

@Observable
final class RippleStore {
  static let capacity = 12
  private static let stride = 4

  private(set) var packed = [Float](repeating: 0, count: capacity * stride)
  private var cursor = 0
  private let start = Date()

  func elapsed(at date: Date) -> Float { Float(date.timeIntervalSince(start)) }

  func add(at point: CGPoint, in size: CGSize, strength: Float = 1) {
    guard size.width > 0, size.height > 0 else { return }
    let i = cursor * Self.stride
    packed[i] = Float(point.x / size.width)
    packed[i + 1] = Float(point.y / size.height)
    packed[i + 2] = Float(Date().timeIntervalSince(start))
    packed[i + 3] = strength
    cursor = (cursor + 1) % Self.capacity
  }
}

Ripple origins are normalised to 0...1, so the shader's uv and the gesture's coordinate space agree without passing the frame twice.

Driving It

TimelineView(.animation) re-renders every frame and hands you a Date to convert into shader time. Apply the effect to the image only.

TimelineView(.animation) { context in
  Image(uiImage: artwork)
    .resizable()
    .scaledToFill()
    .frame(width: size.width, height: size.height)
    .clipped()
    .distortionEffect(
      ShaderLibrary.ripple(
        .float2(size.width, size.height),
        .float(store.elapsed(at: context.date)),
        .floatArray(store.packed)
      ),
      // must exceed the shader's peak displacement or the edges clip
      maxSampleOffset: CGSize(width: 60, height: 60)
    )
}
.gesture(
  DragGesture(minimumDistance: 0)
    .onChanged { value in
      guard Date().timeIntervalSince(lastMove) > 0.18 else { return }   // throttle
      lastMove = Date()
      store.add(at: value.location, in: size, strength: 0.8)
    }
    .onEnded { store.add(at: $0.location, in: size, strength: 1) }
)

DragGesture(minimumDistance: 0) catches taps as well as drags. Throttle onChanged, or one swipe fills the whole ring buffer and the oldest ripples die before they are seen.

Keep text and buttons in a ZStack above the image, never inside the distorted view. Bending your own copy is illegible, not atmospheric.

Gotchas

  • maxSampleOffset too small clips the edges. It is the promise you make about how far the shader samples. At AMP = 0.05 on an 844 point tall screen the peak is about 42 points, so 60 is safe. Too large costs render area, so do not just pass a huge number.
  • Black bars down the edges. A displaced sample outside the layer is transparent. Clamp the returned position into bounds, as the shader above does.
  • The effect applies to a whole view tree. Attaching it to a parent bends the copy and the button with the artwork.
  • Emoji as an icon. iOS resolves characters such as U+2733 to the colour emoji font, and a U+FE0E suffix does not override it. Use an SF Symbol or draw the glyph.
  • Simulator versus device. The simulator renders Metal correctly here, so it is enough to verify the effect. A device still tells you what it costs.

Reduced Motion

Read \.accessibilityReduceMotion and render the plain image when it is on, skipping the TimelineView entirely so no frame loop runs. This is ambient motion with no informational content, so removing it costs the user nothing.

if reduceMotion {
  painting(size: size)
} else {
  TimelineView(.animation) { context in /* ... */ }
}

Gate the gesture handlers on the same flag, otherwise touches keep filling a buffer nothing reads.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-ripple-refraction" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems