SwiftUI Ripple Effect Animation
Asked Answered
P

2

5

I'm working on creating a ripple effect in SwiftUI similar to the one here.

Here is what I have so far:

import SwiftUI

// MARK: - Ripple

struct Ripple: ViewModifier {
    // MARK: Lifecycle

    init(rippleColor: Color) {
        self.rippleColor = rippleColor
    }

    // MARK: Internal

    let rippleColor: Color

    func body(content: Content) -> some View {
        ZStack {
            content

            if let location = touchPoint {
                Circle()
                    .fill(rippleColor)
                    .frame(width: 16.0, height: 16.0)
                    .position(location)
                    .clipped()
                    .opacity(opacity)
            }
        }
        .fixedSize()
        .gesture(
            DragGesture(minimumDistance: 0.0)
                .onChanged { gesture in
                    guard touchPoint != gesture.startLocation else {
                        return
                    }

                    timer?.invalidate()

                    opacity = 1.0
                    touchPoint = gesture.startLocation
                }
                .onEnded { _ in
                    timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
                        withAnimation {
                            opacity = 0.0
                        }
                    }
                }
        )
    }

    // MARK: Private

    @State private var opacity: CGFloat = 0.0
    @State private var touchPoint: CGPoint?
    @State private var timer: Timer?
}

extension View {
    func rippleEffect(rippleColor: Color = .accentColor.opacity(0.5)) -> some View {
        modifier(Ripple(rippleColor: rippleColor))
    }
}

The next step is to do the scaling animation, but I'm having trouble figuring out how. I've tried applying scale effects and transitions with the scale modifier, but nothing seems to work correctly.

Can someone assist me in achieving the ripple effect I'm looking for?

Additionally, if something like this already exists, I'd be happy to just use it, but I haven't been able to find anything.

Thanks,

RPK

Principality answered 23/11, 2022 at 16:31 Comment(0)
L
4

You are probably looking for something like this...

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
        .rippleEffect(rippleColor: .gray)
        .frame(width: 400, height: 200)
        .padding()
    }
}

struct Ripple: ViewModifier {
    // MARK: Lifecycle

    init(rippleColor: Color) {
        self.color = rippleColor
    }

    // MARK: Internal

    let color: Color

    @State private var scale: CGFloat = 0.5
    
    @State private var animationPosition: CGFloat = 0.0
    @State private var x: CGFloat = 0.0
    @State private var y: CGFloat = 0.0
    
    @State private var opacityFraction: CGFloat = 0.0
    
    let timeInterval: TimeInterval = 0.5
    
    func body(content: Content) -> some View {
        GeometryReader { geometry in
            ZStack {
                Rectangle()
                    .foregroundColor(.gray.opacity(0.05))
                Circle()
                    .foregroundColor(color)
                    .opacity(0.2*opacityFraction)
                    .scaleEffect(scale)
                    .offset(x: x, y: y)
                content
            }
            .onTapGesture(perform: { location in
                x = location.x-geometry.size.width/2
                y = location.y-geometry.size.height/2
                opacityFraction = 1.0
                withAnimation(.linear(duration: timeInterval)) {
                    scale = 3.0*(max(geometry.size.height, geometry.size.width)/min(geometry.size.height, geometry.size.width))
                    opacityFraction = 0.0
                    DispatchQueue.main.asyncAfter(deadline: .now() + timeInterval) {
                        scale = 1.0
                        opacityFraction = 0.0
                    }
                }
            })
            .clipped()
        }
    }
}

extension View {
    func rippleEffect(rippleColor: Color = .accentColor.opacity(0.5)) -> some View {
        modifier(Ripple(rippleColor: rippleColor))
    }
}
Lawson answered 24/11, 2022 at 13:17 Comment(1)
Thanks! That's actually pretty helpful. It's not exactly the same functionality as the ripple effect linked above, but I can work with this to figure something out.Principality
P
3

Using Frederik's answer from above, I modified it slightly to achieve the desired result I was looking for.

import SwiftUI

// MARK: - ContentView

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
        .rippleEffect(rippleColor: .gray)
        .frame(width: 400, height: 200)
        .padding()
    }
}

// MARK: - Ripple

struct Ripple: ViewModifier {
    // MARK: Lifecycle

    init(rippleColor: Color) {
        color = rippleColor
    }

    // MARK: Internal

    let color: Color

    let timeInterval: TimeInterval = 0.5

    func body(content: Content) -> some View {
        GeometryReader { geometry in
            ZStack {
                Rectangle()
                    .foregroundColor(.gray.opacity(0.05))
                Circle()
                    .foregroundColor(color)
                    .opacity(0.2 * opacityFraction)
                    .scaleEffect(scale)
                    .offset(x: x, y: y)
                content
            }
            .gesture(
                DragGesture(minimumDistance: 0.0)
                    .onChanged { gesture in
                        let location = gesture.startLocation

                        x = location.x - geometry.size.width / 2
                        y = location.y - geometry.size.height / 2

                        opacityFraction = 1.0

                        withAnimation(.linear(duration: timeInterval / 2.0)) {
                            scale = 3.0 *
                                (
                                    max(geometry.size.height, geometry.size.width) /
                                        min(geometry.size.height, geometry.size.width)
                                )
                        }
                    }
                    .onEnded { _ in
                        withAnimation(.linear(duration: timeInterval / 2.0)) {
                            opacityFraction = 0.0
                            scale = 1.0
                        }
                    }
            )
            .clipped()
        }
    }

    // MARK: Private

    @State private var scale: CGFloat = 0.5

    @State private var animationPosition: CGFloat = 0.0
    @State private var x: CGFloat = 0.0
    @State private var y: CGFloat = 0.0

    @State private var opacityFraction: CGFloat = 0.0
}

extension View {
    func rippleEffect(rippleColor: Color = .accentColor.opacity(0.5)) -> some View {
        modifier(Ripple(rippleColor: rippleColor))
    }
}

// MARK: - ContentView_Previews

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Thanks Frederik, for the nudge in the right direction.

Principality answered 24/11, 2022 at 18:27 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.