Why UITableViewCell does not bind to cellWiewModel when I use RxTableViewSectionedAnimatedDataSource? - swift

I have some odd problem with UITableViewCell when I use MVVM pattern with RxSwift. I can't describe it, but I will try to explain.
Let's say we have simple UITableViewCell
class MyTableViewCell: UITableViewCell {
var disposeBag = DisposeBag()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag = DisposeBag()
}
func bind(to viewModel: MyCellViewModel) {
viewModel.randomValue.asDriver()
.drive(onNext: { [weak self] value in
guard let self = self else { return}
print(value) // WHEN TWO CELLS I GET NEW VALUES JUST IN ONE OF THEM
})
.disposed(by: disposeBag)
}
with ViewModel with simple timer.
class MyCellViewModel: NSObject {
let randomValue = PublishSubject<Int>()
init() {
Observable<Int>.timer(.seconds(1), period: .seconds(1), scheduler: SerialDispatchQueueScheduler(qos: .userInteractive))
.map { _ in Int.random()}
.do(onNext: {
print($0) // WORK PERFECT FOR BOTH
})
.bind(to: randomValue)
.disposed(by: rx.disposeBag)
}
}
I also use RxDataSources to fill my tableView.
private func makeDataSource() -> RxTableViewSectionedAnimatedDataSource<Section> {
RxTableViewSectionedAnimatedDataSource<Section>(configureCell: { _, tableView, indexPath, item in
switch item {
case let .myItem(cellViewModel):
let cell = tableView.dequeueReusableCell(with: MyTableViewCell.self, for: indexPath)
cell.bind(to: cellViewModel)
return cell
}
})
}
The problem is that when I have two cells, I get new values in one of them and not in the other.
Pay attention to the method bind(to viewModel
But everything is ok when I change RxTableViewSectionedAnimatedDataSource to RxTableViewSectionedReloadDataSource.
How to make it work with RxTableViewSectionedAnimatedDataSource? I understand the difference between them, but in this case I don't know how does that affect it?
I also checked memory graph and saw just two instances of cells and two instances of cellViewModels. So I hope there are no leaks.
Thanks for any help!

You implemented your ItemMode.== incorrectly. It doesn't actually check for equality, it only checks the identity. Remove the function and let the compiler generate the correct one for you.

Related

Background thread Core Data object property changes doesn't reflect on UI

Let say I want to add a new item in Playlist entity of CoreData and put it in background thread and push back it to main thread then reflect it on tableView. Well, that code is working fine without background thread implementation.
But when I apply below background kinda code, after createPlaylist is executed, tableView becomes to empty space(without any items showed up), though print(self?.playlists.count) gives the correct rows count.
When dealing with GCD, I put some heavy code in background queue and push back to main queue for UI update in same closure. But it seems not worked here, I google a quit of time but still cannot anchor the issue.
import UIKit
import CoreData
class PlayListViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
var songs = [Song]()
var position = 0
let container = (UIApplication.shared.delegate as! AppDelegate).persistentContainer
private var playlists = [Playlist]()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 1, alpha: 1)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "playlistCell")
configureLayout()
getAllPlaylists()
}
// MARK: Core data functions
func getAllPlaylists() {
do {
let context = self.container.viewContext
playlists = try context.fetch(Playlist.fetchRequest())
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
}
print("count: \(playlists.count)")
// printThreadStats()
} catch {
print("getAllPlaylists failed, \(error)")
}
}
func createPlaylist(name: String) {
container.performBackgroundTask { context in
let newPlaylist = Playlist(context: context)
newPlaylist.name = name
do {
try context.save()
self.playlists = try context.fetch(Playlist.fetchRequest())
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
print(self?.playlists.count)
}
} catch {
print("Create playlist failed, \(error)")
}
}
}
// MARK: tableView data source implementation
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return playlists.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let playlist = playlists[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "playlistCell", for: indexPath)
cell.textLabel?.text = playlist.name
// cell.detailTextLabel?.text = "2 songs"
return cell
}
auto generated fetchRequest and Property defining
import Foundation
import CoreData
extension Playlist {
#nonobjc public class func fetchRequest() -> NSFetchRequest<Playlist> {
return NSFetchRequest<Playlist>(entityName: "Playlist")
}
#NSManaged public var name: String?
}
For the first call of func getAllPlaylists(), you are calling this on main thread from viewDidLoad(). So following lines are executed on main thread.
let context = self.container.viewContext
playlists = try context.fetch(Playlist.fetchRequest())
Next time inside the createPlaylist method, you are performing add playlist task in background context (not on main thread). So following lines are executed on background thread.
self.playlists = try context.fetch(Playlist.fetchRequest())
Also note that, first time we are using viewContext to fetch playlists and second time a backgroundContext. This mix up causes the UI to not show expected result.
I think these two methods could be simplified to -
func getAllPlaylists() {
do {
let context = self.container.viewContext
playlists = try context.fetch(Playlist.fetchRequest())
// DispatchQueue.main.async not necessary, we are already on main thread
self.tableView.reloadData()
print("count: \(playlists.count)")
} catch {
print("getAllPlaylists failed, \(error)")
}
}
func createPlaylist(name: String) {
container.performBackgroundTask { context in
let newPlaylist = Playlist(context: context)
newPlaylist.name = name
do {
try context.save()
DispatchQueue.main.async { [weak self] in
self?.getAllPlaylists()
}
} catch {
print("Create playlist failed, \(error)")
}
}
}
After 5 hours' digging today, I found the solution. I'd like put my solution and code below, because the stuff about "How to pass NSManagedObject instances between queues in CoreData" is quite rare && fragmentation, not friendly to newbies of SWIFT.
The thing is we want to do heavy CoreData task on background thread and reflect the changes in UI on foreground(main thread). Generally, we need to create a private queue context(privateMOC) and perform the heavy CoreData task on this private context, see below code.
For reuse purpose, I put CoreData functions separately.
import UIKit
import CoreData
struct CoreDataManager {
let managedObjectContext: NSManagedObjectContext
private let privateMOC = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
let coreDataStack = CoreDataStack()
static let shared = CoreDataManager()
private init() {
self.managedObjectContext = coreDataStack.persistentContainer.viewContext
privateMOC.parent = self.managedObjectContext
}
func fetchAllPlaylists(completion: #escaping ([Playlist]?) -> Void) {
privateMOC.performAndWait {
do {
let playlists: [Playlist] = try privateMOC.fetch(Playlist.fetchRequest())
print("getAllPlaylists")
printThreadStats()
print("count: \(playlists.count)")
completion(playlists)
} catch {
print("fetchAllPlaylists failed, \(error), \(error.localizedDescription)")
completion(nil)
}
}
}
func createPlaylist(name: String) {
privateMOC.performAndWait {
let newPlaylist = Playlist(context: privateMOC)
newPlaylist.name = name
synchronize()
}
}
func deletePlaylist(playlist: Playlist) {
privateMOC.performAndWait {
privateMOC.delete(playlist)
synchronize()
}
}
func updatePlaylist(playlist: Playlist, newName: String) {
...
}
func removeAllFromEntity(entityName: String) {
...
}
func synchronize() {
do {
// We call save on the private context, which moves all of the changes into the main queue context without blocking the main queue.
try privateMOC.save()
managedObjectContext.performAndWait {
do {
try managedObjectContext.save()
} catch {
print("Could not synchonize data. \(error), \(error.localizedDescription)")
}
}
} catch {
print("Could not synchonize data. \(error), \(error.localizedDescription)")
}
}
func printThreadStats() {
if Thread.isMainThread {
print("on the main thread")
} else {
print("off the main thread")
}
}
}
And Apple has a nice template for it Using a Private Queue to Support Concurrency
Another helpful link: Best practice: Core Data Concurrency
The real tricky thing is how to connect it with your view or viewController, the really implementation. See below ViewController code.
// 1
override func viewDidLoad() {
super.viewDidLoad()
// some layout code
// execute on background thread
DispatchQueue.global().async { [weak self] in
self?.fetchAndReload()
}
}
// 2
private func fetchAndReload() {
CoreDataManager.shared.fetchAllPlaylists(completion: { playlists in
guard let playlists = playlists else { return }
self.playlists = playlists
})
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
// 3
#objc func createNewPlaylist(_ sender: Any?) {
let ac = UIAlertController(title: "Create New Playlist", message: "", preferredStyle: .alert)
ac.addTextField { textField in
textField.placeholder = "input your desired name"
}
ac.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
ac.addAction(UIAlertAction(title: "Done", style: .default, handler: { [weak self] _ in
guard let textField = ac.textFields?.first, let newName = textField.text, !newName.isEmpty else { return }
// check duplicate
if let playlists = self?.playlists {
if playlists.contains(where: { playlist in
playlist.name == newName
}) {
self?.duplicateNameAlert()
return
}
}
DispatchQueue.global().async { [weak self] in
CoreDataManager.shared.createPlaylist(name: newName)
self?.fetchAndReload()
}
}))
present(ac, animated: true)
}
Let me break down it:
First in viewDidload, we call fetchAndReload on background thread.
In fetchAndReload function, it brings out all the playlist(returns data with completion handler) and refresh the table on main thread.
We call createPlaylist(name: newName) in background thread and reload the table on main thread again.
Well, this is the 1st time I deal with Multi-threading in CoreData, if there is any mistake, please indicate it. Allright, that's it! Hope it could help someone.

Rxswift hide button inside UITableViewCell after tapped

I have a addButton inside a UITableCieCwell. I want the addButton to be disappear after user click it, so I created a Action and bind it to the addButton.
However, all the addButton is disappeared although I just run my app.
I'm very new to RxSwift, please help me out.
Bind UI
viewModel.courses
.asObservable()
.bind(to: collectionView.rx.items(cellIdentifier: AddableCourseCell.reuseIdentifier, cellType: AddableCourseCell.self)) { (row, element, cell) in
let action = self.viewModel.actions.value[row]
action.enabled.asObservable()
.bind(to: cell.addButton.rx.isHidden)
.disposed(by: self.disposeBag)
cell.addButton.rx
.tap
.debounce(0.3, scheduler: MainScheduler.instance)
.subscribe({ (event) in
action.execute(element)
}).disposed(by: cell.disposeBag)
}.disposed(by: disposeBag)
ViewModel
class ViewModel {
var courses: Variable<[Course]> = Variable([])
var selectedCourses: Variable<[Course]> = Variable([])
var actions = Variable<[Action<Course, Bool>]>([])
private func generateAddActions(courses: [Course]) -> [Action<Course, Bool>] {
var actions: [Action<Course, Bool>] = []
for _ in courses {
let action = Action<Enrollment, Bool>(workFactory: { (input) -> Observable<Bool> in
let isAdded = selectedCourses.value.contains(input)
if !isAdded {
self.selectedCourses.value.append(input)
}
return Observable.just(isAdded)
})
actions.append(action)
}
return actions
}
}
Hi one tip I can give you is to add a .debug() in the action observable so you can see the values emitted. However, I believe what's causing you trouble is that the action observable's initial value is true which is bounded to the isHidden attribute of the addButton

Structuring a View Model Using RxSwift

My view models are fundamentally flawed because those that use a driver will complete when an error is returned and resubscribing cannot be automated.
An example is my PickerViewModel, the interface of which is:
// MARK: Picker View Modelling
/**
Configures a picker view.
*/
public protocol PickerViewModelling {
/// The titles of the items to be displayed in the picker view.
var titles: Driver<[String]> { get }
/// The currently selected item.
var selectedItem: Driver<String?> { get }
/**
Allows for the fetching of the specific item at the given index.
- Parameter index: The index at which the desired item can be found.
- Returns: The item at the given index. `nil` if the index is invalid.
*/
func item(atIndex index: Int) -> String?
/**
To be called when the user selects an item.
- Parameter index: The index of the selected item.
*/
func selectItem(at index: Int)
}
An example of the Driver issue can be found within my CountryPickerViewModel:
init(client: APIClient, location: LocationService) {
selectedItem = selectedItemVariable.asDriver().map { $0?.name }
let isLoadingVariable = Variable(false)
let countryFetch = location.user
.startWith(nil)
.do(onNext: { _ in isLoadingVariable.value = true })
.flatMap { coordinate -> Observable<ItemsResponse<Country>> in
let url = try client.url(for: RootFetchEndpoint.countries(coordinate))
return Country.fetch(with: url, apiClient: client)
}
.do(onNext: { _ in isLoadingVariable.value = false },
onError: { _ in isLoadingVariable.value = false })
isEmpty = countryFetch.catchError { _ in countryFetch }.map { $0.items.count == 0 }.asDriver(onErrorJustReturn: true)
isLoading = isLoadingVariable.asDriver()
titles = countryFetch
.map { [weak self] response -> [String] in
guard let `self` = self else { return [] }
self.countries = response.items
return response.items.map { $0.name }
}
.asDriver(onErrorJustReturn: [])
}
}
The titles drive the UIPickerView, but when the countryFetch fails with an error, the subscription completes and the fetch cannot be retried manually.
If I attempt to catchError, it is unclear what observable I could return which could be retried later when the user has restored their internet connection.
Any justReturn error handling (asDriver(onErrorJustReturn:), catchError(justReturn:)) will obviously complete as soon as they return a value, and are useless for this issue.
I need to be able to attempt the fetch, fail, and then display a Retry button which will call refresh() on the view model and try again. How do I keep the subscription open?
If the answer requires a restructure of my view model because what I am trying to do is not possible or clean, I would be willing to hear the better solution.
Regarding ViewModel structuring when using RxSwift, during intensive work on a quite big project I've figured out 2 rules that help keeping solution scalable and maintainable:
Avoid any UI-related code in your viewModel. It includes RxCocoa extensions and drivers. ViewModel should focus specifically on business logic. Drivers are meant to be used to drive UI, so leave them for ViewControllers :)
Try to avoid Variables and Subjects if possible. AKA try to make everything "flowing". Function into function, into function and so on and, eventually, in UI. Of course, sometimes you need to convert non-rx events into rx ones (like user input) - for such situations subjects are OK. But be afraid of subjects overuse - otherwise your project will become hard to maintain and scale in no time.
Regarding your particular problem. So it is always a bit tricky when you want retry functionality. Here is a good discussion with RxSwift author on this topic.
First way. In your example, you setup your observables on init, I also like to do so. In this case, you need to accept the fact that you DO NOT expect a sequence that can fail because of error. You DO expect sequence that can emit either result-with-titles or result-with-error. For this, in RxSwift we have .materialize() combinator.
In ViewModel:
// in init
titles = _reloadTitlesSubject.asObservable() // _reloadTitlesSubject is a BehaviorSubject<Void>
.flatMap { _ in
return countryFetch
.map { [weak self] response -> [String] in
guard let `self` = self else { return [] }
self.countries = response.items
return response.items.map { $0.name }
}
.materialize() // it IS important to be inside flatMap
}
// outside init
func reloadTitles() {
_reloadTitlesSubject.onNext(())
}
In ViewController:
viewModel.titles
.asDriver(onErrorDriveWith: .empty())
.drive(onNext: [weak self] { titlesEvent in
if let titles = titlesEvent.element {
// update UI with
}
else if let error = titlesEvent.error {
// handle error
}
})
.disposed(by: bag)
retryButton.rx.tap.asDriver()
.drive(onNext: { [weak self] in
self?.viewModel.reloadTitles()
})
.disposed(by: bag)
Second way is basically what CloackedEddy suggests in his answer. But can be simplified even more to avoid Variables. In this approach you should NOT setup your observable sequence in viewModel's init, but rather return it anew each time:
// in ViewController
yourButton.rx.tap.asDriver()
.startWith(())
.flatMap { [weak self] _ in
guard let `self` = self else { return .empty() }
return self.viewModel.fetchRequest()
.asDriver(onErrorRecover: { error -> Driver<[String]> in
// Handle error.
return .empty()
})
}
.drive(onNext: { [weak self] in
// update UI
})
.disposed(by: disposeBag)
I would shift some responsibilities to the view controller.
One approach would be to have the view model produce an Observable which as a side effect updates the view model properties. In the following code example, the view controller remains in charge of the view bindings, as well as triggering the refresh in viewDidLoad() and via a button tap.
class ViewModel {
let results: Variable<[String]> = Variable([])
let lastFetchError: Variable<Error?> = Variable(nil)
func fetchRequest() -> Observable<[String]> {
return yourNetworkRequest
.do(onNext: { self.results.value = $0 },
onError: { self.lastFetchError.value = $0 })
}
}
class ViewController: UIViewController {
let viewModel = ViewModel()
let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.results
.asDriver()
.drive(onNext: { yourLabel.text = $0 /* .reduce(...) */ })
.disposed(by: disposeBag)
viewModel.lastFetchError
.asDriver()
.drive(onNext: { yourButton.isHidden = $0 == nil })
.disposed(by: disposeBag)
yourButton.rx.tap
.subscribe(onNext: { [weak self] in
self?.refresh()
})
.disposed(by: disposeBag)
// initial attempt
refresh()
}
func refresh() {
// trigger the request
viewModel.fetchRequest()
.subscribe()
.disposed(by: disposeBag)
}
}
All answers are good, but i want to mentioned about CleanArchitectureRxSwift. This framework really help me to find the way how rx can be applied to my code. The part about "backend" mobile programming (request, parsers, etc) can be omitted, but work with viewModel/viewController has really interesting things.

UIRefreshControl with RxSwift

Hi I am trying to make a UIRefreshControl work with RxSwift. Therefore I am using the Activity Indicator that is in the RxSwift Example.
In my viewModel I have the following function and variable to get my data.
// MARK: - Variables
var data = Variable<[Data]>([])
// MARK: - Public Interface
func getData() {
let request = Data.readAll()
_ = request.rxResult().subscribe(onNext: { response in
self.data.value = response.data
}, onError: { (Error) in
}, onCompleted: {
}, onDisposed: {
})
}
Then in my view controller I try to bind it to the UIRefreshcontrol and the collection view I have.
let refresher: UIRefreshControl = UIRefreshControl()
let indicator = ActivityIndicator()
indicator.asObservable()
.bindTo(refresher.rx.isRefreshing)
.addDisposableTo(disposeBag)
let resultObservable = viewModel.data.asObservable()
.trackActivity(indicator)
.bindTo(self.collectionView.rx.items(cellIdentifier: reuseCell, cellType: DataCollectionViewCell.self)) {
row, data, cell in
cell.configureCell(with: data)
}
resultObservable.addDisposableTo(disposeBag)
My question is, what am I missing to make this work? Right now if I start the app nothing happens except a black activity indicator that doesn't stop spinning.
I think you should prefer subscribing in ViewController and add that subscription in that view controller's dispose bag.
The following way I think is the correct way of using ActivityIndicator from RxExamples. following is a pseudo code.
/// ViewController.swift
import RxSwift
import RxCocoa
…
let refreshControl = UIRefreshControl()
refreshControl.rx.controlEvent(.valueChanged)
.bind(to:self.viewModel.inputs.loadPageTrigger)
.disposed(by: disposeBag)
self.viewModel.indicator
.bind(to:refreshControl.rx.isRefreshing)
.dispose(by:disposeBag)
…
/// ViewModel.swift
…
let loadTrigger = PublishSubject<Void>()
let indicator = ActivityIndicator().asDriver()
…
// Assuming rxResult returns Observable<Response>
let req = indicator.asObservable()
.sample(loadTrigger)
.flatMap { isLoading -> Observable<Response> in
if isLoading { return Observable.empty() }
return Data.readAll().rxResult()
}
.trackActivity(indicator)
.map { $0.data }
.do(onNext: { [unowned self] data in
self.data.value = data
})
…
refresher
.rx.controlEvent(UIControlEvents.valueChanged)
.subscribe(onNext: { [weak self] in
//Put your hide activity code here
self?.refresher.endRefreshing()
}, onCompleted: nil, onDisposed: nil)
.disposed(by: disposeBag)
Subscribe an event of refresher(UIRefreshControl)

How to reload UITableView without printing data twice?

I have a single view of SlackTextViewController which works as a UITableView. I'm switching between "states" using a public String allowing it to read 1 set of data in a certain state and another set of data in another state. The problem is when I switch back and forth to the original state it prints the data 2, 3, 4 times; as many as I go back and forth. I'm loading the data from a Firebase server and I think it may be a Firebase issue. Here is my code...
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
if chatState == "ALL"
{
self.globalChat()
}else if chatState == "LOCAL"
{
self.localChat()
}
}
override func viewDidDisappear(animated: Bool) {
self.messageModels.removeAll()
tableView.reloadData()
ref.removeAllObservers()
}
func chatACTN()
{
if chatState == "ALL"
{
self.viewWillAppear(true)
}else if chatState == "LOCAL"
{
self.viewWillAppear(true)
}
}
func globalChat()
{
self.messageModels.removeAll()
tableView.reloadData()
let globalRef = ref.child("messages")
globalRef.keepSynced(true)
globalRef.queryLimitedToLast(100).observeEventType(.ChildAdded, withBlock: { (snapshot) -> Void in
if snapshot.exists()
{
let names = snapshot.value!["name"] as! String
let bodies = snapshot.value!["body"] as! String
let avatars = snapshot.value!["photo"] as! String
let time = snapshot.value!["time"] as! Int
let messageModel = MessageModel(name: names, body: bodies, avatar: avatars, date: time)
self.messageModels.append(messageModel)
self.messageModels.sortInPlace{ $0.date > $1.date }
}
self.tableView.reloadData()
})
}
func localChat()
{
self.messageModels.removeAll()
tableView.reloadData()
print("LOCAL")
}
The problem is that each time you call globalChat() you're creating another new observer which results in having multiple observers adding the same items to self.messageModels. Thats why you're seeing the data as many times as you switch to the global state.
Since you want to clear the chat and load the last 100 each time you switch to global, there's no point in keeping the observer active when you switch to "Local".
Just remove the observer when you switch to Local, that should fix your problem.
From firebase docs:
- (void) removeAllObservers
Removes all observers at the current reference, but does not remove any observers at child references.
removeAllObservers must be called again for each child reference where a listener was established to remove the observers.
So, ref.removeAllObservers() will not remove observers at ref.child("messages") level.
Using ref.child("messages").removeAllObservers at the beginning of localChat function to remove the observer you created in globalChat would be ok if you're only dealing with this one observer at this level but if you have more on the same level or you think you might add more in the future the best and safest way would be to remove the specific observer you created. To do that you should use the handle that is returned from starting an observer. Modify your code like this:
var globalChatHandle : FIRDatabaseHandle?
func globalChat()
{
self.messageModels.removeAll()
tableView.reloadData()
ref.child("messages").keepSynced(true)
globalChatHandle = ref.child("messages").queryLimitedToLast(100).observeEventType(.ChildAdded, withBlock: { (snapshot) -> Void in
if snapshot.exists()
{
let names = snapshot.value!["name"] as! String
let bodies = snapshot.value!["body"] as! String
let avatars = snapshot.value!["photo"] as! String
let time = snapshot.value!["time"] as! Int
let messageModel = MessageModel(name: names, body: bodies, avatar: avatars, date: time)
self.messageModels.append(messageModel)
self.messageModels.sortInPlace{ $0.date > $1.date }
}
self.tableView.reloadData()
})
}
func localChat()
{
if globalChatHandle != nil {
ref.child("messages").removeObserverWithHandle(globalChatHandle)
}
self.messageModels.removeAll()
tableView.reloadData()
print("LOCAL")
}
And in viewDidDisappear method replace this
ref.removeAllObservers()
with
ref.child("messages").removeAllObservers()