3

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?

1 Answer 1

8

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
}
Sign up to request clarification or add additional context in comments.

2 Comments

That works! Brilliant – I totally thought I'd tried that and found it didn't work (it only updated the the final state so changed, but only used one animation). Thank you. … would you be able to extend your answer to explain why the other approaches don't work – either do nothing, or only update with the final state?
I haven't analyzed your other approaches in depth, but you should remember that a view is just a structure and SwiftUI can duplicate it and reinitialize however many times it wants, so a subscription done in init could happen for some copy of TestView - not the one in the view hierarchy. Values have to be assigned to a @State property after init (or directly with self._prop).

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.