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
maxSampleOffsettoo small clips the edges. It is the promise you make about how far the shader samples. AtAMP = 0.05on 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+2733to the colour emoji font, and aU+FE0Esuffix 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.