Greeting, traveler!
This is the second part of the series on implementing the SwiftUI Coordinator pattern. We are building an application that showcases the convenience of this pattern. As mentioned earlier, there will be several screens connected via the coordinator. In the previous lesson, we handled TabView and laid the foundation of our app. In this lesson, we will create our first screen, PostList, with its ViewModel.
PostList
Firstly, we need to create two entities – View and ViewModel. Let’s call them PostListView and PostListViewModel. We can use the Xcode SwiftUI template to create a PostListView and leave it for now.
import SwiftUI
struct PostListView: View {
var body: some View {
EmptyView()
}
}
The second step of creating this module is creating the ViewModel. Let’s create a file called PostListViewModel. I suggest importing the Observation framework first. We will use it to handle data changes and transport between the ViewModel and View. Then, let’s create a final class, PostListViewModel, and add an @Observable macro.
import Observation
@Observable
final class PostListViewModel {
}
Data source
We will provide some straightforward content for the PostListView. I prefer the old Lorem Ipsum generator, so let’s create one. Create a struct called LoremIpsumGenerator. It should have private property. Let’s call it parts. It will contain some Lorem Ipsum text parts as a String array. We can create a function called ‘generate’ with simple code inside to get parts from this array.
struct LoremIpsumGenerator {
private var parts: [String] = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.",
"Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.",
"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?",
"At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga.",
"Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.",
"Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat."
]
func generate() -> String {
parts[Int.random(in: 0...parts.count-1)]
}
}
After that, let’s create a separate class, DataSource, which can keep this generator as its property and use it to fill its own data array. DataSource’s data will be represented with another entity, let’s call it Post. It must conform to the Hashable protocol and have two properties: ID and text. After that, we are free to fill in the data.
public final class DataSource {
struct Post: Hashable {
let id = UUID()
var text: String
}
var data: [Post] {
[
.init(text: generator.generate()),
.init(text: generator.generate()),
.init(text: generator.generate()),
.init(text: generator.generate())
]
}
private let generator = LoremIpsumGenerator()
}
State
Now, we can return to our ViewModel and create some properties there. The first one will be the state property. What is state? This property will represent the current state of the ViewModel, and our View will listen to it and get the relevant data.
Enums are one of the most convenient and precise ways to create states. So, let’s make one. It will have three states: loading, display, and error. The display case should have an associated value with data, which we will provide to the PostListView.
enum PostListState {
case loading
case display([DataSource.Post])
case error(Error)
}
Protocols
Let’s create a protocol for our ViewModel while designing the View and ViewModel communication interface. We called it PostListViewModelProtocol. It will contain only one property to represent the ViewModel’s state. Since we are using SwiftUI and Observation, we also need to ensure that the object implementing our protocol also implements the Observable protocol. We will create a typealias to make it more convenient.
import Observation
typealias PostListViewModelObservable = PostListViewModelProtocol & Observable
protocol PostListViewModelProtocol: AnyObject {
var state: PostListState { get }
}
The protocol we created will go to the PostListViewModel, and the typealias will be used inside PostListView.
Now we can implement PostListViewModelProtocol inside PostListViewModel. We will simulate data fetching with Task.sleep.
import Observation
@Observable
final class PostListViewModel: PostListViewModelProtocol {
private(set) var state: PostListState = .loading
private let dataSource = DataSource()
init() {
Task {
try await Task.sleep(for: .seconds(2))
await loadData()
}
}
@MainActor
private func loadData() {
state = .display(dataSource.data)
}
}
Alright then, we’re done with our work here and free to move to our View. Create a private property with PostListViewModelObservable type. Also, an initializer should be created to provide the value for this property from the outside.
struct PostListView: View {
@State private var viewModel: PostListViewModelObservable
init(viewModel: PostListViewModelObservable) {
self.viewModel = viewModel
}
var body: some View {
EmptyView()
}
}
Receiving data
Then, create a view binding to the PostListViewModel state. If there is a loading state, PostListView will display only ProgressView. If it is an error, PostListView will display its description. And, of course, if it is a display state with associated data, PostListView will display this data to the user. We will use a ScrollView with a ForEach view inside.
struct PostListView: View {
@State private var viewModel: PostListViewModelObservable
init(viewModel: PostListViewModelObservable) {
self.viewModel = viewModel
}
var body: some View {
switch viewModel.state {
case .loading:
ProgressView()
case let .display(postList):
ScrollView {
LazyVStack {
ForEach(postList, id: \.self) { post in
VStack {
Text(post.text)
.padding()
.font(.headline)
Divider()
.padding()
}
}
}
}
case let .error(error):
Text(error.localizedDescription)
}
}
}
PostDetails
Since we are gathered here, let’s make the second screen, which, fortunately, is even simpler. The scheme is the same: create a Postdetailview and a PostDetailsViewModel. PostDetailsViewModel should conform to a specific protocol. ViewModel will provide content to the View. It will receive the content data from the outside via its initializer.
// protocol
typealias PostDetailViewModelObservable = PostDetailViewModelProtocol & Observable
protocol PostDetailViewModelProtocol: AnyObject {
var content: String { get }
}
// view
struct PostDetailsView: View {
@State private var viewModel: PostDetailViewModelObservable
init(viewModel: PostDetailViewModelObservable) {
self.viewModel = viewModel
}
var body: some View {
ScrollView {
Text(viewModel.content)
.padding()
}
}
}
// view model
@Observable
final class PostDetailViewModel: PostDetailViewModelProtocol {
var content: String
init(content: String) {
self.content = content
}
}
Conclusion
Okay! But where is anything about the coordinators? It would be better to cover the communication between View and Coordinator in a separate article.
And I look forward to seeing you there.
P.S.
As always, the source code is on GitHub.