MetalRipples

Metal: Apple’s Framework That Rocks Your GPU 🤘

Metal is Apple’s low-level, high-performance graphics and compute framework, designed to get the most out of your device’s GPU (Graphics Processing Unit). Whether you’re building a game, visualizing data, or creating stunning animations, Metal gives you direct access to the hardware—enabling fast rendering, smooth graphics, and efficient parallel processing.

Even if you’re just getting started, understanding Metal opens the door to creating visually impressive and high-performance apps. And in fact, you have been using Metal already – in the background in our last code-along “MeshArt”. MeshGradient is implemented to leverage Metal, the shader code runs behind the scenes.

You can use Metal with Swift, Objective-C, or even C++.

And Metal isn’t just for graphics—it’s also powering the future of machine learning on Apple devices. With the latest improvements to Metal on Apple silicon, you can even train machine learning models directly on your Mac using popular tools like PyTorchTensorFlow, and Apple’s own MLX framework.

This means that both developers and researchers can take advantage of the GPU’s power not only for visuals, but also for fast, efficient AI training—all while staying within Apple’s ecosystem.

As this month’s code-alongs are dedicated to visual effects, let’s get started and create a cool ripple effect by using Apple’s Metal. This ripple effect has been demonstrated at WWDC24: Create cutom visual effects with SwiftUI which we will explain in more detail. You can download the WWDC project here: https://developer.apple.com/documentation/SwiftUI/Creating-visual-effects-with-SwiftUI

However, I encourage you to follow this code-along, build it by yourself and get some more background. Our Ripple Effect will look the following:

The ripple effect is similar to dropping a stone in water where the disturbance travels outward, modifying the appearance of the image as if it were a fluid surface.

Let’s get started!

Step 1: Set up your project

  1. Open Xcode: Launch Xcode and select Create a new Xcode project.
  2. Choose Template: Select App under the iOS tab and click Next.
  3. Name Your Project: Enter a name for your project, like MetalRipples.

Choose SwiftUI for the interface and Swiftfor the language. Click Next, and then save your project.

When you open your project, you’ll see the already familiar standard code presenting a globe and the Text “Hello, world!” in the ContentView.swift. As usual, this will be our “Home”-View which will present all the other views. This time we will be using again a TabView.

Step 2: Create a Stripes Metal Shader

A shader is a small program that runs on the GPU to compute things like colors for pixels. In our example, we’re creating a simple shader that produces horizontal stripes. We have created a stripes modifier already in our last code-along “MeshArt” – however, with Metal shader you can control each and every single pixel which gives you even more possibilities.

First things first. Let’s create a StripesView view that will show the text “Pride” and a circle, both presented in Pride color stripes:

Go to File - New - File from Template... or simply press cmd + N, choose iOS Template and here SwiftUI View, click Next, choose StripesView as the name of the new view, ensure that MetalRipples is selected under Group, check the tick box for Targets: MetalRipples, click Create.

In this Stripes View we start with just the simple text “Pride” and a circle which we will be formatting with the stripes shader we will create soon. Therefore, in the StripesView, please include:

Swift
struct StripesView: View {

    var body: some View {
        VStack {
            Text("Stripes Modifier")
                .font(.title.bold())

            Text("Pride")
                .font(.system(size: 144, weight: .bold))

            Circle()
                .padding(16)

            Spacer()
        }
    }
}

This does not look exciting, yet. So let’s add some color and let’s get started with the Stripes Metal Shader.

Stripes.metal

Create a new file – a new type – a Metal file: Go to File - New - File from Template... or simply press cmd + N, choose iOS Template and in the Search bar type “Metal”, select “Metal File” click Next, choose Stripes as the name, ensure that MetalRipples is selected under Group, check the tick box for Targets: MetalRipples, click Create.

Please insert the following code into the “Stripes.metal” file:

C++
#include <metal_stdlib>
#include <SwiftUI/SwiftUI.h>
using namespace metal;

[[ stitchable ]]
half4 Stripes(
    float2 position,
    float thickness,
    device const half4 *ptr,
    int count
) {
    int i = int(floor(position.y / thickness));
    
    // Clamp to 0 ..< count.
    i = ((i % count) + count) % count;

    return ptr[i];
}

What is this doing?

  1. Importing libraries
    • #include <metal_stdlib> This line includes Metal’s standard library, which gives you access to common types and functions (like vector math) needed for shader programming.
    • #include <SwiftUI/SwiftUI.h> This header helps integrate Metal shaders with SwiftUI.
    • using namespace metal; This makes it easier to write code by letting you use Metal’s types (like float2 and half4) without prefixing them with metal::.
  2. The [[ stichable ]] attribute
    This attribute tells the compiler that the function that follows can be “stitched” or integrated into larger shader programs. It’s a requirement for shaders that you want to use with SwiftUI’s custom shader API.
  3. The Stripes function signature
C++
half4 Stripes(
	float2 position,
	float thickness,
	device const half4 *ptr,
	int count
)

The function returns a half4, which is a four-component vector (usually representing red, green, blue, and alpha (transparency)) in half-precision floating point format. Half-precision calculations require less memory and bandwidth, they can be faster, and for many graphical effects, the precision of 16 bits per channel is enough to display high-quality images. Using vector types like half4 is common practice in shader programming.
This is the color that will be output for a given pixel.

  • System parameter: float2 position is a 2D vector that gives the (x, y) position/location of the pixel being processed. This parameter is automatically passed by SwiftUI during rendering. You don’t need to—and indeed can’t—set these yourself when you configure your shader in SwiftUI.
  • Custom parameters
  • float thickness determines how tall each stripe will be.
  • device const half4 *ptr is a pointer to an array of colors (each color being a half4) stored in GPU (device) memory. This array holds the colors you want to use for your stripes. It’s automatically populated by SwiftUI’s shader infrastructure.
  • int count is the number of colors in the array. This tells the shader how many different stripe colors are available.
  1. Calculating the Stripe index is done by
C++
int i = int(floor(position.y / thickness));

This line divides the y-coordinate of the pixel’s position by the thickness of the stripes. However, if the position is negative, i might also be negative. The floor function rounds the result down to the nearest whole number. This gives us an integer i that represents which stripe the current pixel is in.
5. Clamping the index:

C++
i = ((i % count) + count) % count;

The shader might calculate an i that is outside the bounds of the color array (for example, if i is negative or too high). This line uses modulo arithmetic (%) to “wrap” the value of i so that it always falls between 0 and count - 1. It works correctly even if i is negative.
(NB: In many UIKit or SwiftUI views, you might be used to (0,0) being the top-left corner. However, when a shader is applied to a shape (like a Circle) or to custom drawing code, the coordinate space is usually that of the shape’s own geometry. For example, many shapes are defined in a coordinate system where the center is (0,0) so that they’re drawn symmetrically. That means positions can be negative on one side.)
6. Return the Stripe color
After calculating a valid index i, the function returns the color from the array at that index:

C++
return ptr[i];

This color is used to fill the pixel, creating the stripe effect.

StripesView

Now, let’s apply the created Stripes Metal Shader to our text “Pride” and the circle we already included in our StripesView.

Let’s define a constant stored property stripes, i.e. before the body please type:

Swift
let stripes = ShaderLibrary.Stripes(
        .float(20),
        .colorArray([
            .red, .orange, .yellow, .green, .blue, .purple
        ])
    )

This configures the Metal shader with the following parameters:

  • .float(20): Sets the stripe thickness to 20 units.
  • .colorArray([...]): Provides an array of colors. Here, the colors represent the traditional Pride flag colors.

When you call ShaderLibrary.Stripes(...), SwiftUI takes that color array and creates a corresponding buffer in GPU memory. It then passes a pointer to this buffer to the shader as the ptr parameter. The system automatically handles binding this color data to your shader. Along with ptr, SwiftUI also provides the number of elements in the array through the count parameter. ptr and countare automatically supplied by SwiftUI from the color array.

You can now color our text and circle views by simply applying .foregroundStyle(stripes) to the text and .fill(stripes) to the circle:

Swift
Text("Pride")
	.font(.system(size: 144, weight: .bold))
	.foregroundStyle(stripes)

Circle()
	.fill(stripes)
	.padding(16)

Congrats! You created your first Metal Shader!

Let’s get more sophisticated and create a ripple effect.

Step 3: Create a Ripple effect

To create this ripple effect when tapping the image, let’s start with the image view itself:

Go to File - New - File from Template... or simply press cmd + N, choose iOS Template and here SwiftUI View, click Next, choose RippleEffectView as the name of the new view, ensure that MetalRipples is selected under Group, check the tick box for Targets: MetalRipples, click Create.

Let’s start with a title “Ripple Effect” and our plain image – you can use any image you want. if you want to use our palm tree, you can download it here:

Let’s get started by simply presenting a title and our palm tree. Just a plain presentation in a VStack and just an Image:

Swift
VStack {
	Text("Ripple Effect")
		.font(.title.bold())

	Image("PalmTreeBeach")
		.resizable()
		.scaledToFit()
		.frame(width: 350)
		.cornerRadius(24)

	Spacer()
}

To make the image appear a little bit like 3D, we’ll add a subtile shadow to the image. Add to the image (directly below the .cornerRadius modifier) the shadow modifier:

Swift
.shadow(radius: 5, x: 5, y: 5)

You can play around with the values. radius is a measure of how much to blur the shadow. Larger values result in more blur. x and yare the amount to offset the shadow horizontally and vertically, respectively, from the view.

To implement a ripple effect, we need the following steps:

  1. We need to detect where the user touches the image. We will be using a gesture modifier (SpatialPressingGestureModifier) that captures the tap location and updates the view’s state (origin and counter).
  2. Create the Metal shader Ripplethat calculates a ripple effect by shifting pixel positions based on a sine wave that decays over time.
  3. Create a RippleModifier which will be triggered by a change in counter. This animation runs for 3 seconds, continuously updating the elapsedTime. To every frame of the animation, we will apply the RippleModifier. The RippleModifier will create and apply a Metal shader that distorts the image based on the touch location and the elapsed time.
  4. We will wrap the RippleModifier into a ViewModifier so that we can apply it as a modifier to our image.

This flow will result is a smooth ripple that starts at the touch point and propagates outward, visually distorting the image.

Step 3.1: Capturing user interaction SpatialPressing.swift

Before we can apply a ripple effect, we need to detect where the user touches the image and then reporting that location back so that other parts of your app (like the ripple effect) can react to it. Let’s create the SpatialPressing.swift file:

Go to File - New - File from Template... or simply press cmd + N, choose iOS Template and here Swift File, click Next, choose SpatialPressing as the name of the new view, ensure that MetalRipples is selected under Group, check the tick box for Targets: MetalRipples, click Create.

Here, we will define two structs:

  • SpatialPressingGesture: Focuses solely on detecting touches and calculating the location using the underlying UIKit system.
  • SpatialPressingGestureModifier attaches this functionality to your SwiftUI view and manages the corresponding state changes. The modifier wraps the gesture recognizer, updates SwiftUI state (currentLocation), and then triggers your custom logic (like starting a ripple) whenever the touch location changes.

This separation makes the code modular, easier to understand, and maintainable, especially as you build more complex interactions like the ripple effect.

Let’s start with the SpatialPressingGesture, a custom gesture recognizer that implements the actual touch detection using UIKit’s gesture recognition system. It conforms to UIGestureRecognizerRepresentable, meaning it wraps a UIKit gesture (in this case, a UILongPressGestureRecognizer) for use in SwiftUI.

Please insert in the SpatialPressing file the following:

Swift
import SwiftUI

struct SpatialPressingGesture: UIGestureRecognizerRepresentable {
    final class Coordinator: NSObject, UIGestureRecognizerDelegate {
        @objc
        func gestureRecognizer(
            _ gestureRecognizer: UIGestureRecognizer,
            shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
        ) -> Bool {
            true
        }
    }

    @Binding var location: CGPoint?

    func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
        Coordinator()
    }

    func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
        let recognizer = UILongPressGestureRecognizer()
        recognizer.minimumPressDuration = 0
        recognizer.delegate = context.coordinator
  
        return recognizer
    }

    func handleUIGestureRecognizerAction(
        _ recognizer: UIGestureRecognizerType, context: Context) {
        switch recognizer.state {
            case .began:
                location = context.converter.localLocation
            case .ended, .cancelled, .failed:
                location = nil
            default:
                break
        }
    }
}

It creates a UILongPressGestureRecognizer with a minimumPressDuration of 0, so it responds immediately. It uses a coordinator (a helper object) to manage delegation and to allow the gesture to work simultaneously with others if needed. It updates a bound variable (the location) with the current touch point when the gesture begins, and resets it when the gesture ends or cancels.

Why It’s Needed: This struct encapsulates the low-level gesture recognition logic. It’s the component that directly interacts with UIKit to detect the press and determine the precise location.

We now need to bridge the gesture events and the SwiftUI view, updating the state and triggering any changes (like starting a ripple effect). Therefore we will create a SwiftUI view modifier SpatialPressingGestureModifier that you attach to any view. Its job is to manage the state (the current touch location) and to connect the gesture recognizer to your SwiftUI view.

Below the SpatialPressingGesture struct, please add:

Swift
struct SpatialPressingGestureModifier: ViewModifier {

    var onPressingChanged: (CGPoint?) -> Void
    @State var currentLocation: CGPoint?

    init(action: @escaping (CGPoint?) -> Void) {
        self.onPressingChanged = action
    }

    func body(content: Content) -> some View {
        let gesture = SpatialPressingGesture(location: $currentLocation)

        content
            .gesture(gesture)
            .onChange(of: currentLocation, initial: false) { _, location in
                onPressingChanged(location)
            }
    }
}

It holds a @State variable (currentLocation) that tracks the touch location.
– It applies a gesture (the one provided by SpatialPressingGesture) to the view.
– It listens for changes in the touch location and calls a callback (onPressingChanged) when these changes occur.
– This location will be the center of the ripple effect.

Update the file RippleEffectView

We need to attach our SpatialPressingGestureModifier to our RippleEffectView and therefore we’ll update the RippleEffectView with the following extension (please add below the RippleEffectView struct – please do not include in the struct code of RippleEffectView. Just below the closing } of the RippleEffect struct, before the Preview). Please insert:

Swift
extension View {
    func onPressingChanged(_ action: @escaping (CGPoint?) -> Void) -> some View {
        modifier(SpatialPressingGestureModifier(action: action))
    }
}

In our RippleEffectView let’s define two variablescounter and origin:

The counter is used simply as a trigger to restart the animation every time the user taps on the image, while the origin tells the shader where to start the ripple, it’s the location of the touch.

Insert above the body the following:

Swift
@State var counter: Int = 0
@State var origin: CGPoint = .zero

Now, we can apply this modifier to our image. Please add in our RippleEffectView the following modifier to our image (e.g. directly below .cornerRadius(24)):

Swift
.onPressingChanged { point in
	if let point {
		origin = point
		counter += 1
	}
}

With this, When a touch is detected, the gesture callback updates origin and increments counter.

Step 3.2: The Metal Shader – Making the Ripple Ripple.metal

Implements the actual visual distortion effect using Metal’s shading language. This shader’s job is to calculate a new position for each pixel based on the ripple parameters.

Create a new file – a new type – a Metal file: Go to File - New - File from Template... or simply press cmd + N, choose iOS Template and in the Search bar type “Metal”, select “Metal File” click Next, choose Ripple as the name, ensure that MetalRipples is selected under Group, check the tick box for Targets: MetalRipples, click Create.

As already seen in our Stripes code-along above, please start with:

C++
#include <metal_stdlib>
#include <SwiftUI/SwiftUI.h>
using namespace metal;

[[ stitchable ]]
half4 Ripple(
    float2 position,
    SwiftUI::Layer layer,
    float2 origin,
    float time,
    float amplitude,
    float frequency,
    float decay,
    float speed
) {
// more code to come
}

This defines our function called Ripple with the following parameters:

  • float2 position: represents the current pixel’s position that the shader is processing.
  • SwiftUI::Layer layer: This is a reference to the layer (or image) being rendered. It allows the shader to sample the original image’s pixels.
  • float2 origin: The starting point of the ripple effect, typically the location where the user tapped.
  • float time: The elapsed time since the ripple effect started. This value drives the animation, determining how the ripple evolves over time.
  • float amplitude: controls the maximum displacement of the pixels (i.e., how strong the ripple is).
  • float frequency: determines the number of ripples (or wave cycles) that appear.
  • float decay: controls how quickly the ripple effect diminishes over time.
  • float speed: influences the rate at which the ripple propagates outward from the origin.

Now, let’s think through how a ripple effect works: For each pixel of the image, the shader will calculate:

  1. Distance & Delay: Each pixel’s effect is delayed based on its distance from the ripple’s origin, ensuring the effect propagates outward over time.
  2. Ripple (Oscillatory) Displacement: A sine wave, modulated by an amplitude, provides the oscillatory behavior of the ripple, while an exponential decay ensures that the ripple’s strength decreases over time.
  3. Determining the Direction of the Ripple: The displacement is applied radially from the origin by using a normalized vector from the origin to each pixel.
  4. Image Distortion: Finally, the shader samples the image at this new, displaced position and slightly adjusts the pixel color to enhance the effect.
  5. Return the final color.

This combination of geometric transformation and time-based oscillation creates a visually appealing ripple that mimics the behavior of water ripples, providing a dynamic and interactive effect.

Replace “// more code to come” inside the curly brackets with the following codes:

1. Distance and Delay:
The shader computes its distance from the origin (the touch point). This distance is important because the ripple effect should not reach every pixel at the same time—the effect radiates outward. Based on the distance calculated and the speed parameter, a delay is calculated. This means that pixels further from the origin experience the ripple effect later. Subtracting this delay from the global elapsed time effectively staggers the ripple effect across the image.

C++
float distance = length(position - origin);
float delay = distance / speed;
time -= delay;
time = max(0.0, time);

The last line ensures that if the effective time (after subtracting the delay) is negative (i.e., the ripple hasn’t “reached” that pixel yet), it is clamped to zero.

2. Calculating the Ripple Displacement is done by:

C++
float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time);

The sin(frequency * time) part generates a sine wave. The sine function creates the oscillating behavior, which is at the heart of any ripple effect. The frequency determines how many cycles (or waves) occur in a given time. The amplitude scales the whole sine wave. This controls the maximum displacement—how strong or pronounced the ripple is. The exponential decay (exp(-decay * time)) gradually reduces the ripple’s strength over time. This decay makes sure that the ripple effect fades out, just as water ripples gradually diminish after a disturbance.
The combination of these factors means that for each pixel:

  • The ripple starts with full strength (at the right time after the delay),
  • Oscillates according to a sine wave,
  • And then diminishes as time goes on.

3. Determining the Direction of the Ripple
We need the normalized vector pointing from the origin of the ripple to the current pixel’s position

C++
float2 n = normalize(position - origin);

4. Image Distortion – Applying the displacement
The new position for sampling the original image is computed by moving the current position along the direction n by an amount equal to rippleAmount:

C++
float2 newPosition = position + rippleAmount * n;

This creates the illusion that the image itself is being distorted by a ripple.
The shader then samples the image at newPosition rather than the original position. This is what creates the distortion.
By

C++
half4 color = layer.sample(newPosition);
color.rgb += 0.3 * (rippleAmount / amplitude) * color.a;

the color’s RGB components are subtly modified:
– It adds a fraction (scaled by 0.3 and modulated by the ripple’s normalized strength (rippleAmount / amplitude)) of the original color’s alpha to the RGB channels. The constant 0.3 scales this normalized value down, ensuring that the brightness adjustment is subtle rather than overwhelming. It acts as a fine-tuning factor for how pronounced the lighting change should be. Using the alpha channel (opacity) of the pixel ensures that the effect is proportional to how opaque the pixel is. For more opaque areas, the ripple’s effect on brightness is stronger; for more transparent areas, it’s weaker.
– Finally, this computed value is added to the red, green, and blue components of the pixel’s color. This effectively lightens (or darkens) the pixel slightly depending on whether the ripple displacement is positive or negative, enhancing the visual effect of the ripple.

5. Returning the final color
Finally, the modified color is returned:

C++
return color;

and when this happens for every pixel, you see a ripple effect that starts at the touch point and propagates outward.

Step 3.3: Applying the Metal Shader Effect: RippleModifier

The RippleModifier is a SwiftUI view modifier that connects your animation timing to the actual visual effect by configuring and applying a Metal shader. It sets up and applies a Metal shader to create the visual ripple effect. It takes the current state of the animation (mainly the elapsed time and the touch origin) and feeds these values into a Metal shader that creates the ripple effect.

Go to File - New - File from Template... or simply press cmd + N, choose iOS Template and here Swift File, click Next, choose RippleModifer as the name of the new view, ensure that MetalRipples is selected under Group, check the tick box for Targets: MetalRipples, click Create.

As seen already in the Stripes code-along above, we need to set it up as a ViewModifier

Please insert:

Swift
import SwiftUI

struct RippleModifier: ViewModifier {

	func body(content: Content) -> some View {
	
	}
}

By defining several parameters—such as amplitude, frequency, decay, and speed—you can control the appearance of the ripple – as defined in the Ripple metal file. As a reminder, these parameters let you tweak how large the ripple is, how many ripples appear, how quickly they fade, and how fast they travel.

Therefore, inside the { } of the RippleModifier, before the func body, let’s define our parameters:

Swift
var origin: CGPoint
var elapsedTime: TimeInterval
var duration: TimeInterval
var amplitude: Double = 12
var frequency: Double = 15
var decay: Double = 8
var speed: Double = 1200

var maxSampleOffset: CGSize {
        CGSize(width: amplitude, height: amplitude)
    }

Play around with the values for amplitude, frequency, decay, and speed!

Next, we need to create an instance of our Metal shader Ripple and passing the current state and the parameters.

Inside the body let’s define our shader:

Swift
let shader = ShaderLibrary.Ripple(
            .float2(origin),
            .float(elapsedTime),
            .float(amplitude),
            .float(frequency),
            .float(decay),
            .float(speed)
        )

The shader will use these values to calculate the distortion at each pixel.

Below the shader let’s define

Swift
let maxSampleOffset = maxSampleOffset
let elapsedTime = elapsedTime
let duration = duration

We need those constants for the content.visualEffect closure that comes next. By creating local constants, you “capture” the current values of these properties at the time the modifier’s body is evaluated. This guarantees that the closure will use the intended values even if the properties change later.

Below the local constants, let’s apply the shader to the view’s layer:

Swift
content.visualEffect { view, _ in
	view.layerEffect(
		shader,
		maxSampleOffset: maxSampleOffset,
		isEnabled: 0 < elapsedTime && elapsedTime < duration
	)
}

The visualEffect method applies an effect to the view. view.layerEffect passes the shader to the layer, telling the GPU to use it when rendering the view. maxSampleOffset
limits how far the shader can offset pixels. It’s calculated based on the amplitude.

The effect is enabled only when elapsedTime is greater than 0 and less than the total duration. This means the ripple effect is only active while the animation is running.

Please note that while most of the animation timing is managed by SwiftUI, the visual distortion itself is offloaded to the GPU using Metal. This keeps your animation smooth and efficient.

Almost done – just one more!

Step 3.4: Animating the Ripple with Keyframes: RippleEffect.swift

The RippleEffect file is a SwiftUI view modifier that orchestrates the ripple animation. It ties together user interaction, animation timing, and the visual effect (implemented in the Metal shader via the RippleModifier).

Go to File - New - File from Template... or simply press cmd + N, choose iOS Template and here Swift File, click Next, choose RippleEffect as the name of the new view, ensure that MetalRipples is selected under Group, check the tick box for Targets: MetalRipples, click Create.

RippleEffect defines a generic view modifier that can be applied to any SwiftUI view. origin is the starting point of the ripple—the location where the user tapped on the view. The trigger ensures that every time it changes, the animation will restart. And we’ll set the duration of the animation to a fixed 3(of course, you can play around with this!):

Swift
import SwiftUI

struct RippleEffect<T: Equatable>: ViewModifier {
    var origin: CGPoint
    var trigger: T
    var duration: TimeInterval { 3 }

	func body(content: Content) -> some View {
		// more code to come
	}
}

We need to Initialize the ripple effect with the starting point (origin) and the animation trigger. Therefore below the definition of duration insert:

Swift
init(at origin: CGPoint, trigger: T) {
    self.origin = origin
    self.trigger = trigger
}

Let’s now apply the keyframe animation to the view. Replace // more code to come by:

Swift
let origin = origin
let duration = duration

content.keyframeAnimator(
	initialValue: 0,
	trigger: trigger
) { view, elapsedTime in
	view.modifier(RippleModifier(
		origin: origin,
		elapsedTime: elapsedTime,
		duration: duration
	))
} keyframes: { _ in
	MoveKeyframe(0)
	LinearKeyframe(duration, duration: duration)
}

What does this do?

The keyframeAnimator method sets up an animation that starts at 0 seconds (initialValue: 0), and restarts whenever the trigger changes (for example, every new tap).

The closure { view, elapsedTime in ... } is called on each frame of the animation. It passes an elapsedTime value that increases from 0 to the full duration. This elapsed time is then fed into the RippleModifier.

The modifier creates a new instance of RippleModifier with the origin (touch location), the current elapsedTime (driving the progression of the ripple), and the total duration of the animation. The RippleModifier then uses these values to apply a Metal shader that distorts the view, creating the visual ripple effect.

The keyframes (MoveKeyframe(0) and LinearKeyframe(duration, duration: duration)) define how the animation transitions, i.e. the animation starts at time 0, it progresses linearly over the specified duration.

This layer is responsible for “driving” the animation. When the counter changes, the keyframe animation starts and repeatedly applies a modifier that uses the current time to update the ripple effect.

Almost there! The last step is to apply this RippleEffect modifier to our image.

Step 3.5: Apply the RippleEffect to the image

We can now apply the RippleEffect modifier to our image. Below the .onPressingChanged modifier, add:

.modifier(RippleEffect(at: origin, trigger: counter))

To recap, the RippleEffect file acts as the “conductor” of the ripple animation. It listens for a new trigger, starts a keyframe animation, and passes the necessary data (like the elapsed time and origin) to the RippleModifier, which then drives the visual ripple using a Metal shader. This modular approach makes your code clean, reusable, and easier to understand

That’s it! You can try it in the preview – the ripple effect is working!

Step 4: Add both views to ContentView

Finally, let’s include both views, StripesView and RippleEffectView into our ContentView with a TabView which we already introduced in our “ScrollViews Part 1”.

Therefore, in ContentView in the body replace the VStack(that holds the globe and the text) by

Swift
TabView {
	StripesView()
		.tabItem {
			Image(systemName: "text.justify")
			Text("Stripes")
		}

	RippleEffectView()
		.tabItem {
			Image(systemName: "wave.3.up")
			Text("Ripples")
		}
}

Step 5: Run your app

You see already a preview of your app on the right-hand side in the canvas. To run your app in the simulator, please select a device at the very top-middle of the XCode window – you can also add your own physical device – and press the Play button or simply use Cmd + R.

Congratulations!

You’ve successfully built a stripes shader and a ripple effect using Metal Shader! 🎉

What you have learned

In this code-along, you’ve learned how to

  • Integrate Metal shaders with SwiftUI to create high-performance visual effects.
  • Write a custom Metal shader to simulate dynamic effects like ripples.
  • Use SwiftUI view modifiers and keyframe animations to drive GPU-based animations.
  • Capture and handle user touch interactions using a UIKit gesture recognizer integrated into SwiftUI.
  • Structure your project for modularity and reusability when working with advanced graphics techniques.

That’s a lot to cover in just one code-along! You made it this far, very well done! 🎉

That’s a wrap!

Keep learning, keep building, and let your curiosity guide you. Happy coding! ✨

The only way to do great work is to love what you do. If you haven’t found it yet, keep looking. Don’t settle. — Steve Jobs


Download the full project on GitHub: https://github.com/swiftandcurious/MetalRipple.git