2

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.

YodagamaHeshan
  • 4,996
  • 2
  • 26
  • 36
kirkyoyx
  • 313
  • 2
  • 12

2 Answers2

2

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.)
            }
        }
    }
YodagamaHeshan
  • 4,996
  • 2
  • 26
  • 36
  • 1
    Thank you! It works, but honestly I don't really understand why. The toggling part is confusing me. `shouldShake` starts with false, then I try to save with an empty field and `shouldShake` is changing to true -> The shakeEffect(2) is triggered. But when I tap again on the button with an invalid field the `shouldShake` is getting changed to false. So actually there shouldn't be an shaking effect, but it is?! – kirkyoyx Dec 28 '20 at 17:57
  • @kirkyoyx https://talk.objc.io/episodes/S01E173-building-a-shake-animation – YodagamaHeshan Dec 29 '20 at 00:38
1

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() }

Okayokay
  • 11
  • 2
  • Thanks! It works. But when using in a List, it will only work if the .shakeAnimation is set on the list. On the cell or section, it won't work. Any idea why? – vomi Jun 02 '23 at 13:16
  • I guess the 'model' you used for cell was 'Struct'. If wanna to do that, you should use 'Class' conform to 'ObservableObject' for every cell. btw, make sure cell has @ObservedObject the model – Okayokay Aug 23 '23 at 12:54