SwiftUI: Textfield shake animation when input is not valid

2.9k Views Asked by At

I want to create a shake animation when the User presses the "save"-button and the input is not valid. My first approach is this (to simplify I removed the modifiers and not for this case relevant attributes):

View:

struct CreateDeckView: View {
    @StateObject var viewModel = CreateDeckViewModel()

    HStack {
        TextField("Enter title", text: $viewModel.title)
            .offset(x: viewModel.isValid ? 0 : 10)                 //
            .animation(Animation.default.repeatCount(5).speed(4))  // shake animation

         Button(action: {
                    viewModel.buttonPressed = true
                    viewModel.saveDeck(){
                        self.presentationMode.wrappedValue.dismiss()
                    }
                }, label: {
                    Text("Save")
                })
         }
}

ViewModel:

class CreateDeckViewModel: ObservableObject{

    @Published var title: String = ""
    @Published var buttonPressed = false

    var validTitle: Bool {
        buttonPressed && !(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
    }

    public func saveDeck(completion: @escaping () -> ()){ ... }
}
             

But this solution doesn't really work. For the first time when I press the button nothing happens. After that when I change the textfield it starts to shake.

3

There are 3 best solutions below

2
On BEST ANSWER

using GeometryEffect,

struct ContentView: View {
        @StateObject var viewModel = CreateDeckViewModel()
        
        var body: some View       {
            HStack {
                TextField("Enter title", text: $viewModel.title)
                    .modifier(ShakeEffect(shakes: viewModel.shouldShake ? 2 : 0)) //<- here
                    .animation(Animation.default.repeatCount(6).speed(3))
    
                Button(action: {
                    viewModel.saveDeck(){
                        ...
                    }
                }, label: {
                    Text("Save")
                })
            }
        }
    }
    
    //here
    struct ShakeEffect: GeometryEffect {
        func effectValue(size: CGSize) -> ProjectionTransform {
            return ProjectionTransform(CGAffineTransform(translationX: -30 * sin(position * 2 * .pi), y: 0))
        }
        
        init(shakes: Int) {
            position = CGFloat(shakes)
        }
        
        var position: CGFloat
        var animatableData: CGFloat {
            get { position }
            set { position = newValue }
        }
    }
    
    class CreateDeckViewModel: ObservableObject{
        
        @Published var title: String = ""
        @Published var shouldShake = false
        
        var validTitle: Bool {
            !(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
        }
        
        public func saveDeck(completion: @escaping () -> ()){
            if !validTitle {
                shouldShake.toggle() //<- here (you can use PassThrough subject insteadof toggling.)
            }
        }
    }
0
On

I couldn't identify the issue that prevented Okayokay solution from working correctly on (iOS 17, *). Therefore, I developed my own variant based on the ideas presented above. It functions smoothly on iOS 17. I hope this helps

Full Code:

import SwiftUI
import Combine

class ShakeViewModel: ObservableObject {
    var shake = PassthroughSubject<Void, Never>()
    
    func needShake() {
        shake.send()
    }
}

extension View {
    func shakeAnimation(_ shake: Binding<Bool>, sink: PassthroughSubject<Void, Never>, intensity: CGFloat = 3, duration: CGFloat = 0.05) -> some View {
        modifier(ShakeEffect(shake: shake, sink: sink, intensity: intensity, duration: duration))
    }
}

struct ShakeEffect: ViewModifier {
    @Binding var shake: Bool
    var sink: PassthroughSubject<Void, Never>
    let intensity: CGFloat
    let duration: CGFloat
    
    func body(content: Content) -> some View {
        content
            .modifier(ShakeViewModifier(shake: $shake, sink: sink, intensity: intensity, duration: duration))
        
    }
}

struct ShakeViewModifier: ViewModifier {
    @Binding var shake: Bool
    var sink: PassthroughSubject<Void, Never>
    let intensity: CGFloat
    let duration: CGFloat
    @State private var xIntensity: CGFloat = 0
    
    func body(content: Content) -> some View {
        content
            .offset(x: shake ? xIntensity : -xIntensity, y: 0)
            .onReceive(sink) { _ in
                self.xIntensity = intensity
                withAnimation(.easeInOut(duration: duration).repeatCount(5)) {
                    shake.toggle()
                } completion: {
                    withAnimation(.easeInOut(duration: duration)) {
                        self.xIntensity = 0
                    }
                }
            }
    }
}

struct ShakeView: View {
    @StateObject var viewModel: ShakeViewModel = ShakeViewModel()
    @State var shakeAnimation: Bool = false
    
    var body: some View {
        ZStack {
            Rectangle()
                .fill(Color.purple)
                .cornerRadius(10)
                .frame(width: 200, height: 200)
                .shakeAnimation($shakeAnimation, sink: viewModel.shake, intensity: 6, duration: 0.06)
                .onTapGesture {
                    viewModel.needShake()
                }
        }
    }
}

struct ShakeView_Previews: PreviewProvider {
    static var previews: some View {
        ShakeView()
    }
}

enter image description here

4
On

By the answer of @YodagamaHeshan, this is my way, I think it's easy to reuse:


public struct ShakeEffect: GeometryEffect {
    public var amount: CGFloat = 10
    public var shakesPerUnit = 3
    public var animatableData: CGFloat
    
    public init(amount: CGFloat = 10, shakesPerUnit: Int = 3, animatableData: CGFloat) {
        self.amount = amount
        self.shakesPerUnit = shakesPerUnit
        self.animatableData = animatableData
    }

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

extension View {
    public func shakeAnimation(_ shake: Binding<Bool>, sink: PassthroughSubject<Void, Never>) -> some View {
        modifier(ShakeEffect(animatableData: shake.wrappedValue ? 2 : 0))
            .animation(.default, value: shake.wrappedValue)
            .onReceive(sink) {
                shake.wrappedValue = true
                withAnimation(.default.delay(0.15)) { shake.wrappedValue = false }
            }
    }
}

Where you want to shake, just do like this

/// In View

   @State var shakeAnimation: Bool = false

   VStack { /// any view
       ....
   }
   .shakeAnimation($shake, sink: model.shake)

/// In Model

   var shake = PassthroughSubject<Void, Never>()
   ....

   func needShake() { shake.send() }