Viewing initial data source and filtering with RxSwift using MVVM - swift

I'm starting out with RxSwift and trying to get a simple example of filtering a data source with a UISearchController working.
I have the basic setup of a UISearchController wired into a UITableViewController. Using MVVM I also have a basic view model setup that will drive the table.
self.viewModel.searchText.accept(searchController.searchBar.text ?? "")
viewModel.listItems.bind(to: tableView.rx.items(cellIdentifier: "ItemCell")) { row, item, cell in
cell.textLabel!.text = item.name
}
.disposed(by: disposeBag)
View Model
class ListViewModel {
private let sourceItems: BehaviorRelay<[ListItem]> = BehaviorRelay(value: [
ListItem(name: "abc"),
ListItem(name: "def"),
ListItem(name: "ghi"),
ListItem(name: "jkl"),
ListItem(name: "mno")
])
let searchText = BehaviorRelay<String>(value: "")
var listItems: Observable<[ListItem]> = Observable.just([])
init() {
listItems = sourceItems.asObservable()
}
}
I can add in the search filtering and this works such that only the values matching the filter string will show
let searchObservable = searchText
.throttle(.milliseconds(300), scheduler: MainScheduler.instance)
.distinctUntilChanged()
.filter { query in
return query.count > 2
}
.share(replay: 1)
listItems = Observable.combineLatest(sourceItems.asObservable(), searchObservable) { items, query in
return items.filter({ item in
item.name.lowercased().contains(query.lowercased())
})
}
However, this will not show any values until the filter is matched. What I am trying to do is initially show all the values and then only show the filtered values. I'm not quite sure how to populate the listItems when the searchText changes but is empty or events are filtered out.

You forgot to subscribe for changes, instead of
listItems = Observable.combineLatest(sourceItems.asObservable(), searchObservable) { items, query in
return items.filter({ item in
item.name.lowercased().contains(query.lowercased())
})
}
should be
Observable.combineLatest(sourceItems.asObservable(), searchObservable) { items, query in
return items.filter({ item in
item.name.lowercased().contains(query.lowercased())
})
}.subscribe(onNext: { resultArray in
print(resultArray) // here you can change your listItems
})
.disposed(by: disposeBag)
this is how to change searchText searchText.accept("123")
UPDATED:
to handle any searchBar updates you should implement serachBar.rx
Here is some example how to
import UIKit
import RxSwift
import RxCocoa
class ListItem: NSObject {
var name: String = ""
public init(name str: String) {
super.init()
name = str
}
}
class ViewController: UIViewController, UISearchBarDelegate {
#IBOutlet weak var searchBar: UISearchBar!
private let sourceItems: BehaviorRelay<[ListItem]> = BehaviorRelay(value: [
ListItem(name: "abc"),
ListItem(name: "def"),
ListItem(name: "ghi"),
ListItem(name: "jkl"),
ListItem(name: "mno")
])
let searchText = BehaviorRelay<String>(value: "")
var listItems: Observable<[ListItem]> = Observable.just([])
var disposeBag: DisposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
addSearchBarObserver()
listItems = sourceItems.asObservable()
Observable.combineLatest(sourceItems.asObservable(), searchText) { items, query in
return items.filter({ item in
item.name.lowercased().contains(query.lowercased())
})
}.subscribe(onNext: { resultArray in
print(resultArray)
})
.disposed(by: disposeBag)
}
private func addSearchBarObserver() {
searchBar
.rx
.text
.orEmpty
.debounce(.milliseconds(300), scheduler: MainScheduler.instance)
.distinctUntilChanged()
.subscribe { [weak self] query in
guard
let query = query.element else { return }
self?.searchText.accept(query)
}
.disposed(by: disposeBag)
}
}

my approach will be the following
create such observable class as below
import Foundation
class Observable<Generic> {
var value: Generic {
didSet {
DispatchQueue.main.async {
self.valueChanged?(self.value)
}
}
}
private var valueChanged: ((Generic) -> Void)?
init(_ value: Generic) {
self.value = value
}
/// Add closure as an observer and trigger the closure imeediately if fireNow = true
func addObserver(fireNow: Bool = true, _ onChange: ((Generic) -> Void)?) {
valueChanged = onChange
if fireNow {
onChange?(value)
}
}
func removeObserver() {
valueChanged = nil
}
}
have in your VM the original list
create different filtered list as below
var cellViewModels: Observable<[/* your model */]?>
have the search bar delegate method in the View as below - remember it will vary depending on your implementation -
class TradeDealsViewController: UIViewController, UISearchBarDelegate {
// Mark :- IB Outlets
#IBOutlet weak var collectionView: UICollectionView!
private var viewModel: ViewModel /* your VM */?
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel = TradeDealsViewModel()
bindViewLiveData()
}
private func bindViewLiveData(){
viewModel?.cellViewModels.addObserver({ [weak self] (responsee) in
self?.collectionView.reloadData()
})
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
/* do your filtration logic here */
}
}
note that this solution does not use RxSwift it uses only foundation

With thanks to #AlexandrKolesnik I managed to tweak his answer and get it working:
class ListViewModel {
private let sourceItems: BehaviorRelay<[ListItem]> = BehaviorRelay(value: [
ListItem(name: "abc"),
ListItem(name: "def"),
ListItem(name: "ghi"),
ListItem(name: "jkl"),
ListItem(name: "mno"),
ListItem(name: "abcdef")
])
private var filteredItems: BehaviorRelay<[ListItem]> = BehaviorRelay(value: [])
let searchText = BehaviorRelay<String>(value: "")
var listItems: Observable<[ListItem]> {
return filteredItems.asObservable()
}
private let disposeBag = DisposeBag()
init() {
searchText
.throttle(.milliseconds(300), scheduler: MainScheduler.instance)
.distinctUntilChanged()
.subscribe({ [unowned self] event in
guard let query = event.element, !query.isEmpty else {
self.filteredItems.accept(self.sourceItems.value)
return
}
self.filteredItems.accept(
self.sourceItems.value.filter {
$0.name.lowercased().contains(query.lowercased())
}
)
})
.disposed(by: disposeBag)
}
}

Related

How to pass chain view controller presenter with observable

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.

IGListKitSections doesn't get deallocated

I have a problem with IGListKit sections deallocating. Trying to debug the issue with Xcode memory graph.
My setup is AuthController -> AuthViewModel -> AuthSocialSectionController -> AuthSocialViewModel and some other sections.
AuthController gets presented from several parts of the app if user is not logged in. When I tap close, AuthViewModel and AuthController gets deallocated, but it's underlying sections does not. Memory graph shows nothing leaked in this case, but deinit methods doesn't get called.
But when I'm trying to authorize with social account (successfully) and then look at the memory graph, it shows that sections, that doesn't get deallocated like this:
In this case AuthViewModel doesn't get deallocated either, but after some time it does, but it can happen or not.
I checked every closure and delegate for weak reference, but still no luck.
My code, that I think makes most sense:
class AuthViewController: UIViewController {
fileprivate let collectionView: UICollectionView = UICollectionView(frame: .zero,
collectionViewLayout: UICollectionViewFlowLayout())
lazy var adapter: ListAdapter
= ListAdapter(updater: ListAdapterUpdater(), viewController: self, workingRangeSize: 0)
fileprivate lazy var previewProxy: SJListPreviewProxy = {
SJListPreviewProxy(adapter: adapter)
}()
fileprivate let viewModel: AuthViewModel
fileprivate let disposeBag = DisposeBag()
init(with viewModel: AuthViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
hidesBottomBarWhenPushed = true
setupObservers()
}
private func setupObservers() {
NotificationCenter.default.rx.notification(.SJAProfileDidAutoLogin)
.subscribe(
onNext: { [weak self] _ in
self?.viewModel.didSuccessConfirmationEmail()
self?.viewModel.recoverPasswordSuccess()
})
.disposed(by: disposeBag)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - View Controller Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
// MARK: - Private
#objc private func close() {
dismiss(animated: true, completion: nil)
}
/// Метод настройки экрана
private func setup() {
if isForceTouchEnabled() {
registerForPreviewing(with: previewProxy, sourceView: collectionView)
}
view.backgroundColor = AppColor.instance.gray
title = viewModel.screenName
let item = UIBarButtonItem(image: #imageLiteral(resourceName: "close.pdf"), style: .plain, target: self, action: #selector(AuthViewController.close))
item.accessibilityIdentifier = "auth_close_btn"
asViewController.navigationItem.leftBarButtonItem = item
navigationItem.titleView = UIImageView(image: #imageLiteral(resourceName: "logo_superjob.pdf"))
collectionViewSetup()
}
// Настройка collectionView
private func collectionViewSetup() {
collectionView.keyboardDismissMode = .onDrag
collectionView.backgroundColor = AppColor.instance.gray
view.addSubview(collectionView)
adapter.collectionView = collectionView
adapter.dataSource = self
collectionView.snp.remakeConstraints { make in
make.edges.equalToSuperview()
}
}
}
// MARK: - DataSource CollectionView
extension AuthViewController: ListAdapterDataSource {
func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
return viewModel.sections(for: listAdapter)
}
func listAdapter(_: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
return viewModel.createListSectionController(for: object)
}
func emptyView(for _: ListAdapter) -> UIView? {
return nil
}
}
// MARK: - AuthViewModelDelegate
extension AuthViewController: AuthViewModelDelegate {
func hideAuth(authSuccessBlock: AuthSuccessAction?) {
dismiss(animated: true, completion: {
authSuccessBlock?()
})
}
func reload(animated: Bool, completion: ((Bool) -> Void)? = nil) {
adapter.performUpdates(animated: animated, completion: completion)
}
func showErrorPopover(with item: CommonAlertPopoverController.Item,
and anchors: (sourceView: UIView, sourceRect: CGRect)) {
let popover = CommonAlertPopoverController(with: item,
preferredContentWidth: view.size.width - 32.0,
sourceView: anchors.sourceView,
sourceRect: anchors.sourceRect,
arrowDirection: .up)
present(popover, animated: true, completion: nil)
}
}
class AuthViewModel {
fileprivate let assembler: AuthSectionsAssembler
fileprivate let router: AuthRouter
fileprivate let profileFacade: SJAProfileFacade
fileprivate let api3ProfileFacade: API3ProfileFacade
fileprivate let analytics: AnalyticsProtocol
fileprivate var sections: [Section] = []
weak var authDelegate: AuthDelegate?
weak var vmDelegate: AuthViewModelDelegate?
var authSuccessBlock: AuthSuccessAction?
private lazy var socialSection: AuthSocialSectionViewModel = { [unowned self] in
self.assembler.socialSection(delegate: self)
}()
init(assembler: AuthSectionsAssembler,
router: AuthRouter,
profileFacade: SJAProfileFacade,
api3ProfileFacade: API3ProfileFacade,
analytics: AnalyticsProtocol,
delegate: AuthDelegate? = nil,
purpose: Purpose) {
self.purpose = purpose
authDelegate = delegate
self.assembler = assembler
self.router = router
self.profileFacade = profileFacade
self.api3ProfileFacade = api3ProfileFacade
self.analytics = analytics
sections = displaySections()
}
private func authDisplaySections() -> [Section] {
let sections: [Section?] = [vacancySection,
authHeaderSection,
socialSection,
authLoginPasswordSection,
signInButtonSection,
switchToSignUpButtonSection,
recoverPasswordSection]
return sections.compactMap { $0 }
}
}
class AuthSocialSectionController: SJListSectionController, SJUpdateCellsLayoutProtocol {
fileprivate let viewModel: AuthSocialSectionViewModel
init(viewModel: AuthSocialSectionViewModel) {
self.viewModel = viewModel
super.init()
minimumInteritemSpacing = 4
viewModel.vmDelegate = self
}
override func cellType(at _: Int) -> UICollectionViewCell.Type {
return AuthSocialCell.self
}
override func cellInitializationType(at _: Int) -> SJListSectionCellInitializationType {
return .code
}
override func configureCell(_ cell: UICollectionViewCell, at index: Int) {
guard let itemCell = cell as? AuthSocialCell else {
return
}
let item = viewModel.item(at: index)
itemCell.imageView.image = item.image
}
override func separationStyle(at _: Int) -> SJCollectionViewCellSeparatorStyle {
return .none
}
}
extension AuthSocialSectionController {
override func numberOfItems() -> Int {
return viewModel.numberOfItems
}
override func didSelectItem(at index: Int) {
viewModel.didSelectItem(at: index)
}
}
// MARK: - AuthSocialSectionViewModelDelegate
extension AuthSocialSectionController: AuthSocialSectionViewModelDelegate {
func sourceViewController() -> UIViewController {
return viewController ?? UIViewController()
}
}
protocol AuthSocialSectionDelegate: class {
func successfullyAuthorized(type: SJASocialAuthorizationType)
func showError(with error: Error)
}
protocol AuthSocialSectionViewModelDelegate: SJListSectionControllerOperationsProtocol, ViewControllerProtocol {
func sourceViewController() -> UIViewController
}
class AuthSocialSectionViewModel: NSObject {
struct Item {
let image: UIImage
let type: SJASocialAuthorizationType
}
weak var delegate: AuthSocialSectionDelegate?
weak var vmDelegate: AuthSocialSectionViewModelDelegate?
fileprivate var items: [Item]
fileprivate let api3ProfileFacade: API3ProfileFacade
fileprivate let analyticsFacade: SJAAnalyticsFacade
fileprivate var socialButtonsDisposeBag = DisposeBag()
init(api3ProfileFacade: API3ProfileFacade,
analyticsFacade: SJAAnalyticsFacade) {
self.api3ProfileFacade = api3ProfileFacade
self.analyticsFacade = analyticsFacade
items = [
Item(image: #imageLiteral(resourceName: "ok_icon.pdf"), type: .OK),
Item(image: #imageLiteral(resourceName: "vk_icon.pdf"), type: .VK),
Item(image: #imageLiteral(resourceName: "facebook_icon.pdf"), type: .facebook),
Item(image: #imageLiteral(resourceName: "mail_icon.pdf"), type: .mail),
Item(image: #imageLiteral(resourceName: "google_icon.pdf"), type: .google),
Item(image: #imageLiteral(resourceName: "yandex_icon.pdf"), type: .yandex)
]
if analyticsFacade.isHHAuthAvailable() {
items.append(Item(image: #imageLiteral(resourceName: "hh_icon"), type: .HH))
}
}
// MARK: - actions
func didSelectItem(at index: Int) {
guard let vc = vmDelegate?.sourceViewController() else {
return
}
let itemType: SJASocialAuthorizationType = items[index].type
socialButtonsDisposeBag = DisposeBag()
api3ProfileFacade.authorize(with: itemType, sourceViewController: vc)
.subscribe(
onNext: { [weak self] _ in
self?.delegate?.successfullyAuthorized(type: itemType)
},
onError: { [weak self] error in
if case let .detailed(errorModel)? = error as? ApplicantError {
self?.vmDelegate?.asViewController.showError(with: errorModel.errors.first?.detail ?? "")
} else {
self?.vmDelegate?.asViewController.showError(with: "Неизвестная ошибка")
}
})
.disposed(by: socialButtonsDisposeBag)
}
}
// MARK: - DataSource
extension AuthSocialSectionViewModel {
var numberOfItems: Int {
return items.count
}
func item(at index: Int) -> Item {
return items[index]
}
}
// MARK: - ListDiffable
extension AuthSocialSectionViewModel: ListDiffable {
func diffIdentifier() -> NSObjectProtocol {
return ObjectIdentifier(self).hashValue as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
return object is AuthSocialSectionViewModel
}
}
Where assembler is responsible for creating everyting, for example AuthSocialSection:
func socialSection(delegate: AuthSocialSectionDelegate?) -> AuthSocialSectionViewModel {
let vm = AuthSocialSectionViewModel(api3ProfileFacade: api3ProfileFacade,
analyticsFacade: analyticsFacade)
vm.delegate = delegate
return vm
}
How can I properly debug this issue? Any advice or help is really appreciated
Found an issue in AuthSocialSectionController. Somehow passing viewController from IGList context through delegates caused memory issues. When I commented out the viewModel.vmDelegate = self the issue was gone.
That explains why the AuthViewModel was deallocating properly when I hit close button without attempting to authorize. Only when I hit authorize, that viewController property was called.
Thanks for help #vpoltave
This lines from your AuthViewController can this cause leaks?
// adapter has viewController: self
lazy var adapter: ListAdapter
= ListAdapter(updater: ListAdapterUpdater(), viewController: self, workingRangeSize: 0)
fileprivate lazy var previewProxy: SJListPreviewProxy = {
// capture self.adapter ?
SJListPreviewProxy(adapter: adapter)
}()
I'm not sure, but at least you can try :)
UPDATE
I was wondering about this lazy closures and self inside, it won't create retain cycle because lazy initialization are #nonescaping.

RxSwift - How to reflect the number of item's count to TableView

I'm new to RxSwift. This is quite tricky.
I'm creating like ToDoList that views which are tableView and add-item view are separated by TabBarController.
I have successfully displayed the list array and added a new item into tableView.
I also wanted to display the number of array's count and favourite count in the view that has tableView so that I have displayed it by throwing a value with .just.
But displaying a value based on the result of the array displayed by SearchBar, the value is not reflected as I expected.
In MainViewModel, I made sure if I could get the number of array's count properly by print, but apparently the value was fine.
It is just not reflected in the View.
// Model
struct Item: Codable {
var name = String()
var detail = String()
var tag = String()
var memo = String()
var fav = Bool()
var cellNo = Int()
init(name: String, detail: String, tag: String, memo: String, fav: Bool, celllNo: Int) {
self.name = name
self.detail = detail
self.tag = tag
self.memo = memo
self.fav = fav
self.cellNo = celllNo
}
init() {
self.init(
name: "Apple",
detail: "ringo",
tag: "noun",
memo: "",
fav: false,
celllNo: 0
)
}
}
struct SectionModel: Codable {
var list: [Item]
}
extension SectionModel: SectionModelType {
var items: [Item] {
return list
}
init(original: SectionModel, items: [Item]) {
self = original
self.list = items
}
}
Singleton share class
final class Sharing {
static let shared = Sharing()
var items: [Item] = [Item()]
var list: [SectionModel] = [SectionModel(list: [Item()])] {
didSet {
UserDefault.shared.saveList(list: list)
}
}
let listItems = BehaviorRelay<[SectionModel]>(value: [])
}
extension Sharing {
func calcFavCount(array: [Item]) -> Int {
var count = 0
if array.count > 0 {
for i in 0...array.count - 1 {
if array[i].fav {
count += 1
}
}
}
return count
}
}
// MainTabViewController
class MainTabViewController: UIViewController {
#IBOutlet weak var listTextField: UITextField!
#IBOutlet weak var tagTextField: UITextField!
#IBOutlet weak var itemCountLabel: UILabel!
#IBOutlet weak var favCountLabel: UILabel!
#IBOutlet weak var favIcon: UIImageView!
#IBOutlet weak var infoButton: UIButton!
#IBOutlet weak var searchBar: UISearchBar!
#IBOutlet weak var tableView: UITableView!
private lazy var viewModel = MainTabViewModel(
searchTextObservable: searchTextObservable
)
private let disposeBag = DisposeBag()
private var dataSource: RxTableViewSectionedReloadDataSource<SectionModel>!
override func viewDidLoad() {
super.viewDidLoad()
setupTableViewDataSource()
tableViewSetup()
listDetailSetup()
}
// create Observable searchBar.text to pass to ViewModel
var searchTextObservable: Observable<String> {
let debounceValue = 200
// observable to get the incremental search text
let incrementalTextObservable = rx
.methodInvoked(#selector(UISearchBarDelegate.searchBar(_:shouldChangeTextIn:replacementText:)))
.debounce(.milliseconds(debounceValue), scheduler: MainScheduler.instance)
.flatMap { [unowned self] _ in Observable.just(self.searchBar.text ?? "") }
// observable to get the text when the clear button or enter are tapped
let textObservable = searchBar.rx.text.orEmpty.asObservable()
// merge these two above
let searchTextObservable = Observable.merge(incrementalTextObservable, textObservable)
.skip(1)
.debounce(.milliseconds(debounceValue), scheduler: MainScheduler.instance)
.distinctUntilChanged()
return searchTextObservable
}
func setupTableViewDataSource() {
dataSource = RxTableViewSectionedReloadDataSource<SectionModel>(configureCell: {(_, tableView, indexPath, item) in
let cell = tableView.dequeueReusableCell(withIdentifier: "ListCell") as! ListCell
cell.selectionStyle = .none
cell.backgroundColor = .clear
cell.configure(item: item)
return cell
})
}
func tableViewSetup() {
tableView.rx.itemDeleted
.subscribe {
print("delete")
}
.disposed(by: disposeBag)
viewModel.dispItems.asObservable()
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
}
func listDetailSetup() {
viewModel.itemCountObservable
.bind(to: itemCountLabel.rx.text)
.disposed(by: disposeBag)
viewModel.favCountObservable
.bind(to: favCountLabel.rx.text)
.disposed(by: disposeBag)
}
}
MainTabViewModel
final class MainTabViewModel {
private let disposeBag = DisposeBag()
private let userDefault: UserDefaultManager
var dispItems = BehaviorRelay<[SectionModel]>(value: [])
private let shared = Sharing.shared
// lazy var itemCount = shared.list[0].list.count
// lazy var favCount = shared.calcFavCount
var itemCountObservable: Observable<String>
var favCountObservable: Observable<String>
init(searchTextObservable: Observable<String>,
userDefault: UserDefaultManager = UserDefault()) {
self.userDefault = userDefault
let initialValue = shared.list
shared.listItems.accept(initialValue)
dispItems = shared.listItems
// this part is to display the initil number -> success
var itemCount = shared.list[0].list.count
itemCountObservable = .just(itemCount.description + " items")
var favCount = shared.calcFavCount(array: shared.list[0].list)
favCountObservable = .just(favCount.description)
// this part is based on the searching result -> failure
searchTextObservable.subscribe(onNext: { text in
if text.isEmpty {
let initialValue = self.shared.list
self.shared.listItems.accept(initialValue)
self.dispItems = self.shared.listItems
}else{
let filteredItems: [Item] = self.shared.list[0].list.filter {
$0.name.contains(text)
}
let filteredList = [SectionModel(list: filteredItems)]
self.shared.listItems.accept(filteredList)
self.dispItems = self.shared.listItems
itemCount = filteredItems.count
self.itemCountObservable = .just(itemCount.description + " items")
favCount = self.shared.calcFavCount(array: filteredItems)
self.favCountObservable = .just(favCount.description)
print("\(itemCount) items") // the ideal number is in but not shown in the view
}
})
.disposed(by: disposeBag)
}
}
I removed unnecessary code but I mostly pasted a whole code for your understanding.
Hope you could help me.
Thank you.
I solved this issue anyway; the value was reflected.
the issue was that itemCountObservable was declared as observable and .just was used.
How .just works is to throw onNext once and it is completed, which means the change I did in searchTextObservable.subscribe(onNext~ is unacceptable.
So I shifted the itemCountObservable: Observable<String> to BehaviorRelay<String>that only onNext is thrown and not completed, then it works.
My understanding of this issue is that itemCountObservable: Observable<String> stopped throwing a value due to .just as I've written above.
Am I correct??
If you're familiar with the difference between Observable and BehaviorRelay, it would be appreciated if you could tell me.
Thanks.

UISearchBar in UITableView by Rxswift

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.

How would I write a test to make sure the UIbutton "Show all Providers" turns up when there's more than 12 or more items in the table view?

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