I have the following requirements in my SwiftUI view:
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
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)
}
}