Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 726
kirkyoyx Avatar asked Oct 27 '25 08:10

kirkyoyx


2 Answers

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.)
            }
        }
    }
like image 140
Yodagama Avatar answered Oct 30 '25 14:10

Yodagama


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 works 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

like image 45
Joule87 Avatar answered Oct 30 '25 14:10

Joule87