swiftuiswiftui-animationswiftui-geometryeffect

SwiftUI: Textfield shake animation when input is not valid


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.


Solution

  • 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.)
                }
            }
        }