Is there a better way to implement a shake animation in swiftui?
Asked Answered
I

5

7

I'm trying to get a button to shake when the user tries to log in without filling all the textfields in, and this is what I've come across so far:

struct Shake: GeometryEffect {
    var amount: CGFloat = 10
    var shakesPerUnit = 3
    var animatableData: CGFloat

    func effectValue(size: CGSize) -> ProjectionTransform {
        ProjectionTransform(CGAffineTransform(translationX:
            amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
            y: 0))
    }
}

struct Correct: View {
    @State var attempts: Int = 0

    var body: some View {
        VStack {
            Rectangle()
                .fill(Color.pink)
                .frame(width: 200, height: 100)
                .modifier(Shake(animatableData: CGFloat(attempts)))
            Spacer()
            Button(action: {
                withAnimation(.default) {
                    self.attempts += 1
                }

            }, label: { Text("Login") })
        }
    }
}

However, this is particularly useless for a button, and even then the animation seems very off in that its pretty robotic. Can someone suggest an improvement so that I can get my button to shake?

Inelegancy answered 5/5, 2020 at 17:16 Comment(1)
at least give credits: objc.io/blog/2019/10/01/swiftui-shake-animationBuiltup
P
6

A little bit late to the party, but unfortunately the solutions here either finish the animation with a wrong offset or need some hardcoded assumption on the time the animation will finish.

The solution I came up with looks like this:

@State var shake = false

Text("Shake Me")
    .font(.title)
    .onTapGesture {
        shake = true
    }
    .shake($shake) {
        print("Finished")
    }

To animate, you just need to set shake to true (it will automatically be set to false once the animation completes).

Here is the implementation:

struct Shake<Content: View>: View {
    /// Set to true in order to animate
    @Binding var shake: Bool
    /// How many times the content will animate back and forth
    var repeatCount = 3
    /// Duration in seconds
    var duration = 0.8
    /// Range in pixels to go back and forth
    var offsetRange = 10.0

    @ViewBuilder let content: Content
    var onCompletion: (() -> Void)?

    @State private var xOffset = 0.0

    var body: some View {
        content
            .offset(x: xOffset)
            .onChange(of: shake) { shouldShake in
                guard shouldShake else { return }
                Task {
                    let start = Date()
                    await animate()
                    let end = Date()
                    print(end.timeIntervalSince1970 - start.timeIntervalSince1970)
                    shake = false
                    onCompletion?()
                }
            }
    }

    // Obs: sum of factors must be 1.0.
    private func animate() async {
        let factor1 = 0.9
        let eachDuration = duration * factor1 / CGFloat(repeatCount)
        for _ in 0..<repeatCount {
            await backAndForthAnimation(duration: eachDuration, offset: offsetRange)
        }

        let factor2 = 0.1
        await animate(duration: duration * factor2) {
            xOffset = 0.0
        }
    }

    private func backAndForthAnimation(duration: CGFloat, offset: CGFloat) async {
        let halfDuration = duration / 2
        await animate(duration: halfDuration) {
            self.xOffset = offset
        }

        await animate(duration: halfDuration) {
            self.xOffset = -offset
        }
    }
}

extension View {
    func shake(_ shake: Binding<Bool>,
               repeatCount: Int = 3,
               duration: CGFloat = 0.8,
               offsetRange: CGFloat = 10,
               onCompletion: (() -> Void)? = nil) -> some View {
        Shake(shake: shake,
              repeatCount: repeatCount,
              duration: duration,
              offsetRange: offsetRange) {
            self
        } onCompletion: {
            onCompletion?()
        }
    }

    func animate(duration: CGFloat, _ execute: @escaping () -> Void) async {
        await withCheckedContinuation { continuation in
            withAnimation(.linear(duration: duration)) {
                execute()
            }

            DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
                continuation.resume()
            }
        }
    }
}
Punkah answered 23/10, 2022 at 2:43 Comment(0)
H
5

try this

struct ContentView: View {
    @State var selected = false

    var body: some View {
        VStack {
            Button(action: {
                self.selected.toggle()
            }) { selected ? Text("Deselect") : Text("Select") }
            Rectangle()
                .fill(Color.purple)
                .frame(width: 200, height: 200)
                .offset(x: selected ? -30 : 0)
                .animation(Animation.default.repeatCount(5).speed(6))
        }
    }
}
Heady answered 5/5, 2020 at 17:40 Comment(1)
This is pretty good, but it doesn't reset the Rectangle()'s offset when the animation is done. An example that does that can be found here: objc.io/blog/2019/10/01/swiftui-shake-animationNorvin
C
2

I do this to make the field shake and then gets back to it's original position:

private func signUp() {
        if email.isEmpty {
            withAnimation {
                emailIsWrong = true
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                withAnimation {
                    emailIsWrong = false
                }
            }
            return
        }
}

Where emailIsWrong is a @State variable:

@State private var emailIsWrong = false

Basically after 0.2 sec, I change the emailIsWrong back to false so the view goes back to its position. My text field looks like this:

 TextField("Email", text: $email)
    .padding()
    .frame(height: 45)
    .background(Color.white)   
    .colorScheme(.light)
    .offset(x: emailIsWrong ? -8 : 0)
    .animation(Animation.default.repeatCount(3, autoreverses: true).speed(6))
Cissie answered 29/12, 2021 at 21:29 Comment(0)
Y
0

here's my one

@State var ringOnFinish: Bool = false
@State var shakeOffset: Double = 0

Button() {
ringOnFinish.toggle()
//give it a little shake animation when off
if !ringOnFinish {
    shakeOffset = 5
    withAnimation {
        shakeOffset = 0
    }
} label: {
Image(systemName: "bell\(ringOnFinish ? "" : ".slash")")
    .offset(x: ringOnFinish ? 0 : shakeOffset)
    .animation(.default.repeatCount(3, autoreverses: true).speed(6), value: ringOnFinish)
}
Yaakov answered 27/4, 2022 at 2:20 Comment(0)
P
0

You'll get far better results (and less code) by using the built-in spring animations. See this answer for an excellent example:

enter image description here

Persuade answered 24/6, 2023 at 14:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.