Exploring SwiftUI: Animating Mesh Gradient on Text in iOS 18
I am fascinated by mesh gradients. The SwiftUI team released MeshGradient
in SwiftUI for iOS 18, macOS 15, watchOS 11 and visionOS 2. I even made an app, Meshing, to play around with it!
In this post, we will explore how to animate the mesh gradient on a Text
label in SwiftUI.
Setting Up the Structure
First, let us look at the overall structure of our AnimatedMeshGradientText
view:
struct AnimatedMeshGradientText: View {
@State private var positions: [SIMD2<Float>] = [
// ... (position array)
]
private let colors: [Color] = [
// ... (color array)
]
var body: some View {
LabelView()
.foregroundStyle(.clear)
.background(
TimelineView(.animation) { phase in
MeshGradient(
// ... (MeshGradient parameters)
)
}
)
.mask(LabelView())
}
// ... (animatedPositions function)
}
struct LabelView: View {
var body: some View {
Text("Meshing")
.font(.system(size: 150))
.bold()
.fontWidth(.expanded)
}
}
We will use this structure to layer our components, with a LabelView
for the text and a MeshGradient
for the animated background.
Defining the Gradient
The gradient is defined by two key components:
@State private var positions: [SIMD2<Float>] = [
SIMD2<Float>(0.0, 0.0),
SIMD2<Float>(0.5, 0.0),
SIMD2<Float>(1.0, 0.0),
SIMD2<Float>(0.0, 0.5),
SIMD2<Float>(0.45, 0.55),
SIMD2<Float>(1.0, 0.5),
SIMD2<Float>(0.0, 1.0),
SIMD2<Float>(0.5, 1.0),
SIMD2<Float>(1.0, 1.0)
]
let colors: [Color] = [
Color(red: 0.922, green: 0.000, blue: 0.000),
Color(red: 1.000, green: 0.535, blue: 0.000),
Color(red: 0.924, green: 0.915, blue: 0.000),
Color(red: 1.000, green: 0.000, blue: 0.465),
Color(red: 0.000, green: 0.749, blue: 1.000),
Color(red: 0.000, green: 1.000, blue: 0.603),
Color(red: 0.576, green: 0.000, blue: 1.000),
Color(red: 1.000, green: 0.000, blue: 0.733),
Color(red: 0.000, green: 0.980, blue: 0.864)
]
The positions
array defines the control points for our gradient, while the colors
array specifies the colors used in the gradient:
TimelineView(.animation) { phase in
MeshGradient(
width: 3,
height: 3,
locations: .points(positions),
colors: .colors(colors),
background: Color(red: 0.000, green: 0.000, blue: 0.000),
smoothsColors: true
)
}
.ignoresSafeArea()
This is how it would look:
Animating the Gradient
For animations, we create a customanimatedPositions
method:
func animatedPositions(for date: Date) -> [SIMD2<Float>] {
let phase = CGFloat(date.timeIntervalSince1970)
var animatedPositions = positions
animatedPositions[1].x = 0.5 + 0.4 * Float(cos(phase))
animatedPositions[3].y = 0.5 + 0.3 * Float(cos(phase * 1.1))
animatedPositions[4].y = 0.5 - 0.4 * Float(cos(phase * 0.9))
animatedPositions[5].y = 0.5 - 0.2 * Float(cos(phase * 0.9))
animatedPositions[7].x = 0.5 - 0.4 * Float(cos(phase * 1.2))
return animatedPositions
}
This uses cosine waves to create smooth, cyclical animations after exploring and playing around with the middle control points of the gradient. Thephase
is derived from the current time, ensuring continuous animation:
var body: some View {
TimelineView(.animation) { phase in
MeshGradient(
width: 3,
height: 3,
locations: .points(animatedPositions(for: phase.date)),
colors: .colors(colors),
background: Color(red: 0.000, green: 0.000, blue: 0.000),
smoothsColors: true
)
}
.ignoresSafeArea()
}
func animatedPositions(for date: Date) -> [SIMD2<Float>] {
let phase = CGFloat(date.timeIntervalSince1970)
var animatedPositions = positions
animatedPositions[1].x = 0.5 + 0.4 * Float(cos(phase))
animatedPositions[3].y = 0.5 + 0.3 * Float(cos(phase * 1.1))
animatedPositions[4].y = 0.5 - 0.4 * Float(cos(phase * 0.9))
animatedPositions[5].y = 0.5 - 0.2 * Float(cos(phase * 0.9))
animatedPositions[7].x = 0.5 - 0.4 * Float(cos(phase * 1.2))
return animatedPositions
}
This is how the animation looks like:
Stretching the Gradient
First, we use a label to stretch the gradient to the exact dimensions of our text. This ensures that the entire gradient is visible and properly positioned:
struct AnimatedMeshGradientText: View {
var body: some View {
LabelView()
.foregroundStyle(.clear)
.background(
TimelineView(.animation) { phase in
MeshGradient(
width: 3,
height: 3,
locations: .points(animatedPositions(for: phase.date)),
colors: .colors(colors),
background: Color(red: 0.000, green: 0.000, blue: 0.000),
smoothsColors: true
)
}
)
}
}
Here is what is happening in this part:
- We define a
LabelView
that contains our text with the desired styling. - In the main view, we create an instance of
LabelView
with a clear foreground style. This acts as a placeholder, giving us the exact dimensions and position of our text. - We set the
MeshGradient
as the background of this clearLabelView
. This allows the gradient to stretch across the entire area occupied by the text. - The
TimelineView
andanimatedPositions
function work together to create the flowing animation effect in the gradient.
At this point, if we were to render the view, we would see the animated gradient in the shape of our text, but it would extend beyond the boundaries of the letters (made the foreground color white to show the difference):
Masking the Gradient to the Text
Now that we have our gradient stretched to the right size, we need to mask it so it only appears within the bounds of our text:
var body: some View {
LabelView()
.foregroundColor(.clear)
.background(TimelineView(.animation) { phase in
MeshGradient(
width: 3,
height: 3,
locations: .points(animatedPositions(for: phase.date)),
colors: .colors(colors),
background: Color(red: 0.000, green: 0.000, blue: 0.000),
smoothsColors: true
)
})
.mask(LabelView())
}
Here is what this final step does:
- We apply a
.mask()
modifier to our gradient-filledLabelView
. - Inside the mask, we create another instance of
LabelView
. - This mask ensures that the gradient is only visible within the shapes of the letters in our text.
And this is the final outcome:
The mask uses the alpha channel of the masking view. The parts of the gradient that align with the opaque parts of the text (the letter shapes) remain visible, while the rest becomes transparent.
By using the same LabelView
for both stretching the gradient and masking it, we ensure an alignment between the gradient and the text shape. If there is a better way to do this, please let me know!
Moving Forward
The result is a beautifully animated gradient that flows within the boundaries of our text, creating a gorgeous effect. I love the arcs that are created inside the letters from the mesh gradient!
Moving forward, you can consider experimenting with these aspects:
- Animation Patterns: Try adjusting the animation function in
animatedPositions
. You could:
- Modify the values for the cosine waves
- Mix and explore with sine trigonometric functions, too
- Color Schemes: Play with different color palettes in the
colors
array. You might:
- Implement complementary colors for high contrast
- Create gradients that reflect specific moods or themes, like galaxy or tropic sunset
- Text Styling: Experiment with different text attributes in
LabelView
:
- Try various fonts, weights, and sizes
- Adjust letter spacing or line height for different effects
- Use multi-line text to see how the gradient flows across lines!
Explore with mesh gradients, and explore some more. Mesh gradients everywhere, not a pixel to spare!
Happy meshing!