Oscillating Glowing Strings with Metal and SwiftUI
May 4, 2025
It's been a while since the last post. Let's make up for it with a new UI experiment, in which we'll create this button with an interactive animation inside.
The front end is made with SwiftUI and the animation itself is written in Metal.
As usual, the source code is at the end of the article. Without further ado, let's get down to crafting.
Harmonic Function
Let's start by defining the base component. For this experiment, it will be an empty button.
The only unusual things about it are its convenient frame size and the immediately applied HarmonicStyle
type, which is an implementation of ButtonStyle
.
struct HarmonicButton: View {
var body: some View {
Button(
action: {},
label: {}
)
.frame(width: 240.0, height: 70.0)
.buttonStyle(HarmonicStyle())
}
}
The HarmonicStyle
structure will be our main object of study and change. From the beginning, it already contains a bit of logic for shader animation.
Notably, here we calculate the animation time ourselves (although we could have used the date from the TimelineView
context). This approach will help us to control the animation speed in the future.
struct HarmonicStyle: ButtonStyle {
@State private var elapsedTime: Double = 0.0
private let updateInterval: Double = 0.016
func makeBody(configuration: Configuration) -> some View {
TimelineView(.periodic(from: .now, by: updateInterval)) { context in
configuration.label
.spatialWrap(Capsule(), lineWidth: 1.0)
.background {
Rectangle()
.colorEffect(ShaderLibrary.default.harmonicColorEffect(
.boundingRect, // bounding rect
.float(6), // waves count,
.float(elapsedTime), // animation clock
))
}
.clipShape(Capsule())
.onChange(of: context.date) { _, _ in
elapsedTime += updateInterval
}
}
}
}
To get a better understanding of how SwiftUI and Metal interact, I suggest you start with this article:
Next we define the shader function. Following the choice of the colorEffect
modifier we need to define a function in a certain format:
- returns the resulting color in
half4
format - first parameter - 2D coordinate of the processed pixel
- second parameter - color of that pixel
The rest of the parameters you may have already guessed - these are the ones we pass in the modifier call on the SwiftUI side.
First we do some preparatory work and normalize the pixel position in the uv
variable. Immediately after that we prepare some values for the harmonic function.
[[ stitchable ]]
half4 harmonicColorEffect(
float2 pos,
half4 color,
float4 bounds,
float wavesCount,
float time
) {
float amplitude = 0.5;
float2 uv = pos / float2(bounds.z, bounds.w);
uv -= float2(0.5, 0.5);
float a = cos(uv.x * 3.0) * amplitude * 0.2;
float offset = sin(uv.x * 12.0 + time) * a * 0.1;
float frequency = 3.0;
float3 finalColor = float3(0.0);
for (float i = 0.0; i < wavesCount; i++) {
float phase = time + i * M_PI_F / wavesCount;
float sdfDist = harmonicSDF(uv, a, offset, frequency, phase);
float3 waveColor = float3(1.0);
finalColor += waveColor * sdfDist;
}
return half4(half3(finalColor), 1.0);
}
The equation of a harmonic oscillator is as follows.
x(t) = A * sin(w * t + p)
Where t
represents time, w
defines the frequency and p
defines the phase of oscillation.
float harmonicSDF(float2 uv, float a, float offset, float f, float phi) {
return abs((uv.y - offset) + cos(uv.x * f + phi) * a);
}
Essentially, we combine several functions of this type so that their different characteristics also oscillate. This is called modulation.
The for-loop in the original code snippet helps us create as many waves as we want. To make them different, we shift the phase of each successive wave based on its index.
The variable sdfDist
contains a coefficient that indicates whether a color should be present or not (in other words 0 or 1, taking into account interpolation).
`
for (float i = 0.0; i < wavesCount; i++) {
float phase = time + i * M_PI_F / wavesCount;
float sdfDist = harmonicSDF(uv, a, offset, frequency, phase);
float3 waveColor = float3(1.0);
finalColor += waveColor * sdfDist;
}
To learn more about the Signed Distance Field technique, I would suggest you start with the XOR blog:
If you run the code, you will see the following result. I agree, it is not very impressive yet. Let's fix it with a couple of lines of code.
The glow effect always comes to the rescue. This is not the most efficient implementation, but for our purposes it is suitable. If you are looking for alternatives, consider to use MPSImageGaussianBlur
.
float glow(float x, float str, float dist){
return dist / pow(x, str);
}
Then enter a couple of variables to control the glow effect and update the SDF call by wrapping it in a glow function call.
float glowWidth = 0.6;
float glowIntensity = 0.02;
...
float sdfDist = glow(harmonicSDF(uv, a, offset, frequency, phase), glowWidth, glowIntensity);
You should now be able to observe each wave separately from each other.
The oscillator SDF function we introduced here is based on this great ShaderToy experiment.
We've just dealt with the basis of all animation. Let's now add some responsiveness to user interactions.
Harmonic Interactions
The main interaction will be based on tracking the state of the button press. As this state changes, we will update the various oscillator variables.
We will start this part by introducing a couple of new states in HarmonicStyle
. The amplitude
variable will help us control the amplitude of the oscillator (i.e. how big the waves will be).
The speedMultiplier
will control the speed of these waves. The logic behind it is simple.
By introducing updateInterval / speedMultiplier
evaluation we tell the TimelineView
how often we want to update the animation clock (greater speedMultipler
means more frequent updates).
struct HarmonicStyle: ButtonStyle {
@State private var speedMultiplier: Double = 1.0
@State private var amplitude: Float = 0.5
@State private var elapsedTime: Double = 0.0
private let updateInterval: Double = 0.016
func makeBody(configuration: Configuration) -> some View {
TimelineView(.periodic(from: .now, by: updateInterval / speedMultiplier)) { context in
configuration.label
.background {
Rectangle()
.colorEffect(ShaderLibrary.default.harmonicColorEffect(
.boundingRect, // bounding rect
.float(6), // waves count,
.float(elapsedTime), // animation clock
.float(amplitude) // amplitude
))
}
.clipShape(Capsule())
.onChange(of: context.date) { _, _ in
elapsedTime += updateInterval
}
}
}
}
The important part here is to keep track of changes in the pressed state. For this we use the onChange
modifier, passing configuration.isPressed
as a trigger. The newValue
variable corresponds to the next pressed state.
struct HarmonicStyle: ButtonStyle {
...
func makeBody(configuration: Configuration) -> some View {
TimelineView(.periodic(from: .now, by: updateInterval / speedMultiplier)) { context in
...
}
.onChange(of: configuration.isPressed) { _, newValue in
withAnimation(.spring(duration: 0.3)) {
amplitude = newValue ? 2.0 : 0.5
speedMultiplier = newValue ? 2.0 : 1.0
}
}
}
}
On the shader side, all the changes we need at this point are to make amplitude
a function parameter rather than a local variable.
[[ stitchable ]]
half4 harmonicColorEffect(
float2 pos,
half4 color,
float4 bounds,
float wavesCount,
float time,
float amplitude
) {
...
}
Checkpoint to see that we are doing great.
Let's take the animation a little further and make more parameters depend on the state of the press. To do this, we'll pass one more parameter to the shader call: a coefficient that determines whether the button is pressed or not.
Notice that we also update the elapsedTime
calculation with multiplying the updateInterval
value by the speedMultiplier
. This change helps us to avoid a speed drop a bit later.
struct HarmonicStyle: ButtonStyle {
...
func makeBody(configuration: Configuration) -> some View {
TimelineView(.periodic(from: .now, by: updateInterval / speedMultiplier)) { context in
configuration.label
.background {
Rectangle()
.colorEffect(ShaderLibrary.default.harmonicColorEffect(
.boundingRect, // bounding rect
.float(6), // waves count,
.float(elapsedTime), // animation clock
.float(amplitude), // amplitude
.float(configuration.isPressed ? 1.0 : 0.0) // press coeff
))
}
.clipShape(Capsule())
.onChange(of: context.date) { _, _ in
elapsedTime += updateInterval * speedMultiplier
}
}
...
}
}
Update the shader function signature by introducing another parameter of the float
type. And update the oscillator parameters with the mix
function call. This function provides a linear interpolation between two values based on the coefficient.
[[ stitchable ]]
half4 harmonicColorEffect(
float2 pos,
half4 color,
float4 bounds,
float wavesCount,
float time,
float amplitude,
float mixCoeff
) {
...
float frequency = mix(3.0, 12.0, mixCoeff);
float glowWidth = mix(0.6, 0.9, mixCoeff);
float glowIntensity = mix(0.02, 0.01, mixCoeff);
...
}
Let's add some colors. Here is the color palette for this article.
float3 getColor(float t) {
if (t == 0) {
return float3(0.4823529412, 0.831372549, 0.8549019608);
}
if (t == 1) {
return float3(0.4117647059, 0.4117647059, 0.8470588235);
}
if (t == 2) {
return float3(0.9411764706, 0.3137254902, 0.4117647059);
}
if (t == 3) {
return float3(0.2745098039, 0.4901960784, 0.9411764706);
}
if (t == 4) {
return float3(0.0784313725, 0.862745098, 0.862745098);
}
if (t == 5) {
return float3(0.7843137255, 0.6274509804, 0.5490196078);
}
return float3(0.0);
}
As alternative, you can use a per-channel calculation.
float3 getColor(float t) {
float r = 0.5 + 0.5 * cos(2.0 * M_PI_F * t);
float g = 0.5 + 0.5 * cos(2.0 * M_PI_F * t + 2.0 * M_PI_F / 3.0);
float b = 0.5 + 0.5 * cos(2.0 * M_PI_F * t + 4.0 * M_PI_F / 3.0);
return float3(r, g, b);
}
And don't forget to update the wave colour calculation. It is also set via mix, so in the normal state the animation will be light, but when you press it, it will shine with all the colors.
float3 waveColor = mix(float3(1.0), getColor(i), mixCoeff);
As a cherry on the cake, let's add a little more detail. These include a scale
effect, which when pressed will shrink the whole component a bit. And also haptic feedback, which adds another level of interactivity.
struct HarmonicStyle: ButtonStyle {
@State private var scale: CGFloat = 1.0
@State private var speedMultiplier: Double = 1.0
@State private var amplitude: Float = 0.5
@State private var elapsedTime: Double = 0.0
private let updateInterval: Double = 0.016
func makeBody(configuration: Configuration) -> some View {
TimelineView(.periodic(from: .now, by: updateInterval / speedMultiplier)) { context in
configuration.label
.spatialWrap(Capsule(), lineWidth: 1.0)
.background {
Rectangle()
.colorEffect(ShaderLibrary.default.harmonicColorEffect(
.boundingRect, // bounding rect
.float(6), // waves count,
.float(elapsedTime), // animation clock
.float(amplitude), // amplitude
.float(configuration.isPressed ? 1.0 : 0.0) // monochrome coeff
))
}
.clipShape(Capsule())
.scaleEffect(scale)
.onChange(of: context.date) { _, _ in
elapsedTime += updateInterval * speedMultiplier
}
}
.onChange(of: configuration.isPressed) { _, newValue in
withAnimation(.spring(duration: 0.3)) {
amplitude = newValue ? 2.0 : 0.5
speedMultiplier = newValue ? 2.0 : 1.0
scale = newValue ? 0.95 : 1.0
}
}
.sensoryFeedback(.impact, trigger: configuration.isPressed)
}
}
The spatialWrap
modifier is based on my article dedicated to this effect:
extension View {
@ViewBuilder
func spatialWrap(
_ shape: some InsettableShape,
lineWidth: CGFloat
) -> some View {
self
.background {
shape
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: .white.opacity(0.4), location: 0.0),
.init(color: .white.opacity(0.0), location: 0.4),
.init(color: .white.opacity(0.0), location: 0.6),
.init(color: .white.opacity(0.1), location: 1.0),
]),
startPoint: .init(x: 0.16, y: -0.4),
endPoint: .init(x: 0.2, y: 1.5)
),
style: .init(lineWidth: lineWidth)
)
}
}
}
Conclusion
Even despite the end result, this animation can still be improved and enhanced with new effects and optimizations. But I'm still surprised by how simple it is.
Here is the gist with the source code.
See you in the next experiments!