Skip to content

Instantly share code, notes, and snippets.

@chrishulbert
Created May 21, 2024 05:00
Show Gist options
  • Save chrishulbert/9a21635a581e044f86e3ccc1d56010a6 to your computer and use it in GitHub Desktop.
Save chrishulbert/9a21635a581e044f86e3ccc1d56010a6 to your computer and use it in GitHub Desktop.
Previewable SwiftUI ViewModel
import SwiftUI
// Every different screen's VM re-uses this one VM protocol:
protocol ViewModel<ViewEvent, ViewState>: ObservableObject {
associatedtype ViewEvent
associatedtype ViewState
// For comms in the VM -> View direction:
var viewState: ViewState { get set }
// For comms in the View -> VM direction:
func handle(event: ViewEvent)
}
// This is specific to one screen, and used to update what is displayed.
struct FooViewState: Equatable {
var text: String
var sheetIsPresented: Bool = false
}
// This is specific to one screen, and used by the view to communicate
// to the VM.
enum FooViewEvent {
case hello
case goodbye
case present
}
// This is an example of a screen with a generic VM, so you can use the
// normal VM, or you can preview it with a mock VM.
struct FooView<VM: ViewModel>: View
where VM.ViewEvent == FooViewEvent,
VM.ViewState == FooViewState
{
@StateObject var viewModel: VM
var body: some View {
VStack {
Text(viewModel.viewState.text)
Button("Hello") {
viewModel.handle(event: .hello)
}
Button("Goodbye") {
viewModel.handle(event: .goodbye)
}
Button("Present modal sheet") {
viewModel.handle(event: .present)
}
}
.sheet(isPresented: $viewModel.viewState.sheetIsPresented) {
Text("This is a modal sheet!")
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
}
}
// This is the real VM for this screen.
class FooViewModel: ViewModel {
@Published var viewState: FooViewState
init() {
viewState = FooViewState(
text: "Nothing has happened yet."
)
}
func handle(event: FooViewEvent) {
switch event {
case .hello:
viewState.text = "👋"
case .goodbye:
viewState.text = "😢"
case .present:
viewState.sheetIsPresented = true
}
}
}
#if targetEnvironment(simulator)
// This is declared once and used by all screens you want to preview.
// Is 'preview view' a tautology? Should this be called 'PreviewModel'
// or 'PreViewModel' ? Flip a coin to decide...
class PreviewViewModel<ViewEvent, ViewState>: ViewModel {
@Published var viewState: ViewState
init(viewState: ViewState) {
self.viewState = viewState
}
func handle(event: ViewEvent) {
print("Handle event: \(event)")
}
}
#Preview("Preview viewmodel") {
FooView(
viewModel: PreviewViewModel(
viewState: FooViewState(
text: "This is a preview!"
)
)
)
}
#Preview("Real viewmodel") {
FooView(viewModel: FooViewModel())
}
#endif
@swhitty
Copy link

swhitty commented May 21, 2024

Nice — I really like this pattern!

@chrishulbert
Copy link
Author

99% inspired by @swhitty and @MightyFine I cannot take much credit!

@MightyFine
Copy link

Hehe team effort 😉 great write up!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment