Click here to Skip to main content
15,867,453 members
Articles / Programming Languages / Swift
Tip/Trick

Result Builder in Swift for MVVM Pattern

Rate me:
Please Sign up or sign in to vote.
5.00/5 (1 vote)
17 Feb 2023CPOL1 min read 12K   8  
Example of using result builder for the MVVM pattern
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

Swift
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:

Swift
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:

Swift
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

Swift
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):

Swift
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:

Swift
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
  }
}

That's it!

Source code

https://github.com/SKrotkih/ViewModelBuilder

History

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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Ukraine Ukraine
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- There are no messages in this forum --