Exploring SwiftUI: Creating New Siri Animation
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.
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.
- 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. - We use the
onChange
modifier to start and stop our animation whenshowGenerateAIMeshGradientAnimation
changes. - The
onAppear
andonDisappear
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!