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.