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 PyTorch, TensorFlow, 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
- Open Xcode: Launch Xcode and select Create a new Xcode project.
- Choose Template: Select App under the iOS tab and click Next.
- 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:
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:
#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?
- 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 (likefloat2
andhalf4
) without prefixing them withmetal::
.
- 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. - The
Stripes
function signature
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 ahalf4
) 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.
- Calculating the Stripe index is done by
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:
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:
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:
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 to20
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 count
are 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:
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
:
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:
.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 y
are the amount to offset the shadow horizontally and vertically, respectively, from the view.
To implement a ripple effect, we need the following steps:
- 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
andcounter
). - Create the Metal shader
Ripple
that calculates a ripple effect by shifting pixel positions based on a sine wave that decays over time. - Create a
RippleModifier
which will be triggered by a change incounter
. This animation runs for 3 seconds, continuously updating theelapsedTime
. To every frame of the animation, we will apply theRippleModifier
. TheRippleModifier
will create and apply a Metal shader that distorts the image based on the touch location and the elapsed time. - We will wrap the
RippleModifier
into aViewModifier
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:
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:
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:
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:
@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)
):
.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:
#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:
- Distance & Delay: Each pixel’s effect is delayed based on its distance from the ripple’s origin, ensuring the effect propagates outward over time.
- 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.
- 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.
- Image Distortion: Finally, the shader samples the image at this new, displaced position and slightly adjusts the pixel color to enhance the effect.
- 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.
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:
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
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
:
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
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:
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:
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:
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
:
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
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:
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!):
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:
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:
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
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