The letter first published at the Medium.com. Follow me for more content like this and don't forget to drop a clap if you like it :) Another publish at the CodeProject My Linkedin profile
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. There are other examples of using Result builder here. Let me give you another example of using Result builder for the MVVM pattern.
public class ViewModel: ViewModelDowladable {
@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 downloadImage(url: String) {
Task { @MainActor in
isDownloadingData = true
do {
let im = try await fetchImage(url: URL(string: url))
image = im
networkError = nil
} catch {
image = nil
networkError = error.localizedDescription
}
isDownloadingData = false
}
}
private func fetchImage(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<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.downloadImage(url: Mock.backGroundImageURL)
}
}
}
There is nothing special so far. 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 = ViewModel()
override func viewDidLoad() {
subscrbeOnEvents()
viewModel.downloadImage(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.
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.downloadImage(url: Mock.backGroundImageURL)
}
private func buildViewModel() -> ViewModel {
ViewModel {
// @ViewModelBuilder uses ViewModelEvents
// BackgroundImage is the 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' BackgroundImage, IsDownloadingData, and NetworkError. They belong to the ViewModel.
public class BackgroundImage: ViewModelEvent {
typealias DownloadedClosure = (UIImage?) -> Void
typealias IsDowloadingClosure = () -> Void
typealias ErrorClosure = (String?) -> Void
private var disposableBag = Set<AnyCancellable>()
private var onDownloaded: DownloadedClosure?
private var isDowloading: IsDowloadingClosure?
private var onError: ErrorClosure?
public init() {}
/// Called by the builder
/// - 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
}
}
That's it! You can find the source code of the @ViewModelBuilder here. Learn more about the @ViewModelBuilder here(https://github.com/SKrotkih/TwilioCallKitQuickstart) and here(https://github.com/SKrotkih/twilio-voice-ios-adapter).
Thank you!
- Xcode 14+
- Swift 5.7
- 28th January, 2023: Initial version
- 1 February 2023: Modifiers added