swiftcombine

Two way data flow with @Published property with the ability to ignore events


I have the following requirements in my SwiftUI view:

  1. Have 2 toggles on screen
  2. Based on the values of those toggles, I need to calculate a state (1 value)
  3. When that state changes, I need to post it to a server
  4. When the view appears, I have to set the toggles according to the last state stored locally
  5. At some point I have to request the state from the server and update the toggles accordingly.

Here is my code (in an observable object):

@Published var toggleValue1: Bool = false
@Published var toggleValue2: Bool = false

init() {
    toggleValue1 = getValue1()
    toggleValue2 = getValue2()

    cancellable = $toggleValue1
                   .combineLatest($toggleValue2)
                   .map { ... } // Transforms (bool, bool) to SomeType
                   .removeDuplicates()
                   .dropFirst()
                   .sink { ... } // Upload to backend. Starts a Task
}

Now the combination of .dropFirst and the fact that my upload task verifies if the value to be uploaded is different from what is stored locally (which should also be on the server) solves that I'm not uploading values when the view appears but the issue is with #5. When I'm updating those @Published variables to update the UI, they also trigger an upload.

I'm trying to think of any other combination of publishers or bindings to ignore programatic changes but I can't find any. I think I could set a flag to ignore the following N events, but its seems very hacky


Solution

  • You can solve this problem by avoiding two way bindings and instead utilise an unidirectional pattern (like MVVM, MVI, Redux, ELM, TCA, etc.)

    That is, the responsibility of your view model (or model, or store, you name it) is to 1.) publish the view state and 2.) receive commands. When a command is received, it then 3.) computes a new view state.

    Following this pattern, the view cannot mutate its state itself – it just renders the state and sends "commands" (aka user intents) to the view model. In order to implement this, you don't need Swift Bindings at all (except when you internally have to pass it as parameters to other views).

    The view state only changes, when the view model receives an "Event". Events are user intents (button clicks, etc.) or they can be also materialised result values generated by services.

    These events can be modelled with a Swift Enum. When you have different cases for user intents and for external events (say the return value of some service function) your view model logic can clearly handle it differently - even when these events basically have the same mutating effect on the state.

    In an unidirectional flow the view model intercepts all events and computes the new view state based on the current view state and the event. The view never mutates the view state.

    Below is a complete example which demonstrates the technique. It also uses a Finite State Machine (FSM) to perform the logic which has numerous benefits, like getting the logic easily correct and complete (no so called "edge cases" anymore). You will also notice, that there is no Combine used in the logic function. Combine is great, but it's not needed when implementing the logic function. There is just this pure function covering all cases which implements the whole logic.

    import SwiftUI
    
    /// A state representing the View
    enum State {
        case start
        case idle(value1: Bool, value2: Bool)
        case loading(value1: Bool, value2: Bool)
        case error(Error, value1: Bool, value2: Bool)
    }
    
    /// All events that can happen in the system.
    enum Event {
        case start(value1: Bool, value2: Bool) // sent from the view
        case toggle1(value: Bool) // user intent
        case toggle2(value: Bool) // user intent
        case update // user intent
        case didDismissAlert // user intent
        case serverResponse(Result<(toggle1: Bool, toggle2: Bool), Error>) // external event
    }
    
    struct Env {} // empty, could provide dependencies
    
    
    // Convenience Assessors for State
    extension State {
        var toggle1: Bool {
            switch self {
            case .idle(value1: let value, value2: _), .loading(value1: let value, value2: _), .error(_, value1: let value, value2: _):
                return value
            default:
                return false
            }
        }
        var toggle2: Bool {
            switch self {
            case .idle(value1: _, value2: let value), .loading(value1: _, value2: let value), .error(_, value1: _, value2: let value):
                return value
            default:
                return false
            }
        }
        
        var error: Error? {
            if case .error(let error, _, _) = self { error } else { nil }
        }
        
        var isLoading: Bool {
            if case .loading = self { true } else { false }
        }
    }
    
    
    typealias Effect = Lib.Effect<Event, Env>
    
    /// Implements the logic for the story
    func transduce(_ state: inout State, event: Event) -> [Effect] {
        switch (state, event) {
        case (.start, .start(let value1, let value2)):
            state = .idle(value1: value1, value2: value2)
            return []
            
        case (.idle(_, let value2), .toggle1(let newValue)):
            state = .idle(value1: newValue, value2: value2)
            return []
            
        case (.idle(let value1, _), .toggle2(let newValue)):
            state = .idle(value1: value1, value2: newValue)
            return []
            
        case (.loading, .toggle1), (.loading, .toggle2):
            return []
            
        case (.error, .toggle1), (.error, .toggle2):
            return []
    
        case (.idle(let value1, let value2), .update):
            state = .loading(value1: value1, value2: value2)
            return [
                Effect { _ in
                    do {
                        let state = try await API.update(toggle1: value1, toggle2: value2)
                        return .serverResponse(.success(state))
                    } catch {
                        return .serverResponse(.failure(error))
                    }
                }
            ]
            
        case (.loading(let value1, let value2), .serverResponse(let result)):
            switch result {
            case .success(let args):
                state = .idle(value1: args.toggle1, value2: args.toggle2)
                return []
            case .failure(let error):
                state = .error(error, value1: value1, value2: value2)
                return []
            }
    
        case (.error(_, let value1, let value2), .didDismissAlert):
            state = .idle(value1: value1, value2: value2)
            return []
    
        case (.error, _):
            return []
        case (.loading, _):
            return []
        case (.idle, _):
            return []
        case (.start, _):
            return []
        }
        
    }
    
    
    
    @MainActor
    struct ContentView: View {
        let model: Lib.Model<State, Event> = .init(
            intialState: .start,
            env: .init(),
            transduce: transduce(_:event:)
        )
        
        var body: some View {
            let toggle1Binding = Binding<Bool>(
                get: { self.model.state.toggle1 },
                set: { value in self.model.send(.toggle1(value: value))}
            )
            let toggle2Binding = Binding(
                get: { self.model.state.toggle2 },
                set: { value in self.model.send(.toggle2(value: value))}
            )
            VStack {
                switch self.model.state {
                case .start:
                    ContentUnavailableView(
                        "Nothing to view",
                        image: "x.circle"
                    )
                case .idle, .error, .loading:
                    TwoTogglesView(
                        toggle1: toggle1Binding,
                        toggle2: toggle2Binding,
                        updateIntent: { self.model.send(.update) },
                        dismissAlert: { self.model.send(.didDismissAlert) }
                    )
                    .padding()
                    .disabled(self.model.state.isLoading)
                }
            }
            .onAppear {
                self.model.send(.start(value1: false, value2: false))
            }
            .alert(
                self.model.state.error?.localizedDescription ?? "",
                isPresented: .constant(self.model.state.error != nil)
            ) {
                Button("OK", role: .cancel) { self.model.send(.didDismissAlert) }
            }
            .overlay {
                if self.model.state.isLoading {
                    ProgressView()
                }
            }
        }
        
    }
    
    struct TwoTogglesView: View {
        @Binding var toggle1: Bool
        @Binding var toggle2: Bool
        let updateIntent: () -> Void
        let dismissAlert: () -> Void
        
        var body: some View {
            VStack {
                Toggle("Toggle 1", isOn: $toggle1)
                Toggle("Toggle 2", isOn: $toggle2)
    
                Button("Update") {
                    updateIntent()
                }
            }
        }
    }
    
    #Preview {
        ContentView()
    }
    
    
    // Some reusable components put into a "namespace"
    enum Lib {
        
        /// An effect value encapsulates a function which may have side effects.
        struct Effect<Event, Env> {
            let f: (Env) async -> Event
            
            init(f: @escaping (Env) async -> Event) {
                self.f = f
            }
            
            func invoke(env: Env) async -> Event {
                await f(env)
            }
        }
        
        /// The world's most simple and concise actor embedding a Finite State Machine and a runtime
        /// component executing side effects in a Task context outside the system.
        ///
        /// - Warning: Use it with care. It's not meant for production.
        
        @MainActor
        @Observable
        final class Model<State, Event> {
            var state: State
            
            private let _send: (Model, Event) -> Void
                  
            /// Initialise the actor with an initial value for the state and a transduce function.
            init<Env>(
                intialState: State,
                env: Env,
                transduce: @escaping (_ state: inout State, _ event: Event) -> [Effect<Event, Env>]
            ) {
                self.state = intialState
                
                self._send = { model, event in
                    let effects = transduce(&model.state, event)
                    effects.forEach { effect in
                        Task { @MainActor [weak model] in
                            guard let model = model else { return }
                            model.send(await effect.invoke(env: env))
                        }
                    }
                }
            }
            
            /// Sends the give event into the system.
            ///
            /// Once the function returns, the state has been changed according the transition function.
            func send(_ event: Event) {
                _send(self, event)
            }
        }
    
    }
    
    enum API {
        static func update(toggle1: Bool, toggle2: Bool) async throws -> (Bool, Bool) {
            try await Task.sleep(for: .milliseconds(1000))
            return (!toggle1, !toggle2)
        }
    }