swiftuitableviewuisearchbarrx-swiftrxdatasources

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.


Solution

  • 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

    1. Get rid of the showContacts and allContacts properties
    2. 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)
    3. Combine the query Observable and the viewModel.items Observable into one Observable
    4. Use this observable to filter all contacts with the query.
    5. 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.