I'm using the latest version of Moya with RxSwift and I've encountered a logical issue for which I can't find a solution, at the moment.
Let's say that I have a UITableViewController with a ViewModel that implements the following interface:
protocol ListViewModelType {
var items: Observable<[Item]> { get }
}
The items property is implemented as (using EVReflection):
var items: Observable<[Therapy]> {
get {
let provider = RxMoyaProvider<ItemService>()
return provider
.request(.all)
.map(toArray: Item.self)
}
}
In the viewDidLoad method of the UITableViewController, I have set up a binding between the items property and the tableView via the following code:
self.viewModel.items
.bind(to: tableView.rx.items(cellIdentifier: cellIdentifier, cellType: cellType)) { row, element, cell in
// cell configuration code
}
.disposed(by: self.disposeBag)
Now, I would like to refresh the content of the UITableView to reflect changes that the user has done through other parts of the app. Considering that the RxMoyaProvider returns an Observable, this should be easily done through another value emitted by the Observable, but I don't know how to communicate to the provider that it should refresh the content from the server and put it into the same Observable.
Am I missing something here? Is there a more recommended way to bind a UITableView to a list of objects coming from a RxMoyaProvider?
You have to reload our Moya request. this is a bit hacky but i guess you get the hint.
let didAppear = Variable(true)
override func viewDidAppear(_ animated: Bool) {
didAppear.value = true
}
override func viewDidLoad(){
self.didAppear.flatMap{ _ in self.viewModel.items}
.bind(to: tableView.rx.items(cellIdentifier: cellIdentifier, cellType: cellType)) { row, element, cell in
// cell configuration code
}
.disposed(by: self.disposeBag)
}
alternatively you could redesign our architecture with a offline-first principle
Related
I do have following structure:
- TableView
-- Custom Table View Cell
--- CollectionView
---- Custom CollectionView Cell
I want to understand that how can I pass the data from / using view model in this structure with RxSwift - MVVM Structure.
Whenever I do get response from API it should update the data in table view rows and associated collection view cell respectively.
The simplest solution is to use an array of arrays.
For example. Let's assume your API returns:
struct Group: Decodable {
let items: [String]
}
Then your view model would be as simple as this:
func tableViewItems(source: Observable<[Group]>) -> Observable<[[String]]> {
return source
.map { $0.map { $0.items } }
}
When creating your cell, you can wrap the inner array into an observable with Observable.just() like this:
// in your view controller's viewDidLoad for example.
tableViewItems(source: apiResponse)
.bind(to: tableView.rx.items(cellIdentifier: "Cell", cellType: CollectionTableViewCell.self)) { _, element, cell in
Observable.just(element)
.bind(to: cell.collectionView.rx.items(cellIdentifier: "Cell", cellType: UICollectionViewCell.self)) { _, element, cell in
let label = (cell.viewWithTag(6969) as? UILabel) ?? UILabel()
label.tag = 6969
label.text = element
label.sizeToFit()
cell.addSubview(label)
}
.disposed(by: cell.disposeBag)
}
.disposed(by: dispsoeBag)
Here is an example I wrote just now to demonstrate how you can use RxSwift to do what you want. Important Note: This is a rough example, not optimally written and not tested! I just wrote it using a text editor hope it helps you out, if not I will try to polish it when I have some more time.
class MyViewModel {
// Lets say TableData is your model for the tableView data and CollectionData for your collectionView
public let tableData : PublishSubject<[TableData]> = PublishSubject()
public let collectionData : PublishSubject<[CollectionData]> = PublishSubject()
private let disposeBag = DisposeBag()
func fetchData() {
// Whenever you get an update from your API or whatever source you call .onNext
// Lets assume you received an update and stored them on a variable called newShopsUpdate
self.tableData.onNext(newTableDataUpdate)
self.collectionData.onNext(newCollectionDataDataUpdate)
}
}
class MyViewController: UIViewController {
var tableData: BehaviorRelay<[TableData]> = BehaviorRelay(value: [])
var collectionData: BehaviorRelay<[CollectionData]> = BehaviorRelay(value: [])
let viewModel = MyViewModel()
override func viewDidLoad() {
super.viewDidLoad()
// Setup Rx Bindings
viewModel
.tableData
.observeOn(MainScheduler.instance)
.bind(to: self.tableData)
.disposed(by: DisposeBag())
viewModel
.collectionData
.observeOn(MainScheduler.instance)
.bind(to: self.collectionData)
.disposed(by: DisposeBag())
// Register yours Cells first as usual
// ...
// Setting the datasource using RxSwift
tableData.bind(to: tableView.rx.items(cellIdentifier: "yourCellIdentifier", cellType: costumeTableViewCell.self)) { row, tableData, cell in
// Set all the cell properties here
// Lets also assume you have you collectionView inside one of the cells
cell.tableData = tableData
collectionData.bind(to: cell.collectionView.rx.items(cellIdentifier: "yourCellIdentifier", cellType: costumeCollectionViewCell.self)) { row, collectionData, cell in
// Set all the cell properties here
cell.collectionData = collectionData
}.disposeBag(by: DisposeBag())
}.disposed(by: DisposeBag())
}
}
When a user swipe a cell, it becomes possible to delete it. However, the deletion occurs without animation.
Part of code in my ViewController:
override func viewDidLoad() {
super.viewDidLoad()
guard let foodCategoryDetailViewModel = foodCategoryDetailViewModel else { return }
tableView.delegate = nil
tableView.dataSource = nil
foodCategoryDetailViewModel.foodsInSelectedCategory
.bind(to: tableView.rx.items(cellIdentifier: FoodCategoryDetailTableViewCell.cellIdentifier, cellType: FoodCategoryDetailTableViewCell.self))
{ row, food, cell in
cell.foodCategoryDetailCellViewModel = foodCategoryDetailViewModel.cellViewModel(forRow: row)
}.disposed(by: disposeBag)
tableView.rx.itemDeleted.subscribe(onNext: { indexPath in
foodCategoryDetailViewModel.removeFoodFromApplication(atRow: indexPath.row)
}).disposed(by: disposeBag)
}
Part of code in my ViewModel:
class FoodCategoryDetailTableViewViewModel: FoodCategoryDetailTableViewViewModelType {
var foodsInSelectedCategory: BehaviorRelay<[Food]>
func removeFoodFromApplication(atRow row: Int) {
if let food = getFood(atRow: row) {
foodsInSelectedCategory.remove(at: row)
//remove from core data
CoreDataHelper.sharedInstance.removeFoodFromApplication(foodName: food.name!)
}
}
How to animating deleting process from tableView?
In order to animate the deleting process, you need a datasource that is designed to do that. The default datasource in RxSwift is not.
The most popular library for such a thing is RxDataSources which brings in a full blown multi-section system for animating table and collection views, but if you don't want something that elaborate, you can easily write your own.
Here is an example of a simple animatable datasource for RxSwift that uses DifferenceKit to calculate which cells must be animated on/off: (https://github.com/danielt1263/RxMultiCounter/blob/master/RxMultiCounter/RxExtensions/RxSimpleAnimatableDataSource.swift)
In my mobile application I would like to update the tableView datasource by a pull to refresh request, but I don't know how to insert the new items on top of the tableview datasource.
I see that there is a a method of insertRows such as : self.tableView?.insertRows(at: [indexPath], with: .top) but how do I add the newItems here according to my methods I have?
I have a function called initializedTableView() that initializes the tableView with PublishSubject observable items.
func initializeTableView() {
viewModel
.items
.subscribe(onNext: { items in
self.tableView?.delegate = nil
self.tableView?.dataSource = nil
Observable.just(items)
.bind(to:(self.tableView?.rx.items(cellIdentifier:
itemCell.Identifier, cellType: itemCell.self))!) {
(index, element, cell) in
cell.itemModel = element
}.disposed(by: self.disposeBag)
})
.disposed(by: disposeBag)
}
This function is called once a pull to refresh is requested by user:
func refreshTableView() {
// get new items
viewModel
.newItems
.subscribe(onNext: { newItems in
//new
let new = newItems.filter({ item in
// items.new == true
})
//old
var old = newItems.filter({ item -> Bool in
// items.new == false
})
new.forEach({item in
// how to update tableView.rx.datasource here???
})
}).disposed(by: disposeBag)
}
struct ViewModel {
let items: BehaviorRelay<[Item]>
init() {
self.items = BehaviorRelay(value: [])
}
func fetchNewItems() {
// This assumes you are properly distinguishing which items are new
// and `newItems` does not contain existing items
let newItems: [Item] = /* However you get new items */
// Get a copy of the current items
var updatedItems = self.items.value
// Insert new items at the beginning of currentItems
updatedItems.insert(contentsOf: newItems, at: 0)
// For simplicity this answer assumes you are using a single cell and are okay with a reload
// rather than the insert animations.
// This will reload your tableView since 'items' is bound to the tableView items
//
// Alternatively, you could use RxDataSources and use the `RxTableViewSectionedAnimatedDataSource`
// This will require a section model that conforms to `AnimatableSectionModelType` and some
// overall reworking of this example
items.accept(updatedItems)
}
}
final class CustomViewController: UIViewController {
deinit {
disposeBag = DisposeBag()
}
#IBOutlet weak var tableView: UITableView!
private var disposeBag = DisposeBag()
private let viewModel = ViewModel()
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(CustomTableCell.self, forCellReuseIdentifier: "ReuseID")
tableView.refreshControl = UIRefreshControl()
viewModel.items
.bind(to: tableView.rx.items(cellIdentifier: "ReuseID", cellType: CustomTableCell.self)) { row, item, cell in
// Configure cell with item
cell.configure(with: item)
}
.disposed(by: disposeBag)
tableView.refreshControl?.rx.controlEvent(.valueChanged)
.subscribe(onNext: { [weak self] in
self?.viewModel.fetchNewItems()
})
.disposed(by: disposeBag)
}
}
Alternative answer using BehaviorRelay and bindings. This way, you are only updating the items relay and it will automatically update the tableView. It also provides a more "Rx" way of handling pull to refresh.
As mentioned in the code comments, this assumes you are determining which items are new and that newItems does not contain any existing items. Either way this should provide a starting point.
struct ViewModel {
let items: Observable<[Item]>
init(trigger: Observable<Void>, newItems: #escaping () -> Observable<[Item]>) {
items = trigger
.flatMapLatest(newItems)
.scan([], accumulator: { $1 + $0 })
}
}
The above doesn't handle errors, nor does it handle resets, but the scan will put the new items at the top of the list.
The situation doesn't feel right though. Normally, the API call returns all the items, how can it possibly know which items are "new"?
I did something similar with my app since I had issues with tableView.insertRows.
Here is the code:
func loadMoreComments() {
// call to backend to get more comments
getMoreComments { (newComments) in
// combine the new data and your existing data source array
self.comments = newComments + self.comments
self.tableView.reloadData()
self.tableView.layoutIfNeeded()
// calculate the total height of the newly added cells
var addedHeight: CGFloat = 0
for i in 0...result.count {
let indexRow = i
let tempIndexPath = IndexPath(row: Int(indexRow), section: 0)
addedHeight = addedHeight + self.tableView.rectForRow(at: tempIndexPath).height
}
// adjust the content offset by how much height was added to the start so that it looks the same to the user
self.tableView.contentOffset.y = self.tableView.contentOffset.y + addedHeight
}
}
So, by calculating the heights of the new cells being added to the start and then adding this calculated height to the tableView.contentOffset.y, I was able to add cells to the top of the tableView seamlessly without reworking my tableView. This may look like a jerky workaround, but the shift in tableView.contentOffset isn't noticeable if you calculate the height properly.
Successes so far: I have a remote data source. Data gets pulled dynamically into a View Controller. The data is used to name a .title and .subtitle on each of the reusable custom cells. Also, each custom cell has a UISwitch, which I have been able to get functional for sending out both a “subscribe” signal for push notifications (for a given group identified by the cell’s title/subtitle) and an “unsubscribe” signal as well.
My one remaining issue: Whenever the user "revisits" the settings VC, while my code is "resetting" the UISwitches, it causes the following warnings in Xcode 9.2:
UISwitch.on must be used from main thread
UISwitch.setOn(_:animated:) must be used from main thread only
-[UISwitch setOn:animated:notifyingVisualElement:] must be used from main thread
The code below "works" -- however the desired result happens rather slowly (the UISwitches that are indeed supposed to be "on" take a good while to finally flip to "on").
More details:
What is needed: Whenever the VC is either shown or "re-shown," I need to "reset" the custom cell’s UISwitch to "on" if the user is subscribed to the given group, and to "off" if the user is not subscribed. Ideally, each time the VC is displayed, something should reach out and touch the OneSignal server and find out that user’s “subscribe state” for each group, using the OneSignal.getTags() function. I have that part working. This code is in the VC. But I need to do it the right way, to suit proper protocols regarding threading.
VC file, “ViewController_13_Settings.swift” holds a Table View with the reusable custom cell.
Table View file is named "CustomTableViewCell.swift"
The custom cell is called "customCell" (I know, my names are all really creative).
The custom cell (designed in XIB) has only three items inside it:
Title – A displayed “friendly name” of a “group” to be subscribed to or unsubscribed from. Set from the remote data source
Subtitle – A hidden “database name” of the aforementioned group. Hidden from the user. Set from the remote data source.
UISwitch - named "switchMinistryGroupList"
How do I properly set the UISwitch programmatically?
Here is the code in ViewController_13_Settings.swift that seems pertinent:
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "customCell", for: indexPath) as! CustomTableViewCell
// set cell's title and subtitle
cell.textLabelMinistryGroupList?.text = MinistryGroupArray[indexPath.row]
cell.textHiddenUserTagName?.text = OneSignalUserTagArray[indexPath.row]
// set the custom cell's UISwitch.
OneSignal.getTags({ tags in
print("tags - \(tags!)")
self.OneSignalUserTags = String(describing: tags)
print("OneSignalUserTags, from within the OneSignal func, = \(self.OneSignalUserTags)")
if self.OneSignalUserTags.range(of: cell.textHiddenUserTagName.text!) != nil {
print("The \(cell.textHiddenUserTagName.text!) UserTag exists for this device.")
cell.switchMinistryGroupList.isOn = true
} else {
cell.switchMinistryGroupList.isOn = false
}
}, onFailure: { error in
print("Error getting tags - \(String(describing: error?.localizedDescription))")
// errorWithDomain - OneSignalError
// code - HTTP error code from the OneSignal server
// userInfo - JSON OneSignal responded with
})
viewWillAppear(true)
return cell
}
}
In the above portion of the VC code, this part (below) is what is functioning but apparently not in a way the uses threading properly:
if OneSignalUserTags.range(of: cell.textHiddenUserTagName.text!) != nil {
print("The \(cell.textHiddenUserTagName.text!) UserTag exists for this device.")
cell.switchMinistryGroupList.isOn = true
} else {
cell.switchMinistryGroupList.isOn = false
}
It's not entirely clear what your code is doing, but there seems to be a few things that need sorting out, that will help you solve your problem.
1) Improve the naming of your objects. This helps others see what's going on when asking questions.
Don't call your cell CustomTableViewCell - call it, say, MinistryCell or something that represents the data its displaying. Rather than textLabelMinistryGroupList and textHiddenUserTagName tree ministryGroup and userTagName etc.
2) Let the cell populate itself. Make your IBOutlets in your cell private so you can't assign to them directly in your view controller. This is a bad habit!
3) Create an object (Ministry, say) that corresponds to the data you're assigning to the cell. Assign this to the cell and let the cell assign to its Outlets.
4) Never call viewWillAppear, or anything like it! These are called by the system.
You'll end up with something like this:
In your view controller
struct Ministry {
let group: String
let userTag: String
var tagExists: Bool?
}
You should create an array var ministries: [Ministry] and populate it at the start, rather than dealing with MinistryGroupArray and OneSignalUserTagArray separately.
In your cell
class MinistryCell: UITableViewCell {
#IBOutlet private weak var ministryGroup: UILabel!
#IBOutlet private weak var userTagName: UILabel!
#IBOutlet private weak var switch: UISwitch!
var ministry: Ministry? {
didSet {
ministryGroup.text = ministry?.group
userTagName.text = ministry?.userTag
if let tagExists = ministry?.tagExists {
switch.isEnabled = false
switch.isOn = tagExists
} else {
// We don't know the current state - disable the switch?
switch.isEnabled = false
}
}
}
}
Then you dataSource method will look like…
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "customCell", for: indexPath) as! MinistryCell
let ministry = ministries[indexPath.row]
cell.ministry = ministry
if ministry.tagExists == nil {
OneSignal.getTags { tags in
// Success - so update the corresponding ministry.tagExists
// then reload the cell at this indexPath
}, onFailure: { error in
print("Error")
})
}
return cell
}
I found the pattern used by the Google places IOS SDK to be clean and well designed. Basically they follow what is presented on the following apple presentation: Advanced User interface with Collection view (It start slide 46).
This is what is implemented in their GMSAutocompleteTableDataSource.
It us up to the datasource to define the state of the tableview.
We link the tableview.
var googlePlacesDataSource = GMSAutocompleteTableDataSource()
tableView.dataSource = googlePlacesDataSource
tableView.delegate = googlePlacesDataSource
Then every time something change the event is binded to the datasource:
googlePlacesDataSource.sourceTextHasChanged("Newsearch")
The data source perform the query set the table view as loading and then display the result.
I would like to achieve this from my custom Source:
class JourneyTableViewDataSource:NSObject, UITableViewDataSource, UITableViewDelegate{
private var journeys:[JourneyHead]?{
didSet{
-> I want to trigger tableView.reloadData() when this list is populated...
-> How do I do that? I do not have a reference to tableView?
}
}
override init(){
super.init()
}
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
...
}
Any idea?
to update dynamic tableview, you have to add reloadData, reloadData updates datasource.
self.tableview.reloadData()
Your data source shouldn't know anything about your table view. This is the basic of this patter and it gives you a lot of power in terms of reusability.
For your purpose the best solution is to use the property closure:
var onJourneysUpdate: (() -> Void)?
private var journeys: [JourneyHead]? {
didSet {
onJourneysUpdate?()
}
}
And in the point where you set your data source you can set whatever action you would like to execute after updating the datasource's array:
var googlePlacesDataSource = JourneyTableViewDataSource()
tableView.dataSource = googlePlacesDataSource
tableView.delegate = googlePlacesDataSource
googlePlacesDataSource.onJourneysUpdate = { [unowned self] in
self.tableView.reloadData()
}