Hi all, I’d like to talk about a way to setup your ViewModels in SwiftUI to make previews easy:
I’ve used a variant of this (I simplified it a little) with a big team before so I know it’s battle-proven. But of course this may be more helpful as a starting point for you, too.
The general idea is this: Have a ‘ViewModel’ protocol, and make your Views have a generic constraint to accept any ViewModel that uses that view’s specific state/events, and use a preview viewmodel that adheres to the protocol.
So here’s the generic ViewModel that every screen will re-use. ViewEvent is typically an enum, and used by the View to eg send button presses to the ViewModel. ViewState is the struct that is used to push the loaded/loading/error/whatever state to the View.
protocol ViewModel<ViewEvent, ViewState>: ObservableObject {
associatedtype ViewEvent
associatedtype ViewState
// For communication in the VM -> View direction:
var viewState: ViewState { get set }
// For communication in the View -> VM direction:
func handle(event: ViewEvent)
}
Somewhere you’ll have a ‘preview’ viewmodel.
This is declared once and used by all screens you want to preview.
I’m a fan of putting your preview code in a conditional compilation statement.
Note that this allows you to inject any viewstate you like.
Is ‘preview view’ a tautology? Should this be called PreviewModel
or PreViewModel
? Flip a coin to decide…
#if targetEnvironment(simulator)
class PreviewViewModel<ViewEvent, ViewState>: ViewModel {
@Published var viewState: ViewState
init(viewState: ViewState) {
self.viewState = viewState
}
func handle(event: ViewEvent) {
print("Event: \(event)")
}
}
#endif
Before I show the view, I’ll introduce the event and states. Firstly the event enum, this is the single ‘pipe’ via which the View calls through to the ViewModel (aspirationally… 2-way bindings sidestep this). You will likely have associated values on some of these, eg the id of which row was pressed, that kind of thing:
enum FooViewEvent {
case hello
case goodbye
case present
}
Next is the ViewState. This controls what is displayed. Typically you might have an loading/loaded/error enum in here, among other things. Notice there’s an ‘xIsPresented’ var here that is used in a 2-way-binding later for modal presentation:
struct FooViewState: Equatable {
var text: String
var sheetIsPresented: Bool = false
}
Ok, now the state and event are out of the way, here’s how a view might look. Note the gnarly generic clause up the top, this is the trickiest part of this whole technique to be honest. Basically it’s saying ‘I can accept any ViewModel that uses this particular screen’s event/state’. Also note the 2-way binding for the modal sheet: even though this somewhat side-steps the idea of piping all input/output through the event/state concept, it’s very SwiftUI-idiomatic to use these bindings so I don’t want to be overly rigid and make life difficult: we want to avoid ‘cutting against the grain’ when working with SwiftUI. So, yeah, this isn’t architecturally pure, but it is productive!
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)
}
}
}
Last but not least is the ViewModel for this screen.
Note that because viewState is @Published
, and ViewModel is a @StateObject
, any updates to viewState are magically automatically applied to the View. It’s really simple, no Combine required!
Also note the xIsPresented
is trivial to set to true to present something, far simpler than using some form of router which I fear can be convoluted.
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
}
}
}
At the bottom of the view file you’ll want your previews. By using the PreviewViewModel you can inject whatever ViewState you like:
#if targetEnvironment(simulator)
#Preview {
FooView(
viewModel: PreviewViewModel(
viewState: FooViewState(
text: "This is a preview!"
)
)
)
}
#endif
I hope this helps you use SwiftUI in a preview-friendly way! SwiftUI without previews is the pits…
The source for this is on this github gist here
Thanks for reading, hope you found this helpful, at least a tiny bit, God bless!
Photo by Yahya Gopalani on Unsplash Font by Khurasan on Dafont
Thanks for reading! And if you want to get in touch, I'd love to hear from you: chris.hulbert at gmail.
(Comp Sci, Hons - UTS)
Software Developer (Freelancer / Contractor) in Australia.
I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Coles, Woolworths, Trust Bank, and Westpac, among others. If you're looking for help developing an iOS app, drop me a line!
Get in touch:
[email protected]
github.com/chrishulbert
linkedin