WWDC.ai

Compose advanced graphics effects with SwiftUI

Compose SwiftUI shader, timeline, scrolling, and alignment APIs into advanced animated visual effects from simple pipeline stages.

Watch on Apple Developer

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, and alignmentGuide instead 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 via ShaderLibrary.
  • layerEffect receives SwiftUI::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 as texture2d<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 .float shader 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 alignmentGuide for semantic attachment over hard-coded offset values.

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

Unofficial, not associated with Apple. BySuperwall

On this page

Ask AI