WWDC Quick Look đź’“ By SwiftGGTeam
Create custom visual effects with SwiftUI

Create custom visual effects with SwiftUI

Watch original video

Highlight

SwiftUI in iOS 18 adds MeshGradient, TextRenderer, a custom Transition protocol, and Metal shader integration—turning visual effects from “built-in modifiers only” into pixel-level customization.


Core Content

When building SwiftUI apps, visual effects often hit two walls: built-in modifiers aren’t enough—for example, scrollTransition only handles rotation and scale, so hue shifts require hand-rolled GeometryReader; or you want per-glyph text animation, but Text is an indivisible black box with no access to individual glyph positions.

Apple introduced five new tools to fill these gaps. The scrollTransition and visualEffect modifiers cover position-based and geometry-based visual adjustments in scroll scenarios. MeshGradient interpolates colors across a grid of control points—far more flexible than linear or radial gradients, with animatable control point positions. The custom Transition protocol uses TransitionPhase to distinguish willAppear from didDisappear for directional transitions. The TextRenderer protocol exposes Text.Layout’s lines, runs, and runSlices; paired with TextAttribute to mark key text, you can animate glyph by glyph with spring animations. Finally, Metal shaders integrate directly into the SwiftUI view tree via ShaderLibrary and layerEffect, computing per-pixel on the GPU with performance far beyond CPU approaches.

The session’s core idea: visual effects need iterative tuning, so every API is designed to be declarative, composable, and previewable—reducing the delay from idea to visible result.


Detailed Content

Custom scroll effects

scrollTransition exposes a phase parameter with values leading / center / trailing, plus phase.value (a floating offset) and phase.isIdentity (whether the view is centered). For parallax, offset only the image content—not the clipping container—to create a “window perspective” effect (03:14).

visualEffect uses GeometryProxy to provide global position and size, enabling coordinate-based hue rotation, blur, scale, and more (04:41):

RoundedRectangle(cornerRadius: 24)
    .fill(.purple)
    .visualEffect({ content, proxy in
        content
            .hueRotation(Angle(degrees: proxy.frame(in: .global).origin.y / 10))
    })

Key points:

  • The proxy closure parameter is a GeometryProxy; read frame, safeAreaInsets, and other geometry
  • The return must be some View, using only visual modifiers (opacity, blur, hueRotation, etc.)—no layout changes
  • Because it doesn’t trigger a layout pass, performance beats GeometryReader plus state-driven updates

Mesh Gradient

MeshGradient defines a grid with width Ă— height; each control point specifies SIMD2 coordinates and a color, and SwiftUI interpolates between points (07:30):

MeshGradient(
    width: 3,
    height: 3,
    points: [
        [0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
        [0.0, 0.5], [0.9, 0.3], [1.0, 0.5],
        [0.0, 1.0], [0.5, 1.0], [1.0, 1.0]
    ],
    colors: [
        .black,.black,.black,
        .blue, .blue, .blue,
        .green, .green, .green
    ]
)

Key points:

  • The points array length must equal width * height; each item is [Float, Float] in the range 0–1
  • Center point [0.9, 0.3] is off-center, shifting the blue region up and right—that’s the core of Mesh Gradient: control point positions define color distribution
  • Control point positions can bind to @State and animate with withAnimation for flowing colors

Custom Transition

iOS 18 adds the Transition protocol; TransitionPhase distinguishes willAppear, identity, and didDisappear (10:36):

struct Twirl: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .scaleEffect(phase.isIdentity ? 1 : 0.5)
            .opacity(phase.isIdentity ? 1 : 0)
            .blur(radius: phase.isIdentity ? 0 : 10)
            .rotationEffect(
                .degrees(
                    phase == .willAppear ? 360 :
                        phase == .didDisappear ? -360 : .zero
                )
            )
            .brightness(phase == .willAppear ? 1 : 0)
    }
}

Key points:

  • When phase.isIdentity is true, the view is fully visible; otherwise use phase.value for continuous interpolation
  • phase == .willAppear and phase == .didDisappear allow opposite rotation directions on enter vs exit
  • brightness adds 1 only on enter, giving a flash of highlight to draw attention

TextRenderer

The core method of the TextRenderer protocol is draw(layout:in:). Text.Layout provides lines → runs → runSlices hierarchically; the smallest runSlice corresponds to a single glyph (13:29). To drive animation, the renderer conforms to Animatable with animatableData pointing to elapsedTime. With TextAttribute, mark specific text ranges so the renderer animates only marked portions glyph by glyph while the rest fades in quickly (14:01).

Metal shader integration

Metal shader functions are marked with [[ stitchable ]], and SwiftUI calls them by name via ShaderLibrary.Ripple(...). Layer Effect receives each pixel’s position and the original layer content for GPU-level distortion, chromatic aberration, and more (22:55):

[[ stitchable ]]
half4 Ripple(
    float2 position,
    SwiftUI::Layer layer,
    float2 origin,
    float time,
    float amplitude,
    float frequency,
    float decay,
    float speed
) {
    float distance = length(position - origin);
    float delay = distance / speed;
    time -= delay;
    time = max(0.0, time);
    float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time);
    float2 n = normalize(position - origin);
    float2 newPosition = position + rippleAmount * n;
    half4 color = layer.sample(newPosition);
    color.rgb += 0.3 * (rippleAmount / amplitude) * color.a;
    return color;
}

Key points:

  • position is the current pixel coordinate; layer is the view’s layer content—sample with layer.sample(at:)
  • Ripples propagate outward from origin; distance / speed computes delay so farther pixels start later
  • sin(frequency * time) * exp(-decay * time) is a damped sine wave—amplitude decreases over time
  • color.rgb += 0.3 * ... brightens at wave peaks, simulating light refraction

On the SwiftUI side, RippleEffect ViewModifier wraps keyframeAnimator, animating elapsedTime from 0 to duration and passing each frame to RippleModifier, which calls the shader via visualEffect + layerEffect (23:36).


Core Takeaways

  • What to do: Use visualEffect for scroll-position-driven hue/blur gradient lists. Why it’s worth it: Monochrome lists look flat; driving hueRotation with proxy.frame(in: .global).origin.y adds color depth with zero layout code. How to start: Add .visualEffect { content, proxy in content.hueRotation(...) } to each item in a LazyVStack and tune the y divisor until the gradient feels natural.

  • What to do: Replace static brand page backgrounds with MeshGradient. Why it’s worth it: MeshGradient control points animate—more flexible than CAGradientLayer, purely declarative with no Core Animation code. How to start: Define a 3Ă—3 MeshGradient, bind center point coordinates to @State, and use withAnimation(.easeInOut(duration: 2)) to toggle coordinate values and watch colors flow.

  • What to do: Use TextRenderer + TextAttribute for per-glyph entrance animation on key information. Why it’s worth it: Plain opacity transitions show all text at once with no emphasis; per-glyph spring animation highlights keywords in titles and draws the eye. How to start: Define EmphasisAttribute: TextAttribute, mark keywords with .customAttribute(EmphasisAttribute()) in Text, and in the renderer check run attributes to choose per-glyph animation vs quick fade-in.

  • What to do: Add Metal shader ripple effects to image interactions. Why it’s worth it: Pure scale feedback lacks direction; ripples spreading from the touch point clearly signal “I tapped here.” How to start: Write a [[ stitchable ]] Ripple function in a .metal file, drive elapsedTime with keyframeAnimator on the SwiftUI side, and apply the shader via layerEffect.


Comments

GitHub Issues · utterances