Reputation: 13887
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.
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())
}
}
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 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).
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.
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.
@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.
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.
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
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