Search code examples
rx-swiftuialertcontroller

How to set properties and get callback from UIAlertController in RxSwift?


I have a ViewModelType to bind UIViewController & ViewModel.

import Foundation

protocol ViewModelType {
    associatedtype Input
    associatedtype Output
    
    func transform(input: Input) -> Output
}

The HomeViewModel conforms to ViewModelType and defines the Input and Output required, then it does the job returning the outputs based on the inputs.

For simplicity I have removed the repository and always returning failure for syncData task.

import Foundation
import RxSwift
import RxCocoa

class HomeViewModel: ViewModelType {

  struct Input {
    let syncData: Driver<Void>
  }
  
  struct Output {
    let message: Driver<String>
  }
  
  func transform(input: Input) -> Output {
    let fetching = input.syncData.flatMapLatest { _ -> Driver<String> in
      return Observable<String>.from(optional: "Choose below options to proceed") // This message will be returned by server.
        .delay(.seconds(1), scheduler: MainScheduler.instance)
        .asDriverOnErrorJustComplete()
    }
    return Output(message: fetching)
  }
}

I have a alert binder which takes a String.

The UIAlertController have a retry button on click of retry button I want to call syncData from Input of HomeViewModel How do I do that?

import UIKit
import RxSwift
import RxCocoa

class HomeViewController: UIViewController {

  private let disposeBag = DisposeBag()
  
  var viewModel = HomeViewModel()
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    let viewDidAppear = rx.sentMessage(#selector(UIViewController.viewDidAppear(_:)))
    .mapToVoid()
    .asDriverOnErrorJustComplete()
    
    // How to merge viewWillAppear & alert for callback of retry button?
    let input = HomeViewModel.Input(syncData: viewDidAppear)
    let output = viewModel.transform(input: input)
    
    output.message.drive(alert)
      .disposed(by: disposeBag)
  }

  var alert: Binder<String> {
    return Binder(self) { (vc, message) in
      let alert = UIAlertController(title: "Sync failed!",
                                    message: message,
                                    preferredStyle: .alert)
      let okay = UIAlertAction(title: "Retry", style: .default, handler: { _ in
        // how to call syncData of Input?
      })
      let dismiss = UIAlertAction(title: "Dismiss",
                                 style: UIAlertAction.Style.cancel,
                                 handler: nil)

      alert.addAction(okay)
      alert.addAction(dismiss)
      vc.present(alert, animated: true, completion: nil)
    }
  } 
}

Solution

  • There are two instances when you should use Subjects, when you are converting non-RxCode into RxCode (like making a UIAlertAction reactive,) and when you have to create a cycle (like feeding the output of the view model back into its own input.)

    If you find yourself doing this a lot, then you might want to consider making multiple view models.

    You will also notice that I made the creation of the alert its own, independent, coordinator function. That way it can be used in multiple places. You can do the same thing with the closure passed into the flatMapFirst if you want.

    class HomeViewModel {
    
        struct Input {
            let syncData: Observable<Void>
            let retry: Observable<Void>
        }
    
        struct Output {
            let message: Observable<String>
        }
    
        func transform(input: Input) -> Output {
            let fetching = Observable.merge(input.syncData, input.retry)
                .flatMapLatest { _ -> Observable<String> in
                    return Observable<String>.from(optional: "Choose below options to proceed") // This message will be returned by server.
                        .delay(.seconds(1), scheduler: MainScheduler.instance)
            }
            return Output(message: fetching)
        }
    }
    
    class HomeViewController: UIViewController {
    
        private let disposeBag = DisposeBag()
    
        var viewModel = HomeViewModel()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            let viewDidAppear = rx.sentMessage(#selector(UIViewController.viewDidAppear(_:)))
                .mapToVoid()
    
            let retry = PublishSubject<Void>()
    
            let input = HomeViewModel.Input(syncData: viewDidAppear, retry: retry.asObservable())
            let output = viewModel.transform(input: input)
    
            output.message
                .flatMapFirst { [weak self] (message) -> Observable<Void> in
                    let (alert, trigger) = createAlert(message: message)
                    self?.present(alert, animated: true)
                    return trigger
                }
                .subscribe(retry)
                .disposed(by: disposeBag)
        }
    }
    
    func createAlert(message: String) -> (UIViewController, Observable<Void>) {
        let trigger = PublishSubject<Void>()
        let alert = UIAlertController(title: "Sync failed!",
                                      message: message,
                                      preferredStyle: .alert)
        let okay = UIAlertAction(title: "Retry", style: .default, handler: { _ in
            trigger.onNext(())
            trigger.onCompleted()
        })
        let dismiss = UIAlertAction(title: "Dismiss",
                                    style: UIAlertAction.Style.cancel,
                                    handler: nil)
    
        alert.addAction(okay)
        alert.addAction(dismiss)
        return (alert, trigger)
    }