Ripple Effect with SwiftUI and Metal Shaders (+ a custom water sceneđź’§)

Victoria Petrova
7 min readJun 25, 2024

--

I had a great time exploring Apple’s demo projects for iOS 18, and loved the interactive ripple effect. So I adopted it to work on iOS17 in my new app, check it out below:

In this article, you will find:

  • an introduction to the Metal Shader API from WWDC24;
  • an example Ripple effect with sample code for iOS 17;
  • a bonus example: a Shower scene using the Ripple effect.

You can find the original Apple demo for custom visual effects here. The sample code for the Ripple effect is heavily based and adapted from that tutorial.

Metal Shaders

Metal shaders are a great API in SwiftUI to create custom visual effects. They’re used to create the famous Mesh Gradient in iOS 18 for example.

To use a metal shader, see the code cell below.

let shader = ShaderLibrary.Ripple(
.float2(origin),
.float(elapsedTime),

// Parameters
.float(amplitude),
.float(frequency),
.float(decay),
.float(speed)
)

someView.layerEffect(
shader,
maxSampleOffset: maxSampleOffset,
isEnabled: 0 < elapsedTime && elapsedTime < duration
)

We need to define a shader function, like Ripple. There are three types of shader effects: Color, Distortion, and Layer Effects. When we use the layer effect on a view, the shader function is applied to every pixel on the view. The Apple demo explains how this is made possible:

To make this possible in real time, Shaders run on your device’s GPU which is optimized for highly parallel tasks such as this. However, because of the specialized nature of GPU programming, the Shaders themselves cannot be written in Swift. Instead, they are written in the Metal Shading Language, or Metal for short. — timestamp from demo

Ripple Effect

Here is the definition of the Metal function for the Ripple effect:

//  Ripple.metal

/*
See the LICENSE at the end of the article for this sample’s licensing information.

Abstract:
A shader that applies a ripple effect to a view when using it as a SwiftUI layer
effect.
*/

#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
) {
// The distance of the current pixel position from `origin`.
float distance = length(position - origin);
// The amount of time it takes for the ripple to arrive at the current pixel position.
float delay = distance / speed;

// Adjust for delay, clamp to 0.
time -= delay;
time = max(0.0, time);

// The ripple is a sine wave that Metal scales by an exponential decay
// function.
float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time);

// A vector of length `amplitude` that points away from position.
float2 n = normalize(position - origin);

// Scale `n` by the ripple amount at the current pixel position and add it
// to the current pixel position.
//
// This new position moves toward or away from `origin` based on the
// sign and magnitude of `rippleAmount`.
float2 newPosition = position + rippleAmount * n;

// Sample the layer at the new position.
half4 color = layer.sample(newPosition);

// Lighten or darken the color based on the ripple amount and its alpha
// component.
color.rgb += 0.3 * (rippleAmount / amplitude) * color.a;

return color;
}

To apply the Ripple layer effect we defined in the Metal file, we want to call it from SwiftUI using a RippleModifier that exposes the parameters needed for the Metal function.

/* See the LICENSE at the end of the article for this sample's licensing information. */

/// A modifier that applies a ripple effect to its content.
struct RippleModifier: ViewModifier {
var origin: CGPoint

var elapsedTime: TimeInterval

var duration: TimeInterval

var amplitude: Double
var frequency: Double
var decay: Double
var speed: Double

func body(content: Content) -> some View {
let shader = ShaderLibrary.Ripple(
.float2(origin),
.float(elapsedTime),

// Parameters
.float(amplitude),
.float(frequency),
.float(decay),
.float(speed)
)

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

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

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

We need to trigger the animation by using SwiftUI since Metal effects don’t have the concept of time built into them. We can do that with the help of a RippleEffect view modifier. This allows us to drive the animation based on things like gestures.

/* See the LICENSE at the end of the article for this sample's licensing information. */

struct RippleEffect<T: Equatable>: ViewModifier {

var origin: CGPoint
var trigger: T
var amplitude: Double
var frequency: Double
var decay: Double
var speed: Double

init(at origin: CGPoint, trigger: T, amplitude: Double = 12, frequency: Double = 15, decay: Double = 8, speed: Double = 1200) {
self.origin = origin
self.trigger = trigger
self.amplitude = amplitude
self.frequency = frequency
self.decay = decay
self.speed = speed
}

func body(content: Content) -> some View {
let origin = origin
let duration = duration
let amplitude = amplitude
let frequency = frequency
let decay = decay
let speed = speed

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

var duration: TimeInterval { 3 }
}

If you want to make a view, like a circle or an image, feel more interactive and alive, the Ripple Effect is great. Let’s trigger it with an onTapGesture:

struct ContentView: View {

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

var body: some View {
VStack {
Circle().fill(Color.blue) // You can replace this with your view
.modifier(RippleEffect(at: origin, trigger: counter))
.onTapGesture { location in
origin = location
counter += 1
}
}
.padding()
}
}

At the end the result is a super fun view that feels much more responsive to the user! Check out the Ripple Effect on a Circle view combined with another Metal shader function — the Stripe effect.

Circle View with Ripple and Stripe Metal Shaders

Now what?

You can try creating your own Metal shaders to elevate your UI. You can also experiment with the parameters of the Ripple effect and apply it to different contexts. And most importantly have fun while creating new things!

I played with the amplitude, frequency, decay, and speed to create a new water drop effect that you’ll find in the bonus section.

Bonus: Shower Scene with Ripple effect đź’§

Custom Shower Scene with a Water Ripple Effect

I adopted the Ripple effect to simulate a water drop falling from a shower on a water surface. In the previous example the Ripple effect was triggered through a gesture. Now we want to trigger it once the water drop reaches the surface of the water.

The water drop starts at a particular position: dropPosition: CGPoint. Once the view appears we make the waterdrop startFalling() towards its targetWaterDropPosition, like this:

struct WaterDropScene: View {
@State private var counter: Int = 0
@State private var dropPosition: CGPoint = CGPoint(x: UIScreen.main.bounds.width/2, y: 50)
private var targetWaterDropPosition: CGPoint = CGPoint(x: UIScreen.main.bounds.width/2, y: 600)
@State private var waterHeight: CGFloat = 200.0

var body: some View {
ZStack {
tiles
.ignoresSafeArea()
showerHead

// Falling water drop
Raindrop()
.frame(width: 20, height: 40)
.position(dropPosition)
.onAppear {
startFalling()
}

// Water surface
ZStack {
Rectangle()
.fill(Color.white)
Rectangle()
.fill(Color.blue.opacity(0.7))
}
.frame(width: UIScreen.main.bounds.width+20, height: waterHeight)
.position(x: targetWaterDropPosition.x, y: targetWaterDropPosition.y + waterHeight/2 + 20)
.modifier(RippleEffect(at: targetWaterDropPosition, trigger: counter, amplitude: -12, frequency: 15, decay: 8, speed: 800))
}
}
... }}

So how do we know the location of the “tapGesture” to trigger the Ripple Effect? To put it simply, the water-drop “taps” the water surface at its final location, or in other words at the targetWaterDropPosition we had defined earlier. This is the CGPoint we use to trigger the Ripple Effect instead of a onTapGesture.

In the startFalling() function, once the animation for the water drop completes, we trigger the Ripple effect of the water by increasing the counter variable like before.

func startFalling() {
let duration = 2.0
withAnimation(Animation.easeIn(duration: duration)) {
dropPosition = targetWaterDropPosition
}

DispatchQueue.main.asyncAfter(deadline: .now() + duration ) {
counter += 1
// Reset the drop position and start again
dropPosition = CGPoint(x: targetWaterDropPosition.x, y: 50)
startFalling()
}
}

To make the Ripple Effect look realistic I had to modify the amplitude, frequency, decay, and speed parameters.

  • The amplitude now has a negative value so that the water surface bends downwards when the water-drop hits the surface.
  • The speed was decreased to make the water ripple slower.

You can also play with the speed of the water-drop (aka the duration of the animation) and the corresponding ripple effect it would trigger if it falls faster or slower.

You can find all of the code here.

Conclusion

The Metal Shaders API are extremely powerful and can elevate your UI (or you can just have some fun with them). I hope you enjoyed this tutorial and let me know what you think and create! 🚀

Resources

  • Check out this GitHub repo for the code.
  • Find Apple’s demo on Custom Visual effects here. It has more examples and implementations for iOS 18.
  • Follow me on X (Twitter) for more similar content here.

Acknowledgments

Again, the sample code for the Ripple effect is heavily based and adapted from this Apple demo with the following License:

Copyright © 2024 Apple Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

The water drop shape was taken from the Fabula project following the MIT licensee. You can find the project in this GitHub repo. Here’s the MIT License

--

--

Victoria Petrova

🚀Building iOS apps 📚Uni of Cambridge Masters in Management 💻Ex SWE Uber🔥Passionate about making technology that serves people👉https://bento.me/vickipetrova