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

Viewed 524

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)
    }
  } 
}
2 Answers

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

First of all, you should use some Coordinator for calling push/present controllers. And make function which represent Alert.

For example:

class Router {
private let rootViewController: UIViewController
let retryAction = PublishSubject<Void>()

func showGalleryAlert() {
    let alert = UIAlertController(title: "Your Title", message: "Your Message", preferredStyle: .alert)

    let settings = UIAlertAction(title: "Name Action", style: .default) { _ in
        // send here your action to PublishSubject
        self.retryAction.send()
    }
    let cancel = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
    alert.addAction(cancel)
    alert.addAction(settings)
    rootViewController.present(alert, animated: true)
}}

Than you need inject this router to your ViewModel and listen this PublishSubject.

Or you can use Single/Maybe function, here is small example how can use it:

  public func openList() -> Maybe<Void> {          
        return .create { observer -> Disposable in
              let alert = UIAlertController(title: "Your Title", message: "YourMessage", preferredStyle: .alert)

      let settings = UIAlertAction(title: "Name Action", style: .default) { _ in
        // send here your action
        observer.send() 
     }
      let cancel = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
      alert.addAction(cancel)
      alert.addAction(settings)
      rootViewController.present(alert, animated: true)
            return Disposables.create {
                DispatchQueue.main.async {
                    vc.dismiss(animated: true, completion: nil)
                }
            }
        }
    }

And handle it via ViewModel.

P.S: You should use on inputs Subjects in ViewModel, not Drivers. Driver MUST be only for views like output.

Related