I'm new in the RxSwift development and I've an issue while presentation a view controller.
My MainViewController is just a table view and I would like to present detail when I tap on a item of the list.
My DetailViewController is modally presented and needs a ViewModel as input parameter.
I would like to avoid to dismiss the DetailViewController, I think that the responsability of dismiss belongs to the one who presented the view controller, i.e the dismiss should happen in the MainViewController.
Here is my current code
DetailsViewController
class DetailsViewController: UIViewController {
#IBOutlet weak private var doneButton: Button!
#IBOutlet weak private var label: Label!
let viewModel: DetailsViewModel
private let bag = DisposeBag()
var onComplete: Driver<Void> {
doneButton.rx.tap.take(1).asDriver(onErrorJustReturn: ())
}
override func viewDidLoad() {
super.viewDidLoad()
setup()
bind()
}
private func bind() {
let ouput = viewModel.bind()
ouput.id.drive(idLabel.rx.text)
.disposed(by: bag)
}
}
DetailsViewModel
class DetailsViewModel {
struct Output {
let id: Driver<String>
}
let item: Observable<Item>
init(with vehicle: Observable<Item>) {
self.item = item
}
func bind() -> Output {
let id = item
.map { $0.id }
.asDriver(onErrorJustReturn: "Unknown")
return Output(id: id)
}
}
MainViewController
class MainViewController: UIViewController {
#IBOutlet weak private var tableView: TableView!
private var bag = DisposeBag()
private let viewModel: MainViewModel
private var detailsViewController: DetailsViewController?
override func viewDidLoad(_ animated: Bool) {
super.viewDidLoad(animated)
bind()
}
private func bind() {
let input = MainViewModel.Input(
selectedItem: tableView.rx.modelSelected(Item.self).asObservable()
)
let output = viewModel.bind(input: input)
showItem(output.selectedItem)
}
private func showItem(_ item: Observable<Item>) {
let viewModel = DetailsViewModel(with: vehicle)
detailsViewController = DetailsController(with: viewModel)
item.flatMapFirst { [weak self] item -> Observable<Void> in
guard let self = self,
let detailsViewController = self.detailsViewController else {
return Observable<Void>.never()
}
self.present(detailsViewController, animated: true)
return detailsViewController.onComplete.asObservable()
}
.subscribe(onNext: { [weak self] in
self?.detailsViewController?.dismiss(animated: true)
self?.detailsViewController? = nil
})
.disposed(by: bag)
}
}
MainViewModel
class MainViewModel {
struct Input {
let selectedItem: Observable<Item>
}
struct Output {
let selectedItem: Observable<Item>
}
func bind(input: Input) -> Output {
let selectedItem = input.selectedItem
.throttle(.milliseconds(500),
latest: false,
scheduler: MainScheduler.instance)
.asObservable()
return Output(selectedItem: selectedItem)
}
}
My issue is on showItem of MainViewController.
I still to think that having the DetailsViewController input as an Observable isn't working but from what I understand from Rx, we should use Observable as much as possible.
Having Item instead of Observable<Item> as input could let me use this kind of code:
item.flatMapFirst { item -> Observable<Void> in
guard let self = self else {
return Observable<Void>.never()
}
let viewModel = DetailsViewModel(with: item)
self.detailsViewController = DetailsViewController(with: viewModel)
guard let detailsViewController = self.detailsViewController else {
return Observable<Void>.never()
}
present(detailsViewController, animated: true)
return detailsViewController
}
.subscribe(onNext: { [weak self] in
self?.detailsViewController?.dismiss(animated: true)
self?.detailsViewController = nil
})
.disposed(by: bag)
What is the right way to do this?
Thanks
You should not "use Observable as much as possible." If an object is only going to ever have to deal with a single item, then just pass the item. For example if a label is only ever going to display "Hello World" then just assign the string to the label's text property. Don't bother wrapping it in a just and binding it to the label's rx.text.
Your second option is much closer to what you should have. It's a fine idea.
You might find my CLE library interesting. It takes care of the issue you are trying to handle here.
Related
Hello I am trying to do a project with RxSwift and I am stuck trying to do in a properly way the connection between the Coordinator and the ViewModel.
Goal
Using observables, the Coordinator receives and event (in that case, when a row has been tapped) then does whatever.
Scenario
Giving a Post (String)
typealias Post = String
I have the following Coordinator:
class Coordinator {
func start() {
let selectedPostObservable = PublishSubject<Post>()
let viewController = ViewController()
let viewModel = ViewModel()
viewController.viewModel = viewModel
selectedPostObservable.subscribe { post in
//Do whatever
}
}
}
The selectedPostObservable is what I don't know how to connect it in a "clean" way with the viewModel.
As ViewModel:
class ViewModel {
struct Input {
let selectedIndexPath: Observable<IndexPath>
}
struct Output {
//UI Outputs
}
func transform(input: Input) -> Output {
let posts: [Post] = Observable.just(["1", "2", "3"])
//Connect with selectedindex
let result = input.selectedIndexPath
.withLatestFrom(posts) { $1[$0.row] }
.asDriver(onErrorJustReturn: nil)
return Output()
}
}
The result variable is what I should connect with selectedPostObservable.
And the ViewController (although I think is not relevant for the question):
class ViewController: UIViewController {
//...
var viewModel: ViewModel!
var tableView: UITableView!
//...
func bindViewModel() {
let input = ViewModel.Input(selectedIndexPath: tableView.rx.itemSelected.asObservable())
viewModel.transform(input: input)
}
}
Thank you so much.
Working with the structure you are starting with, I would put the PublishSubject in the ViewModel class instead of the Coordinator. Then something like this:
class ViewModel {
struct Input {
let selectedIndexPath: Observable<IndexPath>
}
struct Output {
//UI Outputs
}
let selectedPost = PublishSubject<Post>()
let bag = DisposeBag()
func transform(input: Input) -> Output {
let posts: [Post] = Observable.just(["1", "2", "3"])
//Connect with selectedindex
input.selectedIndexPath
.withLatestFrom(posts) { $1[$0.row] }
.bind(to: selectedPost)
.disposed(by: bag)
return Output()
}
}
class Coordinator {
func start() {
let viewController = ViewController()
let viewModel = ViewModel()
viewController.viewModel = viewModel
viewModel.selectedPost.subscribe { post in
//Do whatever
}
.disposed(by: viewModel.bag)
}
}
I use RxSwift to show list of Persons in my tableview, and my tableview has two sections, the first one is old searches and the second one is all Persons. now I don't know how should I filter Persons when users type a name on UISearchBar's textfield.
This is my Person model:
struct PersonModel {
let name: String
let family:String
let isHistory:Bool
}
This is my ContactsViewModel
struct SectionOfPersons {
var header: String
var items: [Item]
}
extension SectionOfPersons: SectionModelType {
typealias Item = PersonModel
init(original: SectionOfPersons, items: [SectionOfPersons.Item]) {
self = original
self.items = items
}
}
class ContactsViewModel {
let items = PublishSubject<[SectionOfPersons]>()
func fetchData(){
var subItems : [SectionOfPersons] = []
subItems.append( SectionOfPersons(header: "History", items: [
SectionOfPersons.Item(name:"Michelle", family:"Obama", isHistory:true ),
SectionOfPersons.Item(name:"Joanna", family:"Gaines", isHistory:true )
]))
subItems.append( SectionOfPersons(header: "All", items: [
SectionOfPersons.Item(name:"Michelle", family:"Obama", isHistory:false ),
SectionOfPersons.Item(name:"James", family:"Patterson", isHistory:false ),
SectionOfPersons.Item(name:"Stephen", family:"King", isHistory:false ),
SectionOfPersons.Item(name:"Joanna", family:"Gaines", isHistory:false )
]))
self.items.onNext( subItems )
}
}
and this is my ContactsViewController:
class ContactsViewController: UIViewController {
#IBOutlet weak var tableView: UITableView!
#IBOutlet weak var searchBar: UISearchBar!
private lazy var dataSource = RxTableViewSectionedReloadDataSource<SectionOfPersons>(configureCell: configureCell, titleForHeaderInSection: titleForHeaderInSection)
private lazy var configureCell: RxTableViewSectionedReloadDataSource<SectionOfPersons>.ConfigureCell = { [weak self] (dataSource, tableView, indexPath, contact) in
guard let cell = tableView.dequeueReusableCell(withIdentifier: "ContactTableViewCell", for: indexPath) as? ContactTableViewCell else { return UITableViewCell() }
cell.contact = contact
return cell
}
private lazy var titleForHeaderInSection: RxTableViewSectionedReloadDataSource<SectionOfPersons>.TitleForHeaderInSection = { [weak self] (dataSource, indexPath) in
return dataSource.sectionModels[indexPath].header
}
private let viewModel = ContactsViewModel()
private let disposeBag = DisposeBag()
var showContacts = PublishSubject<[SectionOfPersons]>()
var allContacts = PublishSubject<[SectionOfPersons]>()
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
viewModel.fetchData()
}
func bindViewModel(){
tableView.backgroundColor = .clear
tableView.register(UINib(nibName: "ContactTableViewCell", bundle: nil), forCellReuseIdentifier: "ContactTableViewCell")
tableView.rx.setDelegate(self).disposed(by: disposeBag)
viewModel.items.bind(to: allContacts).disposed(by: disposeBag)
viewModel.items.bind(to: showContacts).disposed(by: disposeBag)
showContacts.bind(to: tableView.rx.items(dataSource: dataSource)).disposed(by: disposeBag)
searchBar
.rx.text
.orEmpty
.debounce(0.5, scheduler: MainScheduler.instance)
.distinctUntilChanged()
.filter { !$0.isEmpty }
.subscribe(onNext: { [unowned self] query in
////// if my datasource was simple string I cand do this
self.showContacts = self.allContacts.filter { $0.first?.hasPrefix(query) } // if datasource was simple array string, but what about complex custome object?!
})
.addDisposableTo(disposeBag)
}
}
Thanks for your response.
You don't need the two PublishSubjects in your ContactsViewController. You can bind the Observables you obtain from the UISearchBar and your viewModel directly to your UITableView. To filter the contacts with your query you have to filter each section separately. I used a little helper function for that.
So here is what I did
Get rid of the showContacts and allContacts properties
Create an query Observable that emits the text that the user entered into the search bar (don't filter out the empty text, we need that to bring back all contacts when the user deletes the text in the search bar)
Combine the query Observable and the viewModel.items Observable into one Observable
Use this observable to filter all contacts with the query.
Bind that Observable directly to the table view rx.items
I used combineLatest so the table view gets updated whenever the query or viewModel.items changes (I don't know if that list of all contacts is static or if you add / remove contacts).
So now your bindViewModel() code looks like this (I moved the tableView.register(...) to viewDidLoad):
func bindViewModel(){
let query = searchBar.rx.text
.orEmpty
.distinctUntilChanged()
Observable.combineLatest(viewModel.items, query) { [unowned self] (allContacts, query) -> [SectionOfPersons] in
return self.filteredContacts(with: allContacts, query: query)
}
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
}
Here is the function that filters all contacts using the query:
func filteredContacts(with allContacts: [SectionOfPersons], query: String) -> [SectionOfPersons] {
guard !query.isEmpty else { return allContacts }
var filteredContacts: [SectionOfPersons] = []
for section in allContacts {
let filteredItems = section.items.filter { $0.name.hasPrefix(query) || $0.family.hasPrefix(query) }
if !filteredItems.isEmpty {
filteredContacts.append(SectionOfPersons(header: section.header, items: filteredItems))
}
}
return filteredContacts
}
I assumed that you wanted to check the Persons' name and family against the query.
One more thing: I removed the debounce because you filter a list that is already in memory, which is really fast. You would typically use debounce when typing into the search bar triggers a network request.
I am trying to use MVVM. I am going to VC2 from VC1. I am updating the viewModel.fromVC = 1, but the value is not updating in the VC2.
Here is what I mean:
There is a viewModel, in it there is a var fromVC = Int(). Now, in vc1, I am calling the viewModel as
let viewModel = viewModel().
Now, on the tap of button, I am updating the viewModel.fromVC = 8. And, moving to the next screen. In the next screen, when I print fromVC then I get the value as 0 instead of 8.
This is how the VC2 looks like
class VC2 {
let viewModel = viewModel()
func abc() {
print(viewModel.fromVC)
}
}
Now, I am calling abc() in viewDidLoad and the fromVC is printed as 0 instead of 8. Any help?
For the MVVM pattern you need to understand that it's a layer split in 2 different parts: Inputs & Outputs.
Int terms of inputs, your viewModel needs to catch every event from the viewController, and for the Outputs, this is the way were the viewModel will send data (correctly formatted) to the viewController.
So basically, if we have a viewController like this:
final class HomeViewController: UIViewController {
// MARK: - Outlets
#IBOutlet private weak var titleLabel: UILabel!
// MARK: - View life cycle
override func viewDidLoad() {
super.viewDidLoad()
}
// MARK: - Actions
#IBAction func buttonTouchUp(_ sender: Any) {
titleLabel.text = "toto"
}
}
We need to extract the responsibilities to a viewModel, since the viewController is handling the touchUp event, and owning the data to bring to th label.
By Extracting this, you will keep the responsibility correctly decided and after all, you'll be able to test your viewModel correctly 🙌
So how to do it? Easy, let's take a look to our futur viewModel:
final class HomeViewModel {
// MARK: - Private properties
private let title: String
// MARK: - Initializer
init(title: String) {
self.title = title
}
// MARK: - Outputs
var titleText: ((String) -> Void)?
// MARK: - Inputs
func viewDidLoad() {
titleText?("")
}
func buttonDidPress() {
titleText?(title)
}
}
So now, by doing this, you are keeping safe the different responsibilities, let's see how to bind our viewModel to our previous viewController :
final class HomeViewController: UIViewController {
// MARK: - public var
var viewModel: HomeViewModel!
// MARK: - Outlets
#IBOutlet private weak var titleLabel: UILabel!
// MARK: - View life cycle
override func viewDidLoad() {
super.viewDidLoad()
bind(to: viewModel)
viewModel.viewDidLoad()
}
// MARK: - Private func
private func bind(to viewModel: HomeViewModel) {
viewModel.titleText = { [weak self] title in
self?.titleLabel.text = title
}
}
// MARK: - Actions
#IBAction func buttonTouchUp(_ sender: Any) {
viewModel.buttonDidPress()
}
}
So one thing is missing, you'll asking me "but how to initialise our viewModel inside the viewController?"
Basically you should once again extract responsibilities, you could have a Screens layer which would have the responsibility to create the view like this:
final class Screens {
// MARK: - Properties
private let storyboard = UIStoryboard(name: StoryboardName, bundle: Bundle(for: Screens.self))
// MARK: - Home View Controller
func createHomeViewController(with title: String) -> HomeViewController {
let viewModel = HomeViewModel(title: title)
let viewController = storyboard.instantiateViewController(withIdentifier: "Home") as! HomeViewController
viewController.viewModel = viewModel
return viewController
}
}
And finally do something like this:
let screens = Screens()
let homeViewController = screens.createHomeViewController(with: "Toto")
But the main subject was to bring the possibility to test it correctly, so how to do it? very easy!
import XCTest
#testable import mvvmApp
final class HomeViewModelTests: XCTestCase {
func testGivenAHomeViewModel_WhenViewDidLoad_titleLabelTextIsEmpty() {
let viewModel = HomeViewModel(title: "toto")
let expectation = self.expectation("Returned title")
viewModel.titleText = { title in
XCTAssertEqual(title, "")
expectation.fulfill()
}
viewModel.viewDidLoad()
waitForExpectations(timeout: 1.0, handler: nil)
}
func testGivenAHomeViewModel_WhenButtonDidPress_titleLabelTextIsCorrectlyReturned() {
let viewModel = HomeViewModel(title: "toto")
let expectation = self.expectation("Returned title")
var counter = 0
viewModel.titleText = { title in
if counter == 1 {
XCTAssertEqual(title, "toto")
expectation.fulfill()
}
counter += 1
}
viewModel.viewDidLoad()
viewModel.buttonDidPress()
waitForExpectations(timeout: 1.0, handler: nil)
}
}
And that's it 💪
Everytime we resolve a protocol/class then we get a new object. My coordinator needs a view model and my controller needs the same view model.
internal class LoginFactory: Assembly {
func assemble(container: Container) {
let delegate = UIApplication.shared.delegate as? AppDelegate
container.register(LSViewModel.self) { res in
return LSViewModel()
}
container.register(LSCoordinator.self, factory: { res in
let cord = LSCoordinator(window: (delegate?.window)!)
cord.viewModel = res.resolve(LSViewModel.self)
return cord
})
container.register(LSViewController.self) { res in
let cont = StoryboardScene.Login.lsViewController.instantiate()
cont.viewModel = res.resolve(LSCoordinator.self)?.viewModel
return cont
}
}
}
The coordinator goes like
internal final class LSCoordinator: BaseCoordinator<Void>, Coordinator, HasDisposeBag {
private let window: UIWindow
weak var navigationController: UINavigationController!
//LSViewModel implements LSViewModelType
var viewModel: LSViewModelType!
init(window: UIWindow) {
self.window = window
}
func start() -> Observable<Void> {
let lslViewController: LSViewController = DependencyManager.getResolver().resolve(LSViewController.self)!
navigationController = UINavigationController(rootViewController: lsController)
viewModel.outputs.doneTapped
.subscribe(onNext: { [weak self] in self?.showLoginTypes() }).disposed(by: disposeBag)
window.rootViewController = navigationController
window.makeKeyAndVisible()
return .never()
}
func showLoginTypes() {
print(“blah blah”)
}
}
The problem is, when I am trying to inject viewModel in my lsViewController then a different instance of lsViewModel is created. As a result the Rx bindings are not working and the print statement is not executed. Is there any way to pass the same view model to both coordinator and controller?
So I'm completely new to testing and I just needed some help figuring out for example how I would write a test for each of the three cases in the enum of the View Model (none, dontSeeProvider, showAllProviders).
enum ProvidersButtonType {
case none, dontSeeProvider, showAllProviders
}
I haven't been able to figure out how to write a test for cases "showAllProviders" and "dontSeeProviders".
This is the View Model:
import RxSwift
import RxCocoa
struct TopProvidersPickerItem {
let provider: MVPD
let logoImage: Observable<UIImage>
init(provider: MVPD, imageLoader: DecodableProviding) {
self.init(provider: provider, logoImage: imageLoader.image(fromURL: provider.logoUrl))
}
init(provider: MVPD, logoImage: Observable<UIImage>) {
self.provider = provider
self.logoImage = logoImage.catchErrorJustReturn(UIImage())
}
}
enum ProvidersButtonType {
case none, dontSeeProvider, showAllProviders
}
struct TopProvidersPickerViewModel {
var caption: String {
return "Get access to more full episodes by signing in with your TV Provider"
}
let buttonType = Variable<ProvidersButtonType>(.none)
let items: Observable<[TopProvidersPickerItem]>
let selectedItem: PublishSubject<TopProvidersPickerItem> = PublishSubject()
let showAllProvidersTrigger: PublishSubject<Void> = PublishSubject()
let mvpdPicked: Observable<MVPD>
init(topProviders: Observable<[MVPD]>, imageLoader: DecodableProviding) {
let items = topProviders.map({ mvpds in
return mvpds.map { mvpd in
TopProvidersPickerItem(provider: mvpd, imageLoader: imageLoader)
}
})
self.init(items: items)
}
init(items: Observable<[TopProvidersPickerItem]>) {
self.items = items
mvpdPicked = selectedItem.map { $0.provider }
let buttonType = items.map { (array) -> ProvidersButtonType in
if array.count > 12 {
return .showAllProviders
} else {
return .dontSeeProvider
}
}
buttonType.bind(to: self.buttonType)
}
}
This is the View Controller:
import UIKit
import RxCocoa
import RxSwift
public class ProviderCollectionViewCell: UICollectionViewCell {
#IBOutlet public private(set) weak var imageView: UIImageView!
}
public class TopProvidersPickerViewController: UIViewController,
ViewModelHolder {
var viewModel: TopProvidersPickerViewModel! = nil
private let bag = DisposeBag()
#IBOutlet public private(set) weak var collectionView: UICollectionView!
#IBOutlet public private(set) weak var captionLabel: UILabel!
#IBOutlet weak var viewAllProvidersButton: UIButton!
override public func viewDidLoad() {
super.viewDidLoad()
captionLabel.text = viewModel.caption
setupRx()
}
private func setupRx() {
viewModel.buttonType.asObservable().subscribe(onNext: { [button = self.viewAllProvidersButton] type in
button?.isHidden = false
switch type {
case .none:
button?.isHidden = true
case .dontSeeProvider:
button?.setTitle("Don't see provider", for: .normal)
case .showAllProviders:
button?.setTitle("Show all providers", for: .normal)
}
})
.disposed(by: bag)
viewModel.items
.bind(to: collectionView
.rx
.items(cellIdentifier: "ProviderCell", cellType: ProviderCollectionViewCell.self)) { [ unowned self ] _, item, cell in
item.logoImage.bind(to: cell.imageView.rx.image).addDisposableTo(self.bag)
}
.addDisposableTo(bag)
collectionView
.rx
.modelSelected(TopProvidersPickerItem.self)
.bind(to: self.viewModel.selectedItem)
.addDisposableTo(bag)
viewAllProvidersButton
.rx
.tap
.bind(to: self.viewModel.showAllProvidersTrigger)
.addDisposableTo(bag)
}
}
I wrote a test for the "none" case, but haven't been able to figure out the other two cases:
import FBSnapshotTestCase
import OHHTTPStubs
import RxSwift
#testable import AuthSuite
class TopProvidersPickerViewControllerTests: FBSnapshotTestCase,
ProvidersViewControllerTests {
override func setUp() {
super.setUp()
recordMode = true
}
func testDoesNotShowButtonWhenLoadingProviders() {
let viewModel = TopProvidersPickerViewModel(items: .never())
let controller = TopProvidersPickerViewController.instantiateViewController(with: viewModel)
presentViewController(controller)
FBSnapshotVerifyView(controller.view)
}
I've never used FB Snapshot Tester. I'm going to have to look into that.
Here's how I would do it:
I wouldn't expose the enum to the ViewController. setupRx() would contain this instead:
private func setupRx() {
viewModel.buttonTitle
.bind(to: viewAllProvidersButton.rx.title(for: .normal))
.disposed(by: bag)
viewModel.buttonHidden
.bind(to: viewAllProvidersButton.rx.isHidden)
.disposed(by: bag)
// everything else
}
Then to test the title of the button, for example, I would use these tests:
import XCTest
import RxSwift
#testable import RxPlayground
class TopProvidersPickerViewModelTests: XCTestCase {
func testButtonTitleEmptyItems() {
let topProviders = Observable<[MVPD]>.just([])
let decodableProviding = MockDecodableProviding()
let viewModel = TopProvidersPickerViewModel(topProviders: topProviders, imageLoader: decodableProviding)
var title: String = ""
_ = viewModel.buttonTitle.subscribe(onNext: { title = $0 })
XCTAssertEqual(title, "Don't see provider")
}
func testButtonTitle12Items() {
let topProviders = Observable<[MVPD]>.just(Array(repeating: MVPD(), count: 12))
let decodableProviding = MockDecodableProviding()
let viewModel = TopProvidersPickerViewModel(topProviders: topProviders, imageLoader: decodableProviding)
var title: String = ""
_ = viewModel.buttonTitle.subscribe(onNext: { title = $0 })
XCTAssertEqual(title, "Don't see provider")
}
func testButtonTitle13Items() {
let topProviders = Observable<[MVPD]>.just(Array(repeating: MVPD(), count: 13))
let decodableProviding = MockDecodableProviding()
let viewModel = TopProvidersPickerViewModel(topProviders: topProviders, imageLoader: decodableProviding)
var title: String = ""
_ = viewModel.buttonTitle.subscribe(onNext: { title = $0 })
XCTAssertEqual(title, "Show all providers")
}
}
class MockDecodableProviding: DecodableProviding {
// nothing needed for these tests.
}