How can I reverse an animation that is triggered by a watched variable?

Hi guys,
I have a problem I'm trying to solve and I can't figure out a way to do it. I have a variable in the view model that is being watched and will flash the background of a textfield red if an invalid character is entered. (This is a business requirement for a SwiftUI proof of concept at work so I cannot change this behavior.). Unfortuantely I cannot figure out how to do it.
The code below is a simplification of what I need. If you enter a "!" in the text field, the background will render red. The issue here is because it can't change back to white without a state change, the background stays red until a new character is pressed.
I'm trying to figure out a workaround to this since it doesn't seem possible to chain animations and the "repeatCount(1, autoreverses: true) doesn't do anything here. Anyone have any suggestions?

class Person: ObservableObject {
    @Published var name: String = "" { willSet { processValue(value: newValue) }}
    @Published var hasError: Bool = false
    
    private func processValue(value: String) {
        value.last == "!" ? (hasError = true) : (hasError = false)
    }
}

struct ContentView: View {
    @ObservedObject var vm = Person()
    
    @State private var change = false
    
    var body: some View {
        VStack {
            TextField("Name", text: $vm.name)
            .background(vm.hasError ? Color.red : Color.white) //I'd rather just flash this red and turn it back to white
                .animation(Animation.linear(duration: 0.2).repeatCount(1, autoreverses: true))
        }.padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Accepted Reply

Essentially what you're looking to do is to reverse the 'hasError' property after a certain amount of time. There are several ways to do this, the simplest being a basic dispatch:


DispatchQueue.main.async { self.vm.hasError = false }


A more robust solution would be to use a Publisher though, via a PassthroughSubject. That will let you set delays and even debounce the events (coalescing nearby events together). For a true 'flash' effect, I'd recommend setting the background color to red instantly, without animation, and then animating the fade back to the original color (or to Clear, which is what a TextField would use normally).


Here's my implementation of a ContentView that would work with your Person class to implement a decent-looking background flash:


struct ContentView: View {
    @ObservedObject var vm = Person()
    @State var color = Color.clear
    
    private var unflash = PassthroughSubject<Void, Never>()
    private var afterUnflash: AnyPublisher<Void, Never>
    
    init() {
        self.afterUnflash = unflash
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
    
    var body: some View {
        VStack {
            TextField("Name", text: $vm.name)
                .padding()
                .background(color)
                .onReceive(vm.$hasError, perform: {
                    if $0 {
                        self.color = .red
                        self.unflash.send(())
                    } else {
                        withAnimation(.linear(duration: 0.2)) {
                            self.color = .clear
                        }
                    }
                })
                .onReceive(afterUnflash, perform: { _ in
                    self.vm.hasError = false
                })
        }
    }
}


I've added a Color state value which I'm using to set the background, followed by a PassthroughSubject and a debouncing publisher derived from that. I then monitor the publisher for the vm.hasError property, and when the value becomes true I set the color to red and trigger my PassthroughSubject; when the value becomes false I use an animation to set the color back to clear. Lastly, I monitor the debounced publisher and set vm.hasError to false when it fires. The debouncing here is used to coalesce multiple signals together; try commenting out line ten and typing a series of "!" and you'll see the color switching back & forth rapidly. With the debounce in there, it will only send the signal on when 300 milliseconds of time has elapsed since the last one was received, giving a better overall appearance—it'll stay red while you quickly type "!" and fade out only once you let go.

Replies

Essentially what you're looking to do is to reverse the 'hasError' property after a certain amount of time. There are several ways to do this, the simplest being a basic dispatch:


DispatchQueue.main.async { self.vm.hasError = false }


A more robust solution would be to use a Publisher though, via a PassthroughSubject. That will let you set delays and even debounce the events (coalescing nearby events together). For a true 'flash' effect, I'd recommend setting the background color to red instantly, without animation, and then animating the fade back to the original color (or to Clear, which is what a TextField would use normally).


Here's my implementation of a ContentView that would work with your Person class to implement a decent-looking background flash:


struct ContentView: View {
    @ObservedObject var vm = Person()
    @State var color = Color.clear
    
    private var unflash = PassthroughSubject<Void, Never>()
    private var afterUnflash: AnyPublisher<Void, Never>
    
    init() {
        self.afterUnflash = unflash
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
    
    var body: some View {
        VStack {
            TextField("Name", text: $vm.name)
                .padding()
                .background(color)
                .onReceive(vm.$hasError, perform: {
                    if $0 {
                        self.color = .red
                        self.unflash.send(())
                    } else {
                        withAnimation(.linear(duration: 0.2)) {
                            self.color = .clear
                        }
                    }
                })
                .onReceive(afterUnflash, perform: { _ in
                    self.vm.hasError = false
                })
        }
    }
}


I've added a Color state value which I'm using to set the background, followed by a PassthroughSubject and a debouncing publisher derived from that. I then monitor the publisher for the vm.hasError property, and when the value becomes true I set the color to red and trigger my PassthroughSubject; when the value becomes false I use an animation to set the color back to clear. Lastly, I monitor the debounced publisher and set vm.hasError to false when it fires. The debouncing here is used to coalesce multiple signals together; try commenting out line ten and typing a series of "!" and you'll see the color switching back & forth rapidly. With the debounce in there, it will only send the signal on when 300 milliseconds of time has elapsed since the last one was received, giving a better overall appearance—it'll stay red while you quickly type "!" and fade out only once you let go.

Thanks so much!