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!

Meshing: Explore Mesh Gradients for SwiftUI!
Meshing is the tool you need for working with stunning MeshGradients in SwiftUI with ease!Key Features:- Intuitive visual editor for 2x2, 3x3, and 4x4 MeshGradients- Real-time preview of your gradient creations- Precise control over color positions with draggable points- Customizable background color- Toggle for smooth color transitions- One-click code generation for integration into your SwiftUI projectsPerfect for:- Apple Platforms Developers- UI/UX Designers- SwiftUI Enthusiasts- Anyone looking to add vibrant, complex gradients to their appsBoost Your Workflow!- Experiment with complex gradients in seconds- Save hours of manual coding and tweaking- Achieve professional-looking results quicklyI was tired of working with Previews in Xcode for mesh gradients, so I created an app for it. MeshGradients is a fun visual experience. No more guesswork or manual coding – design the perfect gradient visually and let Meshing generate the SwiftUI code for you!!System Requirements:- macOS 15.0, iOS 18.0, iPadOS 18.0, visionOS 2.0 or later- Xcode 16.0 or later (for use in SwiftUI projects)Download now and start creating beautiful, complex gradients in minutes!

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:

0:00
/0:15

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:

  1. We define a LabelView that contains our text with the desired styling.
  2. 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.
  3. We set the MeshGradient as the background of this clear LabelView. This allows the gradient to stretch across the entire area occupied by the text.
  4. The TimelineView and animatedPositions 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):

0:00
/0:08

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:

  1. We apply a .mask() modifier to our gradient-filled LabelView.
  2. Inside the mask, we create another instance of LabelView.
  3. This mask ensures that the gradient is only visible within the shapes of the letters in our text.

And this is the final outcome:

0:00
/0:16

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!