Result Builder in Swift for MVVM Pattern

Serhii Krotkykh
4 min readJan 28, 2023

--

As known, SwiftUI uses @viewBuilder for building UI via views. @viewBuilder is an implementation of the Result builder. You can find out more about it by reading Result builder proposal at the Swift Evolution proposals website.

Image from Pixabay

There are other examples of using Result builder.

Let me give you another example of using a 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 {
image = try await fetchPhoto(url: URL(string: url))
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 will be ready and waiting for results.

Let’s implement two kinds of clients. The first one is a SwiftUI contentView:

struct ContentView<ViewModel: ViewModelDowladable>: 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)
}
}
}

And another kind of client. In this case, it’s UIKit ViewController:

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 its implementation.

Is there a way to hide that?

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 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 {
// This is where DSL actually starts
ViewModel {
// @ViewModelBuilder uses ViewModelEvents
// BackgroundImage is a ViewModelEvent
BackgroundImage()
// ViewModelEvent modifiers
.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() {}

/// Called by the builder just one time
/// - Parameter viewModel: ViewModel
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)
}
}

// Modifiers
@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
}
}

Source code

https://github.com/SKrotkih/ViewModelBuilder

Learn more about the @ViewModelBuilder:

History

  • 28th January 2023: Initial version
  • 1 February 2023: Modifiers added

--

--