It’s no doubt that SwiftUI makes building custom views easy, even the seemingly complex ones. A few days ago, I thought of building something that may look complex to the eye but which I hoped would be easy with SwiftUI. So, I thought of a simple Ripple animation.
Deep down in my head, this is what I had in mind:

Although, I ended up with two styles, that (outlined) and this (solid):

I knew I needed some circles with strokes and some kind of variation in their visibility to achieve the desired behaviour. Easy peasy right? Well, let’s have a walkthrough from that moment onward.
It all started with a ZStack containing five circles, an initial fixed number needed to start with before making it dynamic.
GeometryReader is utilized to access the container size which is further used to vary the size of the circles, creating the desired sizes from the biggest to the smallest.
struct RippleView: View {
private var rippleCount: Int = 5
private var tintColor: Color = Color.black
var body: some View {
ZStack {
GeometryReader { geometry in
ZStack(alignment: .center) {
circleViews(geometry: geometry)
}
}
}
}
@ViewBuilder
private func circleViews(geometry: GeometryProxy) -> some View {
ZStack {
ForEach(0..<rippleCount) { index in
Circle()
.strokeBorder(tintColor, lineWidth: 1)
.frame(
width: geometry.size.width / (CGFloat(index) + 1),
height: geometry.size.height / (CGFloat(index) + 1)
)
}
}
}
}That produces this result:

I needed to be able to show some of the circles and hide some at intervals. So it’s perfectly okay to consider each circle displayed as different steps, a total number equals the number of circles used in the Ripple animation. Since there is a need to step over each step at intervals, I could utilize a Timer, specifying a particular interval and then performing the action of increasing the steps at every interval, till it gets to the maximum number of steps (maximum number of circles), at which point it returns to the first step.
Sounds confusing? Take a look at it in code:
struct RippleView: View {
@State private var currentStep: Int = -1
@State private var timer: Timer?
private var rippleCount: Int = 5
private var tintColor: Color = Color.black
private var timeIntervalBetweenRipples: Double = 0.13
var body: some View {
ZStack {
GeometryReader { geometry in
ZStack(alignment: .center) {
// To keep the ZStack in shape and initial circles at the center...
// even when the circle is at it's smallest size
Color.clear
circleViews(geometry: geometry)
.onAppear(perform: {
startAnimation()
})
}
}
}
.onDisappear(perform: {
stopAnimation()
})
}
@ViewBuilder
private func circleViews(geometry: GeometryProxy) -> some View {
ZStack {
ForEach(0..<rippleCount) { index in
if index >= currentStep {
Circle()
.strokeBorder(tintColor, lineWidth: 1)
.frame(
width: geometry.size.width / (CGFloat(index) + 1),
height: geometry.size.height / (CGFloat(index) + 1)
)
.animation(.spring(response: 0, dampingFraction: 0, blendDuration: 1))
.animation(.easeInOut)
}
}
}
}
private func startAnimation() {
if timer != nil {
return
}
currentStep = rippleCount - 1
timer = Timer.scheduledTimer(
withTimeInterval: timeIntervalBetweenRipples,
repeats: true
) { timer in
var nextStep = currentStep - 1
if nextStep == -1 {
nextStep = rippleCount - 1
}
withAnimation {
currentStep = nextStep
}
}
}
private func stopAnimation() {
timer?.invalidate()
timer = nil
}
}Here, I’ve introduced two functions, one to start animation and another to stop animation onDisappear of the view. Also, added a simple logic to display circles from the current step up to the last, plus some animations.
With the code above, we have a cool looking animation already:

So, after I thought of creating two styles, one with outline as shown above and another with solid color background that varies across circles.
struct RippleView: View {
// ...
private var style: Style = .solid
var body: some View {
// ...
}
@ViewBuilder
private func circleViews(geometry: GeometryProxy) -> some View {
ZStack {
ForEach(0..<rippleCount) { index in
if index >= currentStep {
Circle()
.strokeBorder(
style == .solid ? Color.clear : tintColor,
lineWidth: 1
)
.background(
Circle().foregroundColor(
style == .solid ? tintColor.opacity(0.3) : Color.clear
)
)
// ...
}
}
}
}
// ...
public enum Style {
case solid
case outlined
}
}Here, I’ve introduced two styles (solid and outlined) and as a result, I’ve updated the Circle view to have background that displays based on the style. All these changes produce this result:

The last set of additions to the code include introducing the init() function to allow users to pass in their different param values (style, rippleCount, tintColor, timeIntervalBetweenRipples) to customize the RippleView. This leaves me with the complete code below:
public struct RippleView: View {
@State private var currentStep: Int = -1
@State private var timer: Timer?
@Binding private var shouldAnimate: Bool
private var style: Style
private var rippleCount: Int
private var tintColor: Color
private var timeIntervalBetweenRipples: Double
public init(
style: Style = .solid,
rippleCount: Int = 5,
tintColor: Color = Color.black,
timeIntervalBetweenRipples: Double = 0.13,
shouldAnimate: Binding<Bool> = .constant(true)
) {
self.style = style
self.rippleCount = rippleCount
self.tintColor = tintColor
self.timeIntervalBetweenRipples = timeIntervalBetweenRipples
self._shouldAnimate = shouldAnimate
}
public var body: some View {
ZStack {
GeometryReader { geometry in
ZStack(alignment: .center) {
// To keep the ZStack in shape and initial circles at the center...
// even when the circle is at it's smallest size
Color.clear
if shouldAnimate {
circleViews(geometry: geometry)
.onAppear(perform: {
startAnimation()
})
}
}
}
}
.onDisappear(perform: {
stopAnimation()
})
}
@ViewBuilder
private func circleViews(geometry: GeometryProxy) -> some View {
ZStack {
ForEach(0..<rippleCount) { index in
if index >= currentStep {
Circle()
.strokeBorder(
style == .solid ? Color.clear : tintColor,
lineWidth: 1
)
.background(
Circle().foregroundColor(
style == .solid ? tintColor.opacity(0.3) : Color.clear
)
)
.frame(
width: geometry.size.width / (CGFloat(index) + 1),
height: geometry.size.height / (CGFloat(index) + 1)
)
.animation(.spring(response: 0, dampingFraction: 0, blendDuration: 1))
.animation(.easeInOut)
}
}
}
}
private func startAnimation() {
if timer != nil {
return
}
currentStep = rippleCount - 1
timer = Timer.scheduledTimer(
withTimeInterval: timeIntervalBetweenRipples,
repeats: true
) { timer in
var nextStep = currentStep - 1
if nextStep == -1 {
nextStep = rippleCount - 1
}
withAnimation {
currentStep = nextStep
}
}
}
private func stopAnimation() {
timer?.invalidate()
timer = nil
}
public enum Style {
case solid
case outlined
}
}Publishing as Swift Package
Publishing the RippleView as swift package is as easy as just creating a new Swift Package on Xcode and creating the view. You can check out the full guide I wrote on how to publish a swift package in this previous article.
As you can see, this animation view here may seem complex looking at it but really not that difficult to build using SwiftUI. You can take a look at the full source code and probably try your hands on modifying some stuff to see the corresponding effects.
Cheers!
P.S: This Ripple Animation View was used in building a Shazam Clone here.