SwiftUI: анимация встряхивания текстового поля при недопустимом вводе

Я хочу создать анимацию встряхивания, когда пользователь нажимает кнопку «сохранить», а ввод недопустим. Мой первый подход таков (для упрощения я удалил модификаторы, а не соответствующие атрибуты в этом случае):

Вид:

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

Но это решение не работает. В первый раз при нажатии на кнопку ничего не происходит. После этого, когда я меняю текстовое поле, оно начинает трястись.

2 ответа

Решение

с помощью 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, это мой способ, я думаю, его легко использовать повторно:

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

Там, где вы хотите встряхнуться, просто сделайте вот так

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

Другие вопросы по тегам