I am embracing my dramatic side of the personality, but this morning, I crossed limits. I woke up at 5 AM with an idea to add the new Siri animation when Meshing AI creates a new mesh gradient, and had something to show the X audience by 8 AM.

But I did not want to start from scratch. Luckily, I found some open-source work by Siddharth that gave me a great starting point.

GitHub - metasidd/PrototypeSiriAnimation
Contribute to metasidd/PrototypeSiriAnimation development by creating an account on GitHub.

I took that code, updated it with my mesh gradient animation logic, slowed it down to match to my taste, and created a Container view to use anywhere. And this post is how I went about it.

Animation Container with MeshingAIProgressView

The MeshingAIProgressView holds the animation. Here is what it looks like:

enum SiriState {
  case none
  case thinking
}

public struct MeshingAIProgressView<Content: View>: View {

  @Binding var showGenerateAIMeshGradientAnimation: Bool

  @State private var state: SiriState = .none
  @State private var gradientSpeed: Float = 0.03
  @State private var timer: Timer?
  @State private var maskTimer: Float = 0.0

  let content: Content

  public init(showGenerateAIMeshGradientAnimation: Binding<Bool>, @ViewBuilder content: () -> Content) {
    self.content = content()
    self._showGenerateAIMeshGradientAnimation = showGenerateAIMeshGradientAnimation
  }

  public var body: some View {
    ZStack {
      MeshingAIGradientView(maskTimer: $maskTimer, gradientSpeed: $gradientSpeed)
        .opacity(containerOpacity)
        .scaleEffect(1.2)

      if state == .thinking {
        RoundedRectangle(cornerRadius: 52, style: .continuous)
          .stroke(Color.white.gradient, style: .init(lineWidth: 2))
          .blur(radius: 4)
      }

      content
        .background(Color.adaptive)
        .mask {
          GeometryReader { geometry in
            AnimatedRectangle(size: geometry.size, cornerRadius: 48, t: CGFloat(maskTimer))
              .scaleEffect(computedScale)
              .blur(radius: animatedMaskBlur)
              .ignoresSafeArea()
              .frame(width: geometry.size.width, height: geometry.size.height)
          }
        }
    }
    .onChange(of: showGenerateAIMeshGradientAnimation, {
      withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
        if showGenerateAIMeshGradientAnimation {
          state = .thinking
        } else {
          state = .none
        }
      }
    })
    .onAppear {
      timer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in
        DispatchQueue.main.async {
          maskTimer += rectangleSpeed
        }
      }
    }
    .onDisappear {
      timer?.invalidate()
    }
  }

  private var computedScale: CGFloat {
    switch state {
      case .none: 1.2
      case .thinking: 1
    }
  }

  private var rectangleSpeed: Float {
    switch state {
      case .none: 0
      case .thinking: 0.03
    }
  }

  private var animatedMaskBlur: CGFloat {
    switch state {
      case .none: 8
      case .thinking: 10
    }
  }

  private var containerOpacity: CGFloat {
    switch state {
      case .none: 0
      case .thinking: 1.0
    }
  }
}

extension Color {
  static var adaptive: Color {
    Color(UIColor { traitCollection in
      switch traitCollection.userInterfaceStyle {
        case .dark:
          return .black
        case .light, .unspecified:
          return .white
        @unknown default:
          return .white
      }
    })
  }
}

We have several @State properties like state tells us if we are animating or not, and maskTimer helps control the speed of our animation.

Then, we use a ZStack to layer the views starting withMeshingAIGradientView, which provides the colorful background. On top of that, we have a white border that appears when we are in the "thinking" state. This adds a nice highlight effect at the edges.

  1. Finally, we have the content (whatever we are wrapping in this view), masked by the AnimatedRectangle. This is what gives us the cool morphing effect.
  2. We use the onChange modifier to start and stop our animation when showGenerateAIMeshGradientAnimation changes.
  3. The onAppear and onDisappear modifiers set up and tear down a timer that drives our animation.

Shaping with AnimatedRectangle

The AnimatedRectangle is a custom Shape that changes over time:

@MainActor
struct AnimatedRectangle: Shape {
  var size: CGSize
  var padding: Double = 2
  var cornerRadius: CGFloat
  var time: CGFloat

  nonisolated var animatableData: CGFloat {
    get { time }
    set { time = newValue }
  }

  func path(in rect: CGRect) -> Path {
    var path = Path()

    let width = size.width
    let height = size.height
    let radius = cornerRadius

    let initialPoints = [
      CGPoint(x: padding + radius, y: padding),
      CGPoint(x: width * 0.25 + padding, y: padding),
      CGPoint(x: width * 0.75 + padding, y: padding),
      CGPoint(x: width - padding - radius, y: padding),
      CGPoint(x: width - padding, y: padding + radius),
      CGPoint(x: width - padding, y: height * 0.25 - padding),
      CGPoint(x: width - padding, y: height * 0.75 - padding),
      CGPoint(x: width - padding, y: height - padding - radius),
      CGPoint(x: width - padding - radius, y: height - padding),
      CGPoint(x: width * 0.75 - padding, y: height - padding),
      CGPoint(x: width * 0.25 - padding, y: height - padding),
      CGPoint(x: padding + radius, y: height - padding),
      CGPoint(x: padding, y: height - padding - radius),
      CGPoint(x: padding, y: height * 0.75 - padding),
      CGPoint(x: padding, y: height * 0.25 - padding),
      CGPoint(x: padding, y: padding + radius)
    ]

    let initialArcCenters = [
      CGPoint(x: padding + radius, y: padding + radius), // Top-left
      CGPoint(x: width - padding - radius, y: padding + radius), // Top-right
      CGPoint(x: width - padding - radius, y: height - padding - radius), // Bottom-right
      CGPoint(x: padding + radius, y: height - padding - radius) // Bottom-left
    ]

    let points = initialPoints.map { point in
      CGPoint(
        x: point.x + 2 * sin(time + point.y * 0.1),
        y: point.y + 2 * sin(time + point.x * 0.1)
      )
    }

    let arcCenters = initialArcCenters.map { center in
      CGPoint(
        x: center.x + 0 * sin(time + center.y * 0.3),
        y: center.y + 0 * sin(time + center.x * 0.3)
      )
    }

    path.move(to: CGPoint(x: padding, y: padding + radius))

    path.addArc(center: arcCenters[0], radius: radius, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)

    for point in points[0...2] {
      path.addLine(to: point)
    }

    path.addArc(center: arcCenters[1], radius: radius, startAngle: .degrees(270), endAngle: .degrees(0), clockwise: false)

    for point in points[4...7] {
      path.addLine(to: point)
    }

    path.addArc(center: arcCenters[2], radius: radius, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)

    for point in points[8...10] {
      path.addLine(to: point)
    }

    path.addArc(center: arcCenters[3], radius: radius, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)

    for point in points[11...14] {
      path.addLine(to: point)
    }

    path.closeSubpath()

    return path
  }
}

This shape is basically a rectangle, but its edges move in a wave-like pattern to create that Siri effect.

We define the basic shape of our rectangle using initialPoints and initialArcCenters. We then animate these points using sine waves. The time parameter, which changes over time, is used in these calculations to make the points move. Finally, we draw the path using these animated points. We move to each point in order, drawing lines and arcs to create the shape.

The result is finally a rounded rectangle that seems to breathe and move!

Gradient with MeshingAIGradientView

The MeshingAIGradientView provides the colorful, animated background:

struct MeshingAIGradientView: View {
  @Binding var maskTimer: Float
  @Binding var gradientSpeed: Float
  
  private let colors: [Color] = [
    .purple, .blue, .cyan, .pink,
    .indigo, .mint, .red, .orange,
    .yellow, .purple, .blue, .cyan,
    .pink, .indigo, .mint, .red
  ]
  
  private let points: [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.5, 0.5), SIMD2<Float>(1.0, 0.5),
    SIMD2<Float>(0.0, 1.0), SIMD2<Float>(0.5, 1.0), SIMD2<Float>(1.0, 1.0)
  ]
  
  var body: some View {
    TimelineView(.animation) { timeline in
      MeshGradient(
        width: 3,
        height: 3,
        locations: .points(points),
        colors: .colors(animatedColors(for: timeline.date)),
        background: .black,
        smoothsColors: true
      )
    }
    .ignoresSafeArea()
  }
  
  private func animatedColors(for date: Date) -> [Color] {
    let phase = CGFloat(date.timeIntervalSince1970)
    
    return colors.enumerated().map { index, color in
      let hueShift = cos(phase + Double(index) * 0.3) * 0.1
      return shiftHue(of: color, by: hueShift)
    }
  }
  
  private func shiftHue(of color: Color, by amount: Double) -> Color {
    var hue: CGFloat = 0
    var saturation: CGFloat = 0
    var brightness: CGFloat = 0
    var alpha: CGFloat = 0
    
    UIColor(color).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
    
    hue += CGFloat(amount)
    hue = hue.truncatingRemainder(dividingBy: 1.0)
    
    if hue < 0 {
      hue += 1
    }
    
    return Color(hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness), opacity: Double(alpha))
  }
}

This view uses the TimelineView to create a constantly updating gradient. We define a set of colors and points. The colors are what we will see in the gradient, and the points determine where these colors are positioned. Inside the TimelineView, we create the new MeshGradient of SwiftUI. The animatedColors method takes the current date and uses it to shift the hues of the colors. This is what makes our gradient change over time.

Putting It All Together

To use this animation, we wrap the content in the MeshingAIProgressView:

MeshingAIProgressView(showGenerateAIMeshGradientAnimation: $showAnimation) {
  MyContentView()
}

Then, we trigger the animation by setting showAnimation to true.

Moving Forward

Custom shapes are powerful. It helps to have animations that would be difficult or impossible with standard SwiftUI shapes. Layering is important, especially the way it uses the rectangle view.

There is a lot of room for improvement. The colors do not pop as much as the Siri's animation, and I omitted the ripple effect because I could not get it to perfect.

For now, I am content with how it turned out.

And remember, if Siri can have a new era, so can you.

Happy animating!

String Catalog

String Catalog - App Localization on Autopilot

Push to GitHub, and we'll automatically localize your app for 40+ languages, saving you hours of manual work.

Tagged in: