Update data using MVVM architecture in swift - swift

I am using MVVM Architecture for learning to develop a small weather application for learning purposes.
View
class ViewController: UIViewController {
private var cityDataViewModel = CityDataViewModel()
private var data = [ConsolidatedWeather]()
#IBOutlet weak var label1: UILabel!
#IBOutlet weak var label2: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
loadCityData()
}
func loadCityData(){
print("loadCityData")
cityDataViewModel.getCityData {
}
}
}
ViewModel
class CityDataViewModel{
private var networkManager = CityNetworkManager()
private var weatherNetworkManager = WeatherNetworkManager()
var weatherModel = [ConsolidatedWeather]()
var myStruct :[WeatherModel] = []
var weatherState: String?
var minTemp: Double?
var maxTemp: Double?
var currentTemperature: Double?
var summary: String?
var dateString: String = ""
//MARK: - Get cityInformation
func getCityData(completion: #escaping () -> ()) {
networkManager.getCityDataNetworkCall { [weak self](result) in
switch result{
case .success(let information):
information.forEach { (data) in
print("\(data.title) || \(data.locationType) || \(data.woeid) || \(data.lattLong)")
print("loadCityData 3")
self?.getCityWeatherInformation(with: data.woeid)
print("loadCityData 4")
}
completion()
case .failure(let error):
print(error)
}
}
}
//MARK: - Get Weather data
func getCityWeatherInformation(with woeid: Int){
//[weak self]
weatherNetworkManager.getWeatherDataNetworkCall(cityId: woeid) {[weak self] (result) in
print("loadCityData 5")
switch result{
case .success(let listOfData):
self?.weatherModel = listOfData.consolidatedWeather
}
case .failure(let error):
print(error)
}
}
}
var ttile: String{
return weatherState ?? ""
}
}
From the view, I am sending a call to ViewModel to get cityId by using func getCityDat()
After get the cityId I called func getCityWeatherInformation(with woeid: Int) for get details weather data. I am successfully getting those data from server.
How can I send that information to view for updating my viewController?

Setting up a protocol/closure system as mentioned in the comments is certainly a popular option.
As of iOS 13, you also have the option of using Combine to publish the changes on your ViewModel, which can trigger the ViewController to update.
A simplified example:
import Combine
import UIKit
class MyVC : UIViewController {
private var label = UILabel()
private var label2 = UILabel()
private var viewModel = ViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
addLabels()
linkPublishers()
viewModel.getData()
}
func linkPublishers() {
//OPTION 1
viewModel.objectWillChange.sink { (_) in
DispatchQueue.main.async {
self.label.text = self.viewModel.text1
self.label2.text = self.viewModel.text2
}
}
.store(in: &cancellables)
// **** OR ****
//OPTION 2
viewModel
.$text1
.receive(on: RunLoop.main)
.sink { (newLabelText) in
self.label.text = newLabelText
}.store(in: &cancellables)
viewModel
.$text2
.receive(on: RunLoop.main)
.sink { (newLabelText) in
self.label2.text = newLabelText
}.store(in: &cancellables)
}
func addLabels() {
label.frame = CGRect(x: 0, y: 0, width: 200, height: 40)
self.view.addSubview(label)
label2.frame = CGRect(x: 0, y: 40, width: 200, height: 40)
self.view.addSubview(label2)
}
}
class ViewModel : ObservableObject {
#Published var text1 = ""
#Published var text2 = ""
func getData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.text1 = "Hello, world"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.text2 = "Hello, world 2"
}
}
}
The ViewModel here does a fake task mocking an async network call. Then, it sets one of its #Published properties to the result of that data.
Back in the ViewController, linkPublishers has two different ways of hooking up those published properties:
Observing objectWillChange, which gets triggered before any of the published properties update
Observing each #Published property independently.

Related

SwiftUI Class won't DeInit

I've got a class class MapSearch that I instantiate when I need to auto-complete address results. It works perfectly but it never deinitializes and I can't figure out why.
Easily test by creating the files below. Use the back button after navigating to the test page and watch the console messages. You will see that the view model initializes and deinitializes as it should, but you'll only see MapSearch initialize.
HomeView.swift
import SwiftUI
struct HomeView: View {
var body: some View {
NavigationView {
NavigationLink(destination: TestView(viewModel: TestViewModel()) {
Text("TestView")
}
}
}
}
TestView.swift
import SwiftUI
struct TestView: View {
#StateObject var viewModel: ViewModel
var body: some View {
Text("Hello World")
}
}
TestViewModel.swift
import Foundation
extension TestView {
#MainActor
class ViewModel: ObservableObject {
#Published var mapSearch: MapSearch()
init() {
print("Test View Model Initialized")
}
deinit {
print("Test View Model Deinitialized")
}
}
}
MapSearch.swift
import Combine
import CoreLocation
import Foundation
import MapKit
/// Uses MapKit and CoreLocation to auto-complete an address
class MapSearch: NSObject, ObservableObject {
#Published var countryName: String = "United States"
#Published var locationResults: [MKLocalSearchCompletion] = []
#Published var searchTerm = ""
private var cancellables: Set<AnyCancellable> = []
private var searchCompleter = MKLocalSearchCompleter()
private var currentPromise: ((Result<[MKLocalSearchCompletion], Error>) -> Void)?
override init() {
super.init()
searchCompleter.delegate = self
searchCompleter.resultTypes = MKLocalSearchCompleter.ResultType([.address])
$searchTerm
.debounce(for: .seconds(0.2), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap({ (currentSearchTerm) in
self.searchTermToResults(searchTerm: currentSearchTerm, countryName: self.countryName)
})
.sink(receiveCompletion: { (_) in
}, receiveValue: { (results) in
// Show country specific results
self.locationResults = results.filter { $0.subtitle.contains(self.countryName) }
})
.store(in: &cancellables)
print("MapSearch Initialized")
}
deinit {
print("MapSearch Deinitialized")
}
func searchTermToResults(searchTerm: String, countryName: String) -> Future<[MKLocalSearchCompletion], Error> {
Future { promise in
self.searchCompleter.queryFragment = searchTerm
self.currentPromise = promise
}
}
}
extension MapSearch: MKLocalSearchCompleterDelegate {
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
currentPromise?(.success(completer.results))
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
// deal with the error here, it will finish the Combine publisher stream
currentPromise?(.failure(error))
}
}
The MapSearch class needed to be adjusted to add [weak self] in the combine calls. Now it deinits properly.
Here's the code for reference:
import Combine
import CoreLocation
import Foundation
import MapKit
/// Uses MapKit and CoreLocation to auto-complete an address
class MapSearch: NSObject, ObservableObject {
#Published var countryName: String = "United States"
#Published var locationResults: [MKLocalSearchCompletion] = []
#Published var searchTerm = ""
private var cancellables: Set<AnyCancellable> = []
private var searchCompleter = MKLocalSearchCompleter()
private var currentPromise: ((Result<[MKLocalSearchCompletion], Error>) -> Void)?
override init() {
super.init()
searchCompleter.delegate = self
searchCompleter.resultTypes = MKLocalSearchCompleter.ResultType([.address])
$searchTerm
.debounce(for: .seconds(0.2), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap({ [weak self] (currentSearchTerm) in
(self?.searchTermToResults(searchTerm: currentSearchTerm, countryName: self?.countryName ?? "")) ??
Future { [weak self] promise in
self?.searchCompleter.queryFragment = self?.searchTerm ?? ""
self?.currentPromise = promise
}
})
.sink(receiveCompletion: { (_) in
}, receiveValue: { [weak self] (results) in
// Show country specific results
self?.locationResults = results.filter { $0.subtitle.contains(self?.countryName ?? "") }
})
.store(in: &cancellables)
print("MapSearch Initialized")
}
deinit {
print("MapSearch Deinitialized")
}
func searchTermToResults(searchTerm: String, countryName: String) -> Future<[MKLocalSearchCompletion], Error> {
Future { [weak self] promise in
self?.searchCompleter.queryFragment = searchTerm
self?.currentPromise = promise
}
}
}
extension MapSearch: MKLocalSearchCompleterDelegate {
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
currentPromise?(.success(completer.results))
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
// deal with the error here, it will finish the Combine publisher stream
currentPromise?(.failure(error))
}
}

Trying to set #published bool to true based on results from an API call

Hi first off I'm very new to swift and programing (coming from design field).
I'm trying to update doesNotificationsExist based on posts.count
I'm getting true inside the Api().getPosts {}
Where I print the following:
print("Api().getPosts")
print(doesNotificationExist)
but outside (in the loadData() {}) I still get false and not the #Publihed var doesNotificationExist:Bool = false doesn't update.
Please help me out, I would really appreciate some guidance to what I'm doing wrong and what I need to do.
Here is my code:
import SwiftUI
import Combine
public class DataStore: ObservableObject {
#Published var posts: [Post] = []
#Published var doesNotificationExist:Bool = false
init() {
loadData()
startApiWatch()
}
func loadData() {
Api().getPosts { [self] (posts) in
self.posts = posts
if posts.count >= 1 {
doesNotificationExist = true
}
else {
doesNotificationExist = false
}
print("Api().getPosts")
print(doesNotificationExist)
}
print("loadData")
print(doesNotificationExist)
}
func startApiWatch() {
Timer.scheduledTimer(withTimeInterval: 60, repeats: true) {_ in
self.loadData()
}
}
View where I'm trying to set an image based on store.doesNotificationsExist
StatusBarController:
import AppKit
import SwiftUI
class StatusBarController {
private var statusBar: NSStatusBar
private var statusItem: NSStatusItem
private var popover: NSPopover
#ObservedObject var store = DataStore()
init(_ popover: NSPopover)
{
self.popover = popover
statusBar = NSStatusBar.init()
statusItem = statusBar.statusItem(withLength: 28.0)
statusItem.button?.action = #selector(togglePopover(sender:))
statusItem.button?.target = self
if let statusBarButton = statusItem.button {
let itemImage = NSImage(named: store.doesNotificationExist ? "StatusItemImageNotification" : "StatusItemImage")
statusBarButton.image = itemImage
statusBarButton.image?.size = NSSize(width: 18.0, height: 18.0)
statusBarButton.image?.isTemplate = true
statusBarButton.action = #selector(togglePopover(sender:))
statusBarButton.target = self
}
}
`Other none relevant code for the question`
}
It’s a closure and hopefully the #escaping one. #escaping is used to inform callers of a function that takes a closure that the closure might be stored or otherwise outlive the scope of the receiving function. So, your outside print statement will be called first with bool value false, and once timer is completed closure will be called changing your Bool value to true.
Check code below -:
import SwiftUI
public class Model: ObservableObject {
//#Published var posts: [Post] = []
#Published var doesNotificationExist:Bool = false
init() {
loadData()
// startApiWatch()
}
func loadData() {
getPost { [weak self] (posts) in
//self.posts = posts
if posts >= 1 {
self?.doesNotificationExist = true
}
else {
self?.doesNotificationExist = false
}
print("Api().getPosts")
print(self?.doesNotificationExist)
}
print("loadData")
print(doesNotificationExist)
}
func getPost(completion:#escaping (Int) -> ()){
Timer.scheduledTimer(withTimeInterval: 5, repeats: true) {_ in
completion(5)
}
}
}
struct Test1:View {
#ObservedObject var test = Model()
var body: some View{
Text("\(test.doesNotificationExist.description)")
}
}

Swift MVVM Bind with Boxing

I am simply trying to create a weather application with WeatherViewController displaying the tableView with cells, and when the cell is tapped leads to WeatherDetailsViewController.
I am using the boxing way for binding and I am confused if I set Dynamic type in both the model and viewModel in the example below. You will know what I mean.
This is the Boxing Class
class Dynamic<T>: Decodable where T: Decodable {
typealias Listener = (T) -> ()
var listener: Listener?
var value: T {
didSet {
listener?(value)
}
}
func bind(listener: #escaping Listener) {
self.listener = listener
self.listener?(self.value)
}
init(_ value: T) {
self.value = value
}
private enum CodingKeys: CodingKey {
case value
}
}
This is the Weather Model Struct
struct Weather: Decodable {
let date: Dynamic<Int>
let description: Dynamic<String>
let maxTemperature: Dynamic<Double>
private enum CodingKeys: String, CodingKey {
case date = "time"
case description = "summary"
case maxTemperature = "temperatureMax"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
date = try Dynamic(container.decode(Int.self, forKey: .date))
description = try Dynamic(container.decode(String.self, forKey: .description))
maxTemperature = try Dynamic(container.decode(Double.self, forKey: .maxTemperature))
}
}
Here is my WeatherListViewModel & WeatherViewModel
Inside my WeatherViewModel I have assigned the type to be Dynamic but also in the model in order to bind in my WeatherDetailsViewController, is that right?
class WeatherListViewModel {
var weatherViewModels: [WeatherViewModel]
private var sessionProvider: URLSessionProvider
init(sessionProvider: URLSessionProvider) {
self.sessionProvider = sessionProvider
self.weatherViewModels = [WeatherViewModel]()
}
func numberOfRows(inSection section: Int) -> Int {
return weatherViewModels.count
}
func modelAt(_ index: Int) -> WeatherViewModel {
return weatherViewModels[index]
}
func didSelect(at indexPath: Int) -> WeatherViewModel {
return weatherViewModels[indexPath]
}
}
This is WeatherListViewModel Extension for network fetching where I initialize the WeatherViewModel
func fetchWeatherLocation(withLatitude latitude: CLLocationDegrees, longitude: CLLocationDegrees, completion: #escaping handler) {
sessionProvider.request(type: WeatherWrapper.self, service: WeatherService.specificLocation, latitude: latitude, longitude: longitude) { [weak self] result in
switch result {
case let .success(weatherWrapper):
let weathers = weatherWrapper.daily.weathers
self?.weatherViewModels = weathers.map {
return WeatherViewModel(weather: $0)
}
completion()
case let .failure(error):
print("Error: \(error)")
}
}
}
This is WeatherViewModel
struct WeatherViewModel {
private(set) var weather: Weather
var temperature: Dynamic<Double>
var date: Dynamic<Int>
var description: Dynamic<String>
init(weather: Weather) {
self.weather = weather
self.temperature = Dynamic(weather.maxTemperature)
self.date = Dynamic(weather.date)
self.description = Dynamic(weather.description)
}
}
Here is my WeatherDetailsViewController
Here I assign the binding to the labels respectively to get the changes
class WeatherDetailsViewController: UIViewController {
#IBOutlet private var imageView: UIImageView!
#IBOutlet private var cityLabel: UILabel!
#IBOutlet private var dateLabel: UILabel!
#IBOutlet private var descriptionLabel: UILabel!
#IBOutlet private var temperatureLabel: UILabel!
var viewModel: WeatherViewModel?
override func viewDidLoad() {
super.viewDidLoad()
setupVMBinding()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationItem.largeTitleDisplayMode = .never
}
private func setupVMBinding() {
if let viewModel = viewModel {
viewModel.date.bind {
self.dateLabel.text = $0.toString()
}
viewModel.temperature.bind {
self.temperatureLabel.text = "\($0)"
}
viewModel.description.bind {
self.descriptionLabel.text = $0.description
}
}
}
}
Question is, did I just repeat writing the type Dynamic in both model and viewModel? Is there a better way of doing this or am I on the right track. Sorry for the long code example.
I think you repeat writing Dynamic inside your Weather Model.
It does not need to be Dynamic type.
You can create a GenericDataSource
class GenericDataSource<T>: NSObject {
var data: Dynamic<T>?
}
Inside your View Model. This will Reference to your Weather Model without the need for creating dynamic type.
class WeatherViewModel {
var dataSource: GenericDataSource<Weather>?
....
}
Inside your View Controller
class WeatherDetailsViewController {
var viewModel: WeatherViewModel?
override func viewDidLoad() {
viewModel = ViewModel()
var dataSource = GenericDataSource<Weather>()
dataSource.data = Dynamic(Weather)
viewModel.dataSource = dataSource
setupVMBinding()
}
private func setupVMBinding() {
viewModel?.dataSource?.data?.bind {
self.dateLabel.text = $0.date
self.temperatureLabel.text = "\($0.maxTemperature)"
self.descriptionLabel.text = $0.description
}
}
}

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.

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