Compose advanced graphics effects with SwiftUI
Compose SwiftUI shader, timeline, scrolling, and alignment APIs into advanced animated visual effects from simple pipeline stages.
TL;DR
- Treat advanced SwiftUI graphics as a composable pipeline: start with existing data, transform it through small APIs, then connect stages together.
- Use SwiftUI shader effects-especially
layerEffect-with Metal stitchable functions to sample a view layer and create image-based warps driven by noise textures. - Drive stateless shader animation by passing changing time values from
TimelineView(.animation)into shader parameters. - Build synchronized foreground UI with
ScrollViewReader, playback-state changes, overlays, andalignmentGuideinstead of manual offsets.
Creative pipelines for SwiftUI effects
The session frames advanced SwiftUI visuals as a pipeline: data enters one stage, gets transformed, and becomes input for the next stage. The example starts from a podcast app that already has cover art, playback state, and transcript text, then composes standard SwiftUI layout and graphics APIs into a richer experience.
The finished design has two parallel flows: a background flow that turns cover art into an animated visualizer, and a foreground flow that turns transcript data plus playback time into a synchronized scrolling transcript with a floating timestamp.
- Break down the design into data sources: image, playback time, transcript lines, and line timestamps.
- Choose a transformation for each stage: blur, shader warp, time-driven animation, current-line highlighting, scrolling, and overlay alignment.
- Compose stages rather than building one monolithic custom renderer.
Cover art, blur, and shader effects
The background begins with a regular SwiftUI Image, softened with blur(radius:) so it can sit behind transcript text without competing for attention. The visualizer is then built with a SwiftUI shader effect.
SwiftUI exposes shader effects through colorEffect, distortionEffect, and layerEffect. colorEffect transforms each pixel color, distortionEffect maps output positions to source positions, and layerEffect receives the whole rendered layer so the shader can sample from nearby pixels or the broader region. The example chooses layerEffect for flexibility.
- Shaders run per pixel on the GPU; each pixel invocation is independent.
- A
[[stitchable]]Metal function can be called from SwiftUI viaShaderLibrary. layerEffectreceivesSwiftUI::Layer, which can be sampled at arbitrary positions.- Pass SwiftUI-side values, such as size, offsets, images, or time, as shader parameters.
Start from cover art and soften it
The raw image becomes a blurred background layer before shader processing.
Image("CoverArt")
.blur(radius: 30)Minimal SwiftUI layer effect and Metal shader
The first shader simply samples the original layer at the current pixel position, producing the same image while establishing the shader pipeline.
GeometryReader { proxy in
CoverArtView()
.layerEffect(
ShaderLibrary.backgroundWarp(),
maxSampleOffset: .zero
)
}
.ignoresSafeArea()
[[stitchable]] half4 backgroundWarp(
float2 position,
SwiftUI::Layer layer
) {
return layer.sample(position);
}Organic warping with noise and domain warping
A uniform offset only shifts every pixel in the same way. To get organic variation, the example passes a precomputed NoiseTexture into the shader and samples its red and green channels as a two-dimensional per-pixel offset.
The richer version uses domain warping: sample noise once to get an intermediate coordinate offset, then sample noise again at the shifted coordinate. That layered noise produces flowing blob-like distortions.
- Pass the view size so the shader can compute UV coordinates from pixel positions.
- Pass the noise image with
.image(Image("NoiseTexture")); Metal receives it astexture2d<half>. - Use a repeating, linear sampler so the noise texture tiles smoothly.
- Scale the sampled red/green values around zero before adding them to the source sample position.
SwiftUI passes size and noise texture to the shader
The layer effect receives both geometry-derived size and a noise texture.
GeometryReader { proxy in
CoverArtView()
.layerEffect(
ShaderLibrary.backgroundWarp(
.float2(proxy.size),
.image(Image("NoiseTexture"))
),
maxSampleOffset: .zero
)
}
.ignoresSafeArea()Metal layer shader with domain warping
The shader uses a second noise sample at a noise-shifted coordinate to create a more complex warp.
[[stitchable]] half4 backgroundWarp(
float2 position,
SwiftUI::Layer layer,
float2 size,
texture2d<half> noiseTex
) {
constexpr sampler s(address::repeat, filter::linear);
float2 uv = position / size;
half4 n = noiseTex.sample(s, uv);
float2 q = float2(n.r, n.g);
n = noiseTex.sample(s, uv + q);
float2 offset = (float2(n.r, n.g) - 0.5) * 200.0;
return layer.sample(position + offset);
}Animating shaders with time
SwiftUI transaction-based animation is not enough by itself for shader state, because shaders are stateless and compute output only from their inputs. To animate a shader, pass in a parameter that changes over time.
TimelineView(.animation) supplies a timestamp each frame. The example records a start date, computes elapsed time, and passes that value into the shader so the noise sampling position can change as playback continues.
- Use
TimelineView(.animation)for frame-by-frame time values. - Compute elapsed time relative to a stored
startDate. - Forward elapsed time as a
.floatshader argument. - Use the time value in Metal to shift noise sampling and make the warp flow.
Pass elapsed time into a layer shader
The timeline acts as the time pipe that drives an otherwise stateless shader.
@State private var startDate = Date.now
TimelineView(.animation) { timeline in
let elapsed = timeline.date.timeIntervalSince(startDate)
CoverArtView()
.layerEffect(
ShaderLibrary.backgroundWarp(
.float2(proxy.size),
.image(Image("NoiseTexture")),
.float(elapsed)
),
maxSampleOffset: .zero
)
}Time-synced transcript and floating timestamps
The foreground transcript remains ordinary SwiftUI: Text views in a LazyVStack inside a ScrollView. Playback state determines the current line, which is styled differently from the rest.
ScrollViewReader and onChange keep the current line centered as playback progresses. Timestamp labels are attached with overlays, and alignmentGuide semantically changes how the overlay aligns so the timestamp floats outside the row edge without manual size-dependent offsets.
- Use playback timestamp to derive the current transcript line.
- Use
scrollProxy.scrollTo(_:anchor:)when the current line changes. - Keep timestamp overlays present but show only the active one, avoiding layout changes.
- Prefer
alignmentGuidefor semantic attachment over hard-codedoffsetvalues.
Scroll transcript to the current line
The transcript highlights the current line and scrolls it to the center when playback advances.
@State private var playback = PlaybackState()
ScrollViewReader { scrollProxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(sampleTranscript) { line in
Text(line.text)
.transcriptLineStyle(
isCurrent: line.id == playback.currentLineIndex
)
}
}
}
.onChange(of: playback.currentLineIndex, { _, i in
scrollProxy.scrollTo(i, anchor: .center)
})
}Float a timestamp with an alignment guide
The overlay's bottom alignment is overridden to use the timestamp's top edge, placing it just outside the text row.
Text(line.text)
.overlay(alignment: .bottomLeading) {
Text(line.formattedTimestamp)
.alignmentGuide(.bottom) { $0[.top] }
}Resources
Related Sessions
Dive into lazy stacks and scrolling with SwiftUI
Understand how SwiftUI LazyVStack and LazyHStack estimate layout, load subviews, prefetch work, and support smoother programmatic scrolling.
Elevate your app’s text experience with TextKit
Use new TextKit hooks to extend UITextView and NSTextView with viewport-aware rendering, collapsible layout, and reusable text attachments.