As known, SwiftUI uses @viewBuilder for building UI via views. @viewBuilder is an implementation of the Result builder. Let me give you another example of using result builder for the MVVM pattern.
Introduction
As known, SwiftUI uses @viewBuilder
for building UI via views. @viewBuilder
is an implementation of the result builder which was implemented in Swift 5.4. There are other examples of using result builder here.
Let me give you another example of using result builder for the MVVM pattern.
Traditional MVVM Implementation
public class ViewModel: ObservableObject {
@Published public var isDownloadingData = false
@Published public var image: UIImage?
@Published public var networkError: String?
public init() { }
enum NetworkError: Error {
case invalidImageUrl
case invalidServerResponse
case unsupportedImage
}
public func downloadPhoto(url: String) {
Task { @MainActor in
isDownloadingData = true
do {
let im = try await fetchPhoto(url: URL(string: url))
image = im
networkError = nil
} catch {
image = nil
networkError = error.localizedDescription
}
isDownloadingData = false
}
}
private func fetchPhoto(url: URL?) async throws -> UIImage {
guard let url else {
throw NetworkError.invalidImageUrl
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.invalidServerResponse
}
guard let image = UIImage(data: data) else {
throw NetworkError.unsupportedImage
}
return image
}
}
The ViewModel
class is a view model example. As you can see, it responds by fetching images by URL and informing clients about the results of the operation. So it has three observable events and one shared method to download images for URLs. The client of the view model can start downloading when it will be ready and waiting for results. Let's implement two kinds of clients. The first one is a SwiftUI contentView
:
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
ZStack {
if viewModel.image == nil {
Image(systemName: Mock.placeholderImageName)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(uiImage: viewModel.image!)
.resizable()
.aspectRatio(contentMode: .fit)
}
VStack {
Spacer()
if viewModel.isDownloadingData {
Text("Downloading...")
.foregroundColor(.green)
.font(.title)
Spacer()
}
if let text = viewModel.networkError {
Text(text)
.foregroundColor(.red)
.font(.title)
}
}
.padding()
} .onAppear() {
viewModel.downloadPhoto(url: Mock.backGroundImageURL)
}
}
}
There is nothing special so far. We don't use dependency injection for the viewModel
because it simplifies the example. As usual, we use @EnvironmentObject
.
And another kind of client. In this case, it's UIKit UIViewController
:
class ViewController: UIViewController {
@IBOutlet weak var backgroundImageView: UIImageView!
@IBOutlet weak var activityIndicator: UIActivityIndicatorView!
@IBOutlet weak var errorMessage: UILabel!
private var disposableBag = Set<AnyCancellable>()
private let viewModel = ViewModel()
override func viewDidLoad() {
subscrbeOnEvents()
viewModel.downloadPhoto(url: Mock.backGroundImageURL)
}
private func subscrbeOnEvents() {
viewModel.$isDownloadingData
.receive(on: RunLoop.main)
.sink { [weak self] inProcess in
if inProcess {
self?.activityIndicator.startAnimating()
} else {
self?.activityIndicator.stopAnimating()
}
}.store(in: &disposableBag)
viewModel.$image
.receive(on: RunLoop.main)
.sink { [weak self] image in
self?.backgroundImageView.image = image
}.store(in: &disposableBag)
viewModel.$networkError
.receive(on: RunLoop.main)
.sink { [weak self] text in
self?.errorMessage.text = text
}.store(in: &disposableBag)
}
}
The viewController
owns viewModel
, subscribes to the events, and updates the UI according to the downloading results. But as you can see, the client 'knows' about the view model's structure and details of implementation. Is there a way to hide that? The client must depend on abstractions only. Let's use a result builder for that.
Creating a Custom Result Builder
public protocol ViewModelEvent {
func perform(at viewModel: ViewModel)
}
@resultBuilder
public struct ViewModelBuilder {
public static func buildBlock(_ components: ViewModelEvent...) -> [ViewModelEvent] {
components
}
}
public extension ViewModel {
convenience init(@ViewModelBuilder _ builder: () -> [ViewModelEvent]) {
self.init()
let components = builder()
for component in components {
component.perform(at: self)
}
}
}
ViewModelBuilder
gives us the ability to rewrite the viewController
with a new domain-specific language (DSL):
class ViewController: UIViewController {
@IBOutlet weak var backgroundImageView: UIImageView!
@IBOutlet weak var activityIndicator: UIActivityIndicatorView!
@IBOutlet weak var errorMessage: UILabel!
private var disposableBag = Set<AnyCancellable>()
private var viewModel: ViewModel!
override func viewDidLoad() {
viewModel = buildViewModel()
viewModel.downloadPhoto(url: Mock.backGroundImageURL)
}
private func buildViewModel() -> ViewModel {
ViewModel {
BackgroundImage()
.onDownloaded { [weak self] image in
self?.activityIndicator.stopAnimating()
self?.backgroundImageView.image = image
}
.isDowloading {[weak self] in
self?.activityIndicator.startAnimating()
}
.onError { [weak self] text in
self?.errorMessage.text = text
}
}
}
}
In this case, the viewController
depends on some abstract view model events ‘domain statements’. In this case, BackgroundImage
, which 'knows' whole about ViewModel
and has some modifiers:
public class BackgroundImage: ViewModelEvent {
typealias DownloadedClosure = (UIImage?) -> Void
typealias IsDowloadingClosure = () -> Void
typealias ErrorClosure = (String?) -> Void
private var onDownloaded: DownloadedClosure?
private var isDowloading: IsDowloadingClosure?
private var onError: ErrorClosure?
private var disposableBag = Set<AnyCancellable>()
public init() {}
public func perform(at viewModel: ViewModel) {
self.isDowloading?()
if onDownloaded != nil {
viewModel.$image
.receive(on: RunLoop.main)
.sink { image in
self.onDownloaded?(image)
}.store(in: &disposableBag)
}
if onError != nil {
viewModel.$networkError
.receive(on: RunLoop.main)
.sink { errorMessage in
self.onError?(errorMessage)
}.store(in: &disposableBag)
}
}
@discardableResult public func onDownloaded(_ closure: @escaping (UIImage?) -> Void) -> BackgroundImage {
onDownloaded = closure
return self
}
@discardableResult public func isDowloading(_ closure: @escaping () -> Void) -> BackgroundImage {
isDowloading = closure
return self
}
@discardableResult public func onError(_ closure: @escaping (String?) -> Void) -> BackgroundImage {
onError = closure
return self
}
}
That's it!
Source code
https://github.com/SKrotkih/ViewModelBuilder
History
- 28th January, 2023: Initial version
- 1 February 2023: Modifiers added