Benjohn
Benjohn

Reputation: 13887

Subscribe SwiftUI View to every update of a combine publisher

Here's a simplified example of an approach I want to take, but I can't get the simple example to work.

I have a Combine publisher who's subject is a view model State:

struct State {
    let a: Bool
    let b: Bool
    let transition: Transition?
}

The State includes a transition property. This describes the Transition that the State made in order to become the current state.

enum Transition {
    case onAChange, onBChange
}

I want to use transition property to drive animations in a View subscribed to the publisher so that different transitions animate in specific ways.

View code

Here's the code for the view. You can see how it tries to use the transition to choose an animation to update with.

struct TestView: View {
    
    let model: TestViewModel

    @State private var state: TestViewModel.State
    private var cancel: AnyCancellable?

    init(model: TestViewModel) {
        self.model = model
        self._state = State(initialValue: model.state.value)
        self.cancel = model.state.sink(receiveValue: updateState(state:))
    }
    
    var body: some View {
        VStack(spacing: 20) {
            Text("AAAAAAA").scaleEffect(state.a ? 2 : 1)
            Text("BBBBBBB").scaleEffect(state.b ? 2 : 1)
        }
        .onTapGesture {
            model.invert()
        }
    }
    
    private func updateState(state: TestViewModel.State) {
        withAnimation(animation(for: state.transition)) {
            self.state = state
        }
    }

    private func animation(for transition: TestViewModel.Transition?) -> Animation? {
        guard let transition = transition else { return nil }

        switch transition {
        case .onAChange: return .easeInOut(duration: 1)
        case .onBChange: return .easeInOut(duration: 2)
        }
    }
}

struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView(model: TestViewModel())
    }
}

Model code

final class TestViewModel: ObservableObject {
    
    var state = CurrentValueSubject<State, Never>(State(a: false, b: false, transition: nil))
    
    struct State {
        let a: Bool
        let b: Bool
        let transition: Transition?
    }
    
    enum Transition {
        case onAChange, onBChange
    }

    func invert() {
        let oldState = state.value
        setState(newState: .init(a: !oldState.a, b: oldState.b, transition: .onAChange))
        setState(newState: .init(a: !oldState.a, b: !oldState.b, transition: .onBChange))
    }
    
    private func setState(newState: State) {
        state.value = newState
    }
}

You can see in the model code that when invert() is called, two state changes occur. The model first toggles a using the .onAChange transition, and then toggles b using the .onBChange transition.

What should happen

What should happen when this is run is that each time the view is clicked, the text "AAAAAAA" and "BBBBBBB" should toggle size. However, the "AAAAAAA" text should change quickly (1 second) and the "BBBBBBB" text should change slowly (2 seconds).

What actually happens

However, when I run this and click on the view, the view doesn't update at all.

I can see from the debugger that onTapGesture { … } is called and invert() is being called on the model. Also updateState(state:) is also being called. However, TestView is not changing on screen, and body is not invoked again.

Other things I tried

Using a callback

Instead of using a publisher to send the event to the view, I've tried a callback function in the model set to the view's updateState(state:) function. I assigned to this in the init of the view with model.handleUpdate = self.update(state:). Again, this did not work. The function invert() and update(state:) were called, as expected, but the view didn't actually change.

Using @ObservedObject

I change the model to be ObservableObject with its state being @Published. I set up the view to have an @ObservedOject for the model. With this, the view does update, but it updates both pieces of text using the same animation, which I don't want. It seems that the two state updates are squashed and it only sees the last one, and uses the transition from that.

Something that did work – sort of

Finally, I tried to directly copy the model's invert() function in to the view's onTapGesture handler, so that the view updates its own state directly. This did work! Which is something, but I don't want to put all by model update logic in my view.

Question

How can I have a SwiftUI view subscribe to all states that a model sends through its publisher so that it can use a transition property in the state to control the animation used for that state change?

Upvotes: 3

Views: 4000

Answers (1)

New Dev
New Dev

Reputation: 49590

The way you subscribe a view to the publisher is by using .onRecieve(_:perform:), so instead of saving a cancellable inside init, do this:

var body: some View {
   VStack(spacing: 20) {
      Text("AAAAAAA").scaleEffect(state.a ? 2 : 1)
      Text("BBBBBBB").scaleEffect(state.b ? 2 : 1)
   }
   .onTapGesture {
      model.invert()
   }
   .onReceive(model.state, perform: updateState(state:)) // <- here
}

Upvotes: 8

Related Questions