RxSwiftでViewController
とViewModel
のBinding部分をどう書けばいいかわからなかったので、サンプルコードを集めてみました。
ReactiveX/RxSwift
GitHubSignupViewController2.swift
class GitHubSignupViewController2 : ViewController {
@IBOutlet weak var usernameOutlet: UITextField!
@IBOutlet weak var usernameValidationOutlet: UILabel!
@IBOutlet weak var passwordOutlet: UITextField!
@IBOutlet weak var passwordValidationOutlet: UILabel!
@IBOutlet weak var repeatedPasswordOutlet: UITextField!
@IBOutlet weak var repeatedPasswordValidationOutlet: UILabel!
@IBOutlet weak var signupOutlet: UIButton!
@IBOutlet weak var signingUpOulet: UIActivityIndicatorView!
override func viewDidLoad() {
super.viewDidLoad()
let viewModel = GithubSignupViewModel2(
input: (
username: usernameOutlet.rx.text.asDriver(),
password: passwordOutlet.rx.text.asDriver(),
repeatedPassword: repeatedPasswordOutlet.rx.text.asDriver(),
loginTaps: signupOutlet.rx.tap.asDriver()
),
dependency: (
API: GitHubDefaultAPI.sharedAPI,
validationService: GitHubDefaultValidationService.sharedValidationService,
wireframe: DefaultWireframe.sharedInstance
)
)
// bind results to {
viewModel.signupEnabled
.drive(onNext: { [weak self] valid in
self?.signupOutlet.isEnabled = valid
self?.signupOutlet.alpha = valid ? 1.0 : 0.5
})
.addDisposableTo(disposeBag)
viewModel.validatedUsername
.drive(usernameValidationOutlet.rx.validationResult)
.addDisposableTo(disposeBag)
viewModel.validatedPassword
.drive(passwordValidationOutlet.rx.validationResult)
.addDisposableTo(disposeBag)
viewModel.validatedPasswordRepeated
.drive(repeatedPasswordValidationOutlet.rx.validationResult)
.addDisposableTo(disposeBag)
viewModel.signingIn
.drive(signingUpOulet.rx.animating)
.addDisposableTo(disposeBag)
viewModel.signedIn
.drive(onNext: { signedIn in
print("User signed in \(signedIn)")
})
.addDisposableTo(disposeBag)
//}
let tapBackground = UITapGestureRecognizer()
tapBackground.rx.event
.subscribe(onNext: { [weak self] _ in
self?.view.endEditing(true)
})
.addDisposableTo(disposeBag)
view.addGestureRecognizer(tapBackground)
}
// This is one of the reasons why it's a good idea for disposal to be detached from allocations.
// If resources weren't disposed before view controller is being deallocated, signup alert view
// could be presented on top of the wrong screen or could crash your app if it was being presented
// while navigation stack is popping.
// This will work well with UINavigationController, but has an assumption that view controller will
// never be added as a child view controller. If we didn't recreate the dispose bag here,
// then our resources would never be properly released.
override func willMove(toParentViewController parent: UIViewController?) {
if let parent = parent {
assert(parent as? UINavigationController != nil, "Please read comments")
}
else {
self.disposeBag = DisposeBag()
}
}
}
class GithubSignupViewModel2 {
// outputs {
//
let validatedUsername: Driver<ValidationResult>
let validatedPassword: Driver<ValidationResult>
let validatedPasswordRepeated: Driver<ValidationResult>
// Is signup button enabled
let signupEnabled: Driver<Bool>
// Has user signed in
let signedIn: Driver<Bool>
// Is signing process in progress
let signingIn: Driver<Bool>
// }
init(
input: (
username: Driver<String>,
password: Driver<String>,
repeatedPassword: Driver<String>,
loginTaps: Driver<Void>
),
dependency: (
API: GitHubAPI,
validationService: GitHubValidationService,
wireframe: Wireframe
)
) {
let API = dependency.API
let validationService = dependency.validationService
let wireframe = dependency.wireframe
/**
Notice how no subscribe call is being made.
Everything is just a definition.
Pure transformation of input sequences to output sequences.
When using `Driver`, underlying observable sequence elements are shared because
driver automagically adds "shareReplay(1)" under the hood.
.observeOn(MainScheduler.instance)
.catchErrorJustReturn(.Failed(message: "Error contacting server"))
... are squashed into single `.asDriver(onErrorJustReturn: .Failed(message: "Error contacting server"))`
*/
validatedUsername = input.username
.flatMapLatest { username in
return validationService.validateUsername(username)
.asDriver(onErrorJustReturn: .failed(message: "Error contacting server"))
}
validatedPassword = input.password
.map { password in
return validationService.validatePassword(password)
}
validatedPasswordRepeated = Driver.combineLatest(input.password, input.repeatedPassword, resultSelector: validationService.validateRepeatedPassword)
let signingIn = ActivityIndicator()
self.signingIn = signingIn.asDriver()
let usernameAndPassword = Driver.combineLatest(input.username, input.password) { ($0, $1) }
signedIn = input.loginTaps.withLatestFrom(usernameAndPassword)
.flatMapLatest { (username, password) in
return API.signup(username, password: password)
.trackActivity(signingIn)
.asDriver(onErrorJustReturn: false)
}
.flatMapLatest { loggedIn -> Driver<Bool> in
let message = loggedIn ? "Mock: Signed in to GitHub." : "Mock: Sign in to GitHub failed"
return wireframe.promptFor(message, cancelAction: "OK", actions: [])
// propagate original value
.map { _ in
loggedIn
}
.asDriver(onErrorJustReturn: false)
}
signupEnabled = Driver.combineLatest(
validatedUsername,
validatedPassword,
validatedPasswordRepeated,
signingIn
) { username, password, repeatPassword, signingIn in
username.isValid &&
password.isValid &&
repeatPassword.isValid &&
!signingIn
}
.distinctUntilChanged()
}
}
sergdort/RxMarvel
HeroesListViewController.swift
class HeroesListViewController: RxTableViewController {
lazy var searchDataSource = RxTableViewSectionedReloadDataSource<HeroCellSection>()
lazy var dataSource = RxTableViewSectionedReloadDataSource<HeroCellSection>()
lazy var searchContentController = UITableViewController()
lazy var searchCotroller: UISearchController = {
return UISearchController(searchResultsController: self.searchContentController)
}()
@IBOutlet var rightBarButton: UIBarButtonItem!
override func viewDidLoad() {
super.viewDidLoad()
setupDataSource()
setupBindings()
}
}
// MARK:Private
extension HeroesListViewController {
private func setupBindings() {
tableView.tableHeaderView = searchCotroller.searchBar
let input = HeroListViewModel.Input(searchQuery: searchCotroller.searchBar.rx_text.asObservable(),
nextPageTrigger: tableView.rx_nextPageTriger,
searchNextPageTrigger: searchContentController.tableView.rx_nextPageTriger,
dismissTrigger: rightBarButton.rx_tap.asDriver())
let viewModel = HeroListViewModel(input: input,
api: DefaultHeroAPI())
viewModel.mainTableItems
.drive(tableView.rx_itemsWithDataSource(dataSource))
.addDisposableTo(disposableBag)
viewModel.searchTableItems
.drive(searchContentController.tableView.rx_itemsWithDataSource(searchDataSource))
.addDisposableTo(disposableBag)
viewModel.dismissTrigger.asDriver(onErrorJustReturn: ())
.driveNext { [weak self] in
self?.dismissViewControllerAnimated(true, completion: nil)
}
.addDisposableTo(disposableBag)
}
private func setupDataSource() {
dataSource.configureCell = BindableCellFactory<HeroListTableViewCell, HeroCellData>.configureCellFromNib
searchDataSource.configureCell = BindableCellFactory<HeroListTableViewCell, HeroCellData>.configureCellFromNib
tableView.dataSource = nil
searchContentController.tableView.dataSource = nil
}
}
class HeroListViewModel {
struct Input {
let searchQuery: Observable<String>
let nextPageTrigger: Observable<Void>
let searchNextPageTrigger: Observable<Void>
let dismissTrigger: Driver<Void>
}
let mainTableItems: Driver<[HeroCellSection]>
let searchTableItems: Driver<[HeroCellSection]>
let dismissTrigger: Driver<Void>
init(input: Input, api: HeroAPI, scheduler: SchedulerType = MainScheduler.instance) {
searchTableItems = input.searchQuery
.filter { !$0.isEmpty }.debug("filter")
.throttle(0.3, scheduler: scheduler)//2
.debug("throttle")
.flatMapLatest { //3
return api.searchItems($0,
batch: Batch.initial,
endPoint: EndPoint.Characters,
nextBatchTrigger: input.searchNextPageTrigger)
.catchError { _ in
return Observable.empty()
}
}
.map { //4
return $0.map(HeroCellData.init)
}
.map {//5
return [HeroCellSection(items: $0)]
}
.asDriver(onErrorJustReturn: [])
mainTableItems = api
.paginateItems(Batch.initial,
endPoint: EndPoint.Characters,
nextBatchTrigger: input.nextPageTrigger)
.map {
return [HeroCellSection(items: $0.map(HeroCellData.init))]
}
.asDriver(onErrorJustReturn: [])
dismissTrigger = input.dismissTrigger
}
}
devxoul/RxTodo
final class TaskEditViewController: BaseViewController {
// MARK: Constants
struct Metric {
static let padding = 15.f
static let titleInputCornerRadius = 5.f
static let titleInputBorderWidth = 1 / UIScreen.mainScreen().scale
}
struct Font {
static let titleLabel = UIFont.systemFontOfSize(14)
}
struct Color {
static let titleInputBorder = UIColor.lightGrayColor()
}
// MARK: Properties
let cancelBarButtonItem = UIBarButtonItem(barButtonSystemItem: .Cancel, target: nil, action: Selector())
let doneBarButtonItem = UIBarButtonItem(barButtonSystemItem: .Done, target: nil, action: Selector())
let titleInput = UITextField().then {
$0.autocorrectionType = .No
$0.font = Font.titleLabel
$0.layer.cornerRadius = Metric.titleInputCornerRadius
$0.layer.borderWidth = Metric.titleInputBorderWidth
$0.layer.borderColor = Color.titleInputBorder.CGColor
}
// MARK: Initializing
init(viewModel: TaskEditViewModelType) {
super.init()
self.navigationItem.leftBarButtonItem = self.cancelBarButtonItem
self.navigationItem.rightBarButtonItem = self.doneBarButtonItem
self.configure(viewModel)
}
required convenience init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .whiteColor()
self.view.addSubview(self.titleInput)
}
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
self.titleInput.becomeFirstResponder()
}
override func setupConstraints() {
self.titleInput.snp_makeConstraints { make in
make.top.equalTo(20 + 44 + Metric.padding)
make.left.equalTo(Metric.padding)
make.right.equalTo(-Metric.padding)
}
}
// MARK: Configuring
private func configure(viewModel: TaskEditViewModelType) {
// 2-Way Binding
(self.titleInput.rx_text <-> viewModel.title)
.addDisposableTo(self.disposeBag)
// Input
self.cancelBarButtonItem.rx_tap
.bindTo(viewModel.cancelButtonDidTap)
.addDisposableTo(self.disposeBag)
self.doneBarButtonItem.rx_tap
.bindTo(viewModel.doneButtonDidTap)
.addDisposableTo(self.disposeBag)
// Output
viewModel.navigationBarTitle
.drive(self.navigationItem.rx_title)
.addDisposableTo(self.disposeBag)
viewModel.doneButtonEnabled
.drive(self.doneBarButtonItem.rx_enabled)
.addDisposableTo(self.disposeBag)
viewModel.presentCancelAlert
.driveNext { [weak self] title, message, leaveTitle, stayTitle in
guard let `self` = self else { return }
let alertController = UIAlertController(title: title, message: message, preferredStyle: .Alert)
let actions = [
UIAlertAction(title: leaveTitle, style: .Destructive) { _ in
viewModel.alertLeaveButtonDidTap.onNext()
},
UIAlertAction(title: stayTitle, style: .Default) { _ in
self.titleInput.becomeFirstResponder()
viewModel.alertStayButtonDidTap.onNext()
}
]
actions.forEach(alertController.addAction)
self.view.endEditing(true)
self.presentViewController(alertController, animated: true, completion: nil)
}
.addDisposableTo(self.disposeBag)
viewModel.dismissViewController
.driveNext { [weak self] in
self?.view.endEditing(true)
self?.dismissViewControllerAnimated(true, completion: nil)
}
.addDisposableTo(self.disposeBag)
}
}
enum TaskEditViewMode {
case New
case Edit(Task)
}
protocol TaskEditViewModelType {
// 2-Way Binding
var title: Variable<String> { get }
// Input
var cancelButtonDidTap: PublishSubject<Void> { get }
var doneButtonDidTap: PublishSubject<Void> { get }
var alertLeaveButtonDidTap: PublishSubject<Void> { get }
var alertStayButtonDidTap: PublishSubject<Void> { get }
var memo: PublishSubject<String> { get }
// Output
var navigationBarTitle: Driver<String?> { get }
var doneButtonEnabled: Driver<Bool> { get }
var presentCancelAlert: Driver<(String, String, String, String)> { get }
var dismissViewController: Driver<Void> { get }
}
struct TaskEditViewModel: TaskEditViewModelType {
// MARK: 2-Way Binding
var title: Variable<String>
// MARK: Input
let cancelButtonDidTap = PublishSubject<Void>()
let doneButtonDidTap = PublishSubject<Void>()
let alertLeaveButtonDidTap = PublishSubject<Void>()
let alertStayButtonDidTap = PublishSubject<Void>()
let memo = PublishSubject<String>()
// MARK: Output
let navigationBarTitle: Driver<String?>
let doneButtonEnabled: Driver<Bool>
let presentCancelAlert: Driver<(String, String, String, String)>
let dismissViewController: Driver<Void>
// MARK: Private
let disposeBag = DisposeBag()
// MARK: Initializing
init(mode: TaskEditViewMode) {
switch mode {
case .New:
self.navigationBarTitle = .just("New")
self.title = Variable("")
case .Edit(let task):
self.navigationBarTitle = .just("Edit")
self.title = Variable(task.title)
}
self.doneButtonEnabled = self.title.asDriver()
.map { !$0.isEmpty }
.asDriver(onErrorJustReturn: false)
.startWith(false)
.distinctUntilChanged()
let needsPresentCancelAlert = self.cancelButtonDidTap.asDriver()
.withLatestFrom(self.title.asDriver())
.map { title -> Bool in
switch mode {
case .New: return !title.isEmpty
case .Edit(let task): return title != task.title
}
}
self.presentCancelAlert = needsPresentCancelAlert
.filter { $0 }
.map { _ in
let title = "Really?"
let message = "Changes will be lost."
return (title, message, "Leave", "Stay")
}
let didDone = self.doneButtonDidTap.asDriver()
.withLatestFrom(self.doneButtonEnabled).filter { $0 }
.map { _ in Void() }
switch mode {
case .New:
didDone
.withLatestFrom(self.title.asDriver())
.map { title in
Task(title: title)
}
.drive(Task.didCreate)
.addDisposableTo(self.disposeBag)
case .Edit(let task):
didDone
.withLatestFrom(self.title.asDriver())
.map { title in
task.then {
$0.title = title
}
}
.drive(Task.didUpdate)
.addDisposableTo(self.disposeBag)
}
self.dismissViewController = Driver.of(self.alertLeaveButtonDidTap.asDriver(), didDone,
needsPresentCancelAlert.filter { !$0 }.map { _ in Void() }).merge()
}
}