1

I am attempting to create a ripple effect on a button, but the animated circles are going below the button, and the ZStack is not functioning as expected.

Expected Result

I want the outer two circles to be continuously going out of the central dark gray circle without any break like this animation

enter image description here

Current Result:

enter image description here

This is my code in XCode Preview it looking good but when I run it on simulator it not working then.

import SwiftUI

struct SwiftUIView: View {
    @State var vpnIsActive = false
    let delayArr = [0.0,0.5,1.0,1.5,2.0]
    
    var body: some View {
        VStack {
            ZStack {
                ForEach(1...4,id:\.self){i in
                    RippleEffect(vpnIsActive: $vpnIsActive, delay: delayArr[i])
                }
                Circle()
                    .fill(vpnIsActive ? Color("homeBlue") : Color("homeGray"))
                    .frame(width: Constants.width * 0.4, height: Constants.width * 0.4)
                
                VStack {
                    Button {
                        vpnIsActive.toggle()
                    } label: {
                        Image("power")
                    }
                    Text(vpnIsActive ? "Tap to Disconnect" : "Tap to Connect")
                        .foregroundColor(.white)
                }
            }
            .frame(width: Constants.width * 0.4, height: Constants.width * 0.4)
        }
    }
}

struct SwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        SwiftUIView()
    }
}

struct Constants{
    static let height = UIScreen.main.bounds.height
    static let width = UIScreen.main.bounds.width
}


struct RippleEffect:View{
    @State private var scale = 1.0
    @Binding var vpnIsActive:Bool
    let delay :Double
    var body: some View{
        VStack{
            Circle()
                .fill(vpnIsActive ? LinearGradient(colors: [Color("homeBlue").opacity(0.5), Color("homeBlue").opacity(0.05)], startPoint: .top, endPoint: .bottom) : LinearGradient(colors: [Color("homeGray").opacity(0.8), Color("homeGray").opacity(0.1)], startPoint: .top, endPoint: .bottom))
                .scaleEffect(scale)
                .opacity(2 - scale)
                .animation(Animation.easeInOut(duration: 2).repeatForever(autoreverses: false).delay(delay))
                .onAppear {
                    scale = 2.5
                }
        }
        .frame(width: Constants.width * 0.3, height: Constants.width * 0.3)
    }
}

1 Answers1

0

You actually have more of a math problem than anything else, but there are some issues with your code. I had to fill in a lot of blanks, because this is not a Minimal, Reproducible Example (MRE). Please make sure your code runs in a separate project before posting. You will get better and faster answers.

This issue you are having is simple. You intended to, I believe, have the RippleEffect ONLY on the button, but you placed your outer circle, your RippleEffect and the Stack() with the button and label into a ZStack which takes those 3 views and stacks them one on top of the other. You need to place the RippleEffect in the background of your button. Based on your sizings, I don't believe this is what you intended.

If, however, you intended the ripple effect to be on your circle, and not the button, then you have one view that is 40% of your constant, and your RippleEffect is 30% of your constant. Your concern is that the RippleEffect grows to be large than the other view. For the sake of ease of math, let's say the width constant is 100. That means the on view is 40x40, and the RippleEffect is 30x30. You then apply a scale effect that scales the RippleEffect by 2.4 times. That means that ultimately, the RippleEffect reaches a size of 2.5 * 30, or 75. 75 is, of course, bigger than 40. It is not more noticeable because as the view scales up, your are reducing the opacity, so by the time you are 80% of the way there (scale of 2.5 * 0.8 = 2 which is where you opacity hits 0), so the appearance of the ripples only looks like it has grown to 60, and not 75, and that is what you see as "going below the button".

The other issue with your code is that you are using a deprecated .animation() call that is deprecated for a good reason. Don't use it. Instead, wrap the scale change in a withAnimation() block in your .onAppear(). It will work more consistently. I have implemented it below.

struct RippleEffectTestView: View {
    @State var vpnIsActive = false
    let delayArr = [0.0,0.5,1.0,1.5,2.0]
    
    var body: some View {
        VStack {
            ZStack {
                Circle()
                    .fill(vpnIsActive ? .blue : .gray)
                    .opacity(0.5)
                    .frame(width: Constants.width * 0.4, height: Constants.width * 0.4)
                    // I used an overlay to make it clear what the animation is supposed to be on
                    .overlay {
                        ForEach(1...4, id: \.self) { i in
                            RippleEffect(vpnIsActive: $vpnIsActive, delay: delayArr[i])
                        }
                    }

                VStack {
                    Button {
                        vpnIsActive.toggle()
                    } label: {
                        Image(systemName: "power")
                    }
                    Text(vpnIsActive ? "Tap to Disconnect" : "Tap to Connect")
                }
                .foregroundColor(.blue)
            }
            .frame(width: Constants.width * 0.4, height: Constants.width * 0.4)
        }
    }
}

enum Constants {
    static let width: CGFloat = 400
}

struct RippleEffect: View {
    @State private var scale = 0.0
    @Binding var vpnIsActive: Bool
    let delay: Double
    let maxScale = 1.25 // I made this a let constant because it is used for both opacity and scale
    var body: some View {
        VStack{
            Circle()
                .fill(vpnIsActive ? LinearGradient(colors: [.blue.opacity(0.5), .blue.opacity(0.05)], startPoint: .top, endPoint: .bottom) : LinearGradient(colors: [.gray.opacity(0.8), .gray.opacity(0.1)], startPoint: .top, endPoint: .bottom))
                .scaleEffect(scale)
                .opacity(maxScale - scale)
                .onAppear {
                    withAnimation(Animation.easeInOut(duration: 2).repeatForever(autoreverses: false).delay(delay)) {
                        scale = maxScale
                    }
                }
        }
        .frame(width: Constants.width * 0.3, height: Constants.width * 0.3)
    }
}

edit:

That is simply just playing with the numbers. Here, I removed the frame to make the ripple view the same size as the other view. Because it is an .overlay() the ripple view will be governed by the frame on the view above. I then changed the beginning scale back to 1.0 and the maxScale to 1.5. Play with these to achieve the look you want.

struct RippleEffect: View {
    @State private var scale = 1.0
    @Binding var vpnIsActive: Bool
    let delay: Double
    let maxScale = 1.5 // I made this a let constant because it is used for both opacity and scale
    var body: some View {
        VStack{
            Circle()
                .fill(vpnIsActive ? LinearGradient(colors: [.blue.opacity(0.5), .blue.opacity(0.05)], startPoint: .top, endPoint: .bottom) : LinearGradient(colors: [.gray.opacity(0.8), .gray.opacity(0.1)], startPoint: .top, endPoint: .bottom))
                .scaleEffect(scale)
                .opacity(maxScale - scale)
                .onAppear {
                    withAnimation(Animation.easeInOut(duration: 2).repeatForever(autoreverses: false).delay(delay)) {
                        scale = maxScale
                    }
                }
        }
    }
}
Yrb
  • 8,103
  • 2
  • 14
  • 44