SwiftUI: Building a reusable Ripple Animation View (Swift Package)

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:

Outlined Style

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

Solid Style

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:

Five Static Ripple Circles

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:

Outlined Style animation

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:

Solid Style

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.