Protocols for ViewModels that support State containers and Event Emission.
- Protocols for handling state (
StateViewModel
) and one-off effects (EventViewModel
) - Seamless with
ObservableObject
and@Published
for idiomatic SwiftUI - Publisher-based APIs for SwiftUI, UIKit, and Combine
- Works cross-platform: iOS, macOS, watchOS, tvOS, visionOS
Add SwiftSmartViewModels using Swift Package Manager.
Xcode:
- File > Add Packages...
- Enter the repository URL and add the package
Or in Package.swift:
dependencies: [
.package(url: "https://github.com/joshgallantt/SwiftSmartViewModels.git", from: "1.0.0")
]
targets: [
.target(
name: "YourTarget",
dependencies: ["SwiftSmartViewModels"]
),
]
struct CounterState {
var count: Int = 0
}
final class CounterViewModel: ObservableObject, StateViewModel {
@Published private(set) var state = CounterState()
var statePublisher: Published<CounterState>.Publisher { $state }
func increment() { state.count += 1 }
}
This works perfectly with SwiftUI's property wrappers:
import SwiftSmartViewModels
import SwiftUI
struct CounterView: View {
@ObservedObject var viewModel: CounterViewModel
var body: some View {
VStack {
Text("Count: \(viewModel.state.count)")
Button("Increment") {
viewModel.increment()
}
}
}
}
struct LoginSucceeded: ViewModelEvent {}
struct LoginFailed: ViewModelEvent { let message: String }
final class LoginViewModel: ObservableObject, EventViewModel {
private let eventSubject = PassthroughSubject<ViewModelEvent, Never>()
var eventPublisher: AnyPublisher<ViewModelEvent, Never> { eventSubject.eraseToAnyPublisher() }
func login(username: String, password: String) {
if username == "cat", password == "meow" {
eventSubject.send(LoginSucceeded())
} else {
eventSubject.send(LoginFailed(message: "Invalid password"))
}
}
}
struct LoginView: View {
@ObservedObject var viewModel: LoginViewModel
@State private var showAlert = false
@State private var alertMessage = ""
var body: some View {
VStack {
Button("Login") {
viewModel.login(username: "cat", password: "meow")
}
}
.onReceive(viewModel.eventPublisher) { event in
if let failure = event as? LoginFailed {
alertMessage = failure.message
showAlert = true
}
}
.alert("Login Error", isPresented: $showAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(alertMessage)
}
}
}
User sees both views. User interacts with the parent view (e.g. a button). Parent view sends command to child view model, child emits event, and parent view reacts via .onReceive
.
import SwiftSmartViewModels
import SwiftUI
import Combine
struct ExampleEvent: ViewModelEvent {
let text: String
}
// 1. ParentView is shown with a button and the child view.
struct ParentView: View {
@ObservedObject var viewModel: ParentViewModel
@State private var lastChildEvent: String = "No event"
var body: some View {
VStack(spacing: 16) {
Button("Send to child") {
// 2. User taps button, triggers parent VM to send command to child
viewModel.sendToChild()
}
Text("Parent received: \(lastChildEvent)")
ChildView(viewModel: viewModel.child)
}
.onReceive(viewModel.child.eventPublisher) { event in
// 6. ParentView listens for child's event and reacts
if let evt = event as? ExampleEvent {
// 7. Parent updates its UI
lastChildEvent = evt.text
}
}
}
}
// 3. ParentViewModel holds child and can send command to it
final class ParentViewModel: ObservableObject {
let child = ChildViewModel()
func sendToChild() {
// 4. Instructs child to emit event
child.emitExampleEvent("Hello from Parent")
}
}
// 5. ChildViewModel emits event when asked
final class ChildViewModel: EventViewModel, ObservableObject {
private let eventSubject = PassthroughSubject<ViewModelEvent, Never>()
var eventPublisher: AnyPublisher<ViewModelEvent, Never> { eventSubject.eraseToAnyPublisher() }
func emitExampleEvent(_ text: String) {
eventSubject.send(ExampleEvent(text: text))
}
}
ParentView
renders and subscribes tochild.eventPublisher
.- User taps "Send to child" button.
ParentViewModel.sendToChild()
is called.ChildViewModel.emitExampleEvent("Hello from Parent")
is called.eventSubject
in child emitsExampleEvent
..onReceive
inParentView
receives the event.lastChildEvent
is updated, UI refreshes.
User sees parent with two children. User interacts with one child view, which causes that child to emit an event. The parent listens, then tells the other child to emit, and the sibling child view listens with .onReceive
and updates UI.
import SwiftSmartViewModels
import SwiftUI
import Combine
struct ExampleEvent: ViewModelEvent {
let text: String
}
// 1. ParentViewModel holds both children
final class ParentViewModel: ObservableObject {
let childA = ChildAViewModel()
let childB = ChildBViewModel()
}
// 2. ParentView is shown with both children
struct ParentView: View {
@ObservedObject var viewModel: ParentViewModel
var body: some View {
VStack(spacing: 16) {
ChildAView(viewModel: viewModel.childA)
ChildBView(viewModel: viewModel.childB)
}
// 5. Listen to childA's events
.onReceive(viewModel.childA.eventPublisher) { event in
if let evt = event as? ExampleEvent {
// 6. ParentView tells childB to emit an event
viewModel.childB.emitExampleEvent("ChildA said: \(evt.text)")
}
}
}
}
// 3. ChildAView shows a button to emit event
struct ChildAView: View {
@ObservedObject var viewModel: ChildAViewModel
var body: some View {
Button("Send to sibling") {
// 4. User taps: childA emits event
viewModel.emitExampleEvent("Hello from ChildA")
}
}
}
// 5. ChildAViewModel emits event when asked
final class ChildAViewModel: EventViewModel, ObservableObject {
private let eventSubject = PassthroughSubject<ViewModelEvent, Never>()
var eventPublisher: AnyPublisher<ViewModelEvent, Never> { eventSubject.eraseToAnyPublisher() }
func emitExampleEvent(_ text: String) {
eventSubject.send(ExampleEvent(text: text))
}
}
// 6. ChildBViewModel emits event when asked by parent
final class ChildBViewModel: EventViewModel, ObservableObject {
private let eventSubject = PassthroughSubject<ViewModelEvent, Never>()
var eventPublisher: AnyPublisher<ViewModelEvent, Never> { eventSubject.eraseToAnyPublisher() }
func emitExampleEvent(_ text: String) {
eventSubject.send(ExampleEvent(text: text))
}
}
// 7. ChildBView listens for childB's events and updates
struct ChildBView: View {
@ObservedObject var viewModel: ChildBViewModel
@State private var lastEvent: String = "No event"
var body: some View {
Text("ChildB received: \(lastEvent)")
.onReceive(viewModel.eventPublisher) { event in
// 8. ChildBView receives event from its own VM and updates UI
if let evt = event as? ExampleEvent {
lastEvent = evt.text
}
}
}
}
- User sees
ParentView
displayingChildAView
andChildBView
. - User taps the button in
ChildAView
. ChildAView
callsviewModel.emitExampleEvent("Hello from ChildA")
onChildAViewModel
.ChildAViewModel
sends anExampleEvent
via itseventSubject
.ParentView
listens tochildA.eventPublisher
via.onReceive
, receives the event.ParentView
, in response, tellschildB
to emit a new event with a message referencing ChildA.ChildBViewModel
emits anExampleEvent
via its owneventSubject
.ChildBView
listens toviewModel.eventPublisher
via.onReceive
, receives the event, and updates its UI (lastEvent
).
MIT – see LICENSE
Open an issue or join a discussion!
Made with ❤️ by Josh Gallant