How do I use multiple cells in a tableView using Rx? - ios

I have a tableView with two cells: StaticSupportTableViewCell and SupportTableViewCell. As the name suggests the first cell is a single static cell on top of the tableView. SupportTableViewCell can be any number and should be displayed underneath the static cell.
I have code that binds and returns the correct cell:
viewModel.multiContent.bind(to: tableView.rx.items) { tableView, index, item in
if let cellViewModel = item as? StaticSupportTableViewCellViewModel {
let cell = tableView.dequeueReusableCell(withIdentifier: StaticSupportTableViewCell.identifier) as? StaticSupportTableViewCell
cell?.viewModel = cellViewModel
guard let guardedCell = cell else { return UITableViewCell()}
return guardedCell
}
if let cellViewModel = item as? SupportTableViewCellViewModel {
let cell = tableView.dequeueReusableCell(withIdentifier: SupportTableViewCell.identifier) as? SupportTableViewCell
cell?.viewModel = cellViewModel
guard let guardedCell = cell else { return UITableViewCell()}
return guardedCell
}
else { return UITableViewCell() }
}.disposed(by: disposeBag)
In the viewModel I have the multiContent variable:
var multiContent = BehaviorRelay<[Any]>(value: [])
Now if I accept the cell viewModels onto that relay one by one it works:
This works:
multiContent.accept([StaticSupportTableViewCellViewModel(myString: "TESTING")])
Or doing this instead:
multiContent.accept(mainService.serviceProviders.compactMap { SupportTableViewCellViewModel(serviceProvider: $0, emailRelay: emailRelay)})
But if I try both at the same time...
multiContent.accept([StaticSupportTableViewCellViewModel(myString: "TESTING")])
multiContent.accept(mainService.serviceProviders.compactMap { SupportTableViewCellViewModel(serviceProvider: $0, emailRelay: emailRelay)})
...only the last cell is shown. It's like the last one replaces the first one instead of being an addition to it.
So how do I accept both cell viewModels to the relay so that both are displayed in the tableView?
EDIT
I sort of got it right by adding the two cell viewModels into one array:
let contents: [Any] = [StaticSupportTableViewCellViewModel(brand: name, url: web.localized(), phoneNumber: phone.localized()), mainService.serviceProviders.compactMap { SupportTableViewCellViewModel(serviceProvider: $0, emailRelay: emailRelay)}]
And changed the binding:
if let cellViewModels = item as? [SupportTableViewCellViewModel] {...
This is problematic though as I'm stuck with and array of [SupportTableViewCellViewModel]. It doesn't work looping them and returning the cells as they overwrite one another.
The solution is to send in the cell viewModel SupportTableViewCellViewModel instead of [SupportTableViewCellViewModel], but how do I do that?

A BehaviorRelay only emits the most recent array accepted. When you call accept(_:) you aren't adding to the state of the relay, you are replacing its state. To emit both, you will need to concatenate the two arrays.
In general, you should avoid using Relays and Subjects in production code. They are good for learning the Rx system and samples but are hard to get right in real world situations. You only need them when converting non-Rx code into Rx or when setting up a feedback loop.
Also, a Subject, Relay or Observable shouldn't be a var. You don't want to replace one once setup. Make them lets instead.

Related

What is NSDiffableDataSourceSnapshot `reloadItems` for?

I'm having difficulty finding the use of NSDiffableDataSourceSnapshot reloadItems(_:):
If the item I ask to reload is not equatable to an item that is already present in the data source, I crash with:
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Attempted to reload item identifier that does not exist in the snapshot: ProjectName.ClassName
But if the item is equatable to an item that is already present in the data source, then what's the point of "reloading" it?
You might think the answer to the second point is: well, there might be some other aspect of the item identifier object that is not part of its equatability but does reflect into the cell interface. But what I find is that that's not true; after calling reloadItems, the table view does not reflect the change.
So when I want to change an item, what I end up doing with the snapshot is an insert after the item to be replaced and then a delete of the original item. There is no snapshot replace method, which is what I was hoping reloadItems would turn out to be.
(I did a Stack Overflow search on those terms and found very little — mostly just a couple of questions that puzzled over particular uses of reloadItems, such as How to update a table cell using diffable UITableView. So I'm asking in a more generalized form, what practical use has anyone found for this method?)
Well, there's nothing like having a minimal reproducible example to play with, so here is one.
Make a plain vanilla iOS project with its template ViewController, and add this code to the ViewController.
I'll take it piece by piece. First, we have a struct that will serve as our item identifier. The UUID is the unique part, so equatability and hashability depend upon it alone:
struct UniBool : Hashable {
let uuid : UUID
var bool : Bool
// equatability and hashability agree, only the UUID matters
func hash(into hasher: inout Hasher) {
hasher.combine(uuid)
}
static func ==(lhs:Self, rhs:Self) -> Bool {
lhs.uuid == rhs.uuid
}
}
Next, the (fake) table view and the diffable data source:
let tableView = UITableView(frame: .zero, style: .plain)
var datasource : UITableViewDiffableDataSource<String,UniBool>!
override func viewDidLoad() {
super.viewDidLoad()
self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
self.datasource = UITableViewDiffableDataSource<String,UniBool>(tableView: self.tableView) { tv, ip, isOn in
let cell = tv.dequeueReusableCell(withIdentifier: "cell", for: ip)
return cell
}
var snap = NSDiffableDataSourceSnapshot<String,UniBool>()
snap.appendSections(["Dummy"])
snap.appendItems([UniBool(uuid: UUID(), bool: true)])
self.datasource.apply(snap, animatingDifferences: false)
}
So there is just one UniBool in our diffable data source and its bool is true. So now set up a button to call this action method which tries to toggle the bool value by using reloadItems:
#IBAction func testReload() {
if let unibool = self.datasource.itemIdentifier(for: IndexPath(row: 0, section: 0)) {
var snap = self.datasource.snapshot()
var unibool = unibool
unibool.bool = !unibool.bool
snap.reloadItems([unibool]) // this is the key line I'm trying to test!
print("this object's isOn is", unibool.bool)
print("but looking right at the snapshot, isOn is", snap.itemIdentifiers[0].bool)
delay(0.3) {
self.datasource.apply(snap, animatingDifferences: false)
}
}
}
So here's the thing. I said to reloadItems with an item whose UUID is a match, but whose bool is toggled: "this object's isON is false". But when I ask the snapshot, okay, what have you got? it tells me that its sole item identifier's bool is still true.
And that is what I'm asking about. If the snapshot is not going to pick up the new value of bool, what is reloadItems for in the first place?
Obviously I could just substitute a different UniBool, i.e. one with a different UUID. But then I cannot call reloadItems; we crash because that UniBool is not already in the data. I can work around that by calling insert followed by remove, and that is exactly how I do work around it.
But my question is: so what is reloadItems for, if not for this very thing?
(I've filed a bug on the behavior demonstrated in the question, because I don't think it's good behavior. But, as things stand, I think I can provide a guess as to what the idea is intended to be.)
When you tell a snapshot to reload a certain item, it does not read in the data of the item you supply! It simply looks at the item, as a way of identifying what item, already in the data source, you are asking to reload.
(So, if the item you supply is Equatable to but not 100% identical to the item already in the data source, the "difference" between the item you supply and the item already in the data source will not matter at all; the data source will never be told that anything is different.)
When you then apply that snapshot to the data source, the data source tells the table view to reload the corresponding cell. This results in the data source's cell provider function being called again.
OK, so the data source's cell provider function is called, with the usual three parameters — the table view, the index path, and the data from the data source. But we've just said that the data from the data source has not changed. So what is the point of reloading at all?
The answer is, apparently, that the cell provider function is expected to look elsewhere to get (at least some of) the new data to be displayed in the newly dequeued cell. You are expected to have some sort of "backing store" that the cell provider looks at. For example, you might be maintaining a dictionary where the key is the cell identifier type and the value is the extra information that might be reloaded.
This must be legal, because by definition the cell identifier type is Hashable and can therefore serve as a dictionary key, and moreover the cell identifiers must be unique within the data, or the data source would reject the data (by crashing). And the lookup will be instant, because this is a dictionary.
Here's a complete working example you can just copy and paste right into a project. The table portrays three names along with a star that the user can tap to make star be filled or empty, indicating favorite or not-favorite. The names are stored in the diffable data source, but the favorite status is stored in the external backing store.
extension UIResponder {
func next<T:UIResponder>(ofType: T.Type) -> T? {
let r = self.next
if let r = r as? T ?? r?.next(ofType: T.self) {
return r
} else {
return nil
}
}
}
class TableViewController: UITableViewController {
var backingStore = [String:Bool]()
var datasource : UITableViewDiffableDataSource<String,String>!
override func viewDidLoad() {
super.viewDidLoad()
let cellID = "cell"
self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellID)
self.datasource = UITableViewDiffableDataSource<String,String>(tableView:self.tableView) {
tableView, indexPath, name in
let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath)
var config = cell.defaultContentConfiguration()
config.text = name
cell.contentConfiguration = config
var accImageView = cell.accessoryView as? UIImageView
if accImageView == nil {
let iv = UIImageView()
iv.isUserInteractionEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(self.starTapped))
iv.addGestureRecognizer(tap)
cell.accessoryView = iv
accImageView = iv
}
let starred = self.backingStore[name, default:false]
accImageView?.image = UIImage(systemName: starred ? "star.fill" : "star")
accImageView?.sizeToFit()
return cell
}
var snap = NSDiffableDataSourceSnapshot<String,String>()
snap.appendSections(["Dummy"])
let names = ["Manny", "Moe", "Jack"]
snap.appendItems(names)
self.datasource.apply(snap, animatingDifferences: false)
names.forEach {
self.backingStore[$0] = false
}
}
#objc func starTapped(_ gr:UIGestureRecognizer) {
guard let cell = gr.view?.next(ofType: UITableViewCell.self) else {return}
guard let ip = self.tableView.indexPath(for: cell) else {return}
guard let name = self.datasource.itemIdentifier(for: ip) else {return}
guard let isFavorite = self.backingStore[name] else {return}
self.backingStore[name] = !isFavorite
var snap = self.datasource.snapshot()
snap.reloadItems([name])
self.datasource.apply(snap, animatingDifferences: false)
}
}
Based on your new example code, I agree, it looks like a bug. When you add a reloadItems to a snapshot it correctly triggers the datasource closure to request an updated cell, but the IdentifierType item that is passed to the closure is the original, not the new value that was provided with the reloadItems call.
If I changed your UniBool struct to a class so that it is a reference rather than a value type, then things worked as expected (since there is now a single instance of a UniBool rather than a new one with the same identifier).
It seems at the moment there are a couple of possible work-arounds:
Use a reference rather than a value type for the IdentifierType
Use an additional backing store, such as an array, and access it via indexPath in the datasource closure.
I don't think that either of these are ideal.
Interestingly, after I changed UniBool to a class, I tried creating a new instance of UniBool that had the same uuid as the existing instance and reloading that; The code crashed with an exception stating Invalid item identifier specified for reload; This doesn't sound right to me; Only the hashValue should matter, not the actual object reference. Both the original and the new objects had the same hashValue and == returned true.
Original answer
reloadItems works, but there are two important points:
You must start with the datasource's current snapshot and call reloadItems on that. You can't create a new snapshot.
You can't rely on the item passed to the CellProvider closure for anything other than the identifier - It doesn't represent the most recent data from your backing model (array).
Point 2 means that you need to use the provided indexPath or item.id to obtain your updated object from your model.
I created a simple example that displays the current time in a table row; This is the data source struct:
struct RowData: Hashable {
var id: UUID = UUID()
var name: String
private let possibleColors: [UIColor] = [.yellow,.orange,.cyan]
var timeStamp = Date()
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
static func ==(lhs: RowData, rhs: RowData) -> Bool {
return lhs.id == rhs.id
}
}
Note that despite the hash function only using the id property it is also necessary to override == or you will get a crash with an invalid identifier when you attempt to reload the row.
Each second a random selection of rows are reloaded. When you run the code you see that the time is updated on those randomly selected rows.
This is the code that uses reloadItems:
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
guard let datasource = self.tableview.dataSource as? UITableViewDiffableDataSource<Section,RowData> else {
return
}
var snapshot = datasource.snapshot()
var rowIdentifers = Set<RowData>()
for _ in 0...Int.random(in: 1...self.arrItems.count) {
let randomIndex = Int.random(in: 0...self.arrItems.count-1)
self.arrItems[randomIndex].timeStamp = Date()
rowIdentifers.insert(self.arrItems[randomIndex])
}
snapshot.reloadItems(Array(rowIdentifers))
datasource.apply(snapshot)
}
I posted the same question, not realising. I got this working by firstly converting my model to classes. Then calling 'applySnapshot' after calling 'reloadItems'.
func toggleSelectedStateForItem(at indexPath: IndexPath, animate: Bool = true) {
let item = dataSource.itemIdentifier(for: indexPath)!
var snapshot = dataSource.snapshot()
item.isSelected = !item.isSelected
snapshot.reloadItems([item])
dataSource.apply(snapshot)
}
I found (via Swift Senpai) that the way you update these diffabledatasource depends on if your model is a class (pass by reference) or struct (pass by value). In the pass by reference you can take the item, update it, then reload the item:
// Model is a class compliant with Hasable and Equatable, name String property
guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return}
// modify item
selectedItem.name = "new name"
// update the snapshot
var newSnapShot = dataSource.snapshot()
newSnapshot.reloadItems([selectedItem])
dataSource.apply(newSnapshot)
So the above code will work with a model that is a class (the class needs to explicitly implement hast(into:) and ==(lhs:rhs:)).
On the other hand, a struct requires you to copy the item, update it, then insert the updated item and delete the old item from the snapshot.
// Model is a struct with name String property
guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return}
// update the item
var updatedSelectedItem = selectedItem
updatedSelectedItem.name = "new name"
// update snapshot
var newSnapShot = dataSource.snapshot()
newSnapshot.insertItems([updatedSelectedItem], beforeItem: selectedItem)
newSnapshot.deleteItems([selectedItem])
dataSource.apply(newSnapshot)
These worked for me.

Get model from UICollectionView indexpath

I'm using RxSwift to bind a model array to a collection view
How do I get the model object from a given indexPath?
I'm doing the binding like this:
vm.bikeIssueCatagories()
.drive(self.collectionView.rx.items(cellIdentifier: "BikeIssueCategoryCollectionViewCell", cellType: UICollectionViewCell.self)) { row, data, cell in
}.disposed(by: disposeBag)
The core of my issue is, that I need to get both the model object and the cell that a user selects. Using collectionView.rx.modelSelected(T.self) only gives me the model og type T. And calling collectionView.rx.itemSelected only gives me the selected IndexPath
collectionView.rx.itemSelected.asDriver()
.driveNext { [unowned self] indexPath in
guard let model = try? collectionView.rx.model(at: indexPath) else { return }
guard let cell = self.collectionView.cellForItem(at: indexPath) else { return }
}.disposed(by: disposeBag)
But this gives me an error when trying to the the model at indexPath:
Type 'inout UICollectionView' does not conform to protocol
'ReactiveCompatible'
Just trying:
let indexPath = IndexPath.init()
self.collectionView.rx.model(at: indexPath)
also gives me an error:
Ambiguous reference to member 'model(at:)'
SO... How to get both the model object and the cell that a user selects?
I could have done like RamwiseMatt proposed. Making a method on my ViewModel that takes the IndexPath and return the model. I did however end up using a zip:
let modelSelected = collectionView.rx.modelSelected(SelectableBikeIssueCategory.self).asObservable()
let cellSelected = collectionView.rx.itemSelected.asObservable()
.map { [weak self] indexPath -> UICollectionViewCell in
guard let cell = self?.collectionView.cellForItem(at: indexPath) else { fatalError("Expected cell at indexpath") }
return cell
}
Observable.zip(cellSelected, modelSelected)
.map { [weak self] cell, category -> SomeThing in
return SomeThing()
}
Your self-accepted solution is not optimal, because the modelSelected stream maps indexPath internally and needs to be used when there is no need to know about indexPath. In your case, it is better to use use itemSelected and convert to a tuple for example.
collectionView.rx.itemSelected
.map { [weak self] indexPath in
guard
let model = try? self?.collectionView.rx.model(at: indexPath) as YourModelName?,
let cell = self?.collectionView.cellForItem(at: indexPath)
else {
preconditionFailure("Describing of error")
}
return (cell, model)
}
Your ViewModel defines a method bikeIssueCatagories(), which is what your UIViewController binds the UICollectionView to. To get your model at the correct position, you can use the itemSelected property you mentioned, which gives you an Observable<IndexPath>, which you should feed into your ViewModel. There, you can use the IndexPath's item property to determine which model from your data array (given in bikeIssueCatagories()) is the one you are looking for. The modelSelected property makes this easier for you since it already knows the datasource, so all you have to provide is the type of your Model (replace T.self with YourModelName.self.)
I'm not sure why you want a reference to your cell as well, but if you must, you can just use cellForItem(at:) on your UICollectionView (feeding it the IndexPath you obtained via itemSelected.)

Array of observable element in RxSwift

I am new to RxSwift and working on some sample to test. I have show some data on uitableview with the help of RxSwift. However when I try to delete any item from tableview and reload. Observable array didn't update and due to this scrolling at last item crashes. Below is the code, please help me know what I am doing wrong.
self.itemArray = NSMutableArray(objects: "First Item","Second Item","Third Item","Fourth Item","Fifth Item","Sixth Item","Seventh Item","Eight Item","Nineth Item","Tenth Item","Eleventh Item","Twelveth Item","Thirtheenth Item","Fourtheenth Item","Fifteenth Item","Sixteenth Item","Seventhenth Item","First Item","Second Item","Third Item","Fourth Item","Fifth Item","Sixth Item","Seventh Item","Eight Item","Nineth Item","Tenth Item","Eleventh Item","Twelveth Item","Thirtheenth Item","Fourtheenth Item","Fifteenth Item","Sixteenth Item","Seventhenth Item")
var seqValue = Observable.just(self.itemArray)
seqValue
.bind(to: rxtableView.rx.items) { (tableView, row, seqValue) in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell")!
cell.textLabel?.text = self.itemArray.object(at: row) as? String
cell.backgroundColor = .clear
return cell
}.disposed(by: disposeBag)
self.rxtableView.rx.itemDeleted
.subscribe(onNext: { [unowned self]indexPath in
self.itemArray.removeObject(at: indexPath.row)
seqValue = Observable.just(self.itemArray)
self.rxtableView.reloadData()
}).addDisposableTo(disposeBag)
make it BehaviorSubject, i.e. -
var seqValue = BehaviorSuject.create(self.itemArray)
and instead of 'seqValue = Observable.just(self.itemArray)' do
seqValue.onNext(self.itemArray)
calling '.reloadData()' is not necessary
I think what you want is RxSwift's Variable:
let seqValue = Variable<String>(["First Item", ...])
so you in your .itemDeleted you can modify that variable:
seqValue.value.remove(at: indexPath.row)
Variable is an Observable, so changes are propagated automatically. No need to call reloadData().

It throws an error "Index out of range" when I try to remove a value from dictionary

I'm newbie programmer, and not talented yet, I stucked in an error and I can't handle it for days. I have problem with indexpath, when I try to remove something from Dictionary it throws an error, “fatal error: Index out of range“
Briefly, I showed my dictionary and tableview in the below, and numberofrows and members may increase or decrease, I’m adding these members to dictionary when I checked one of them on tableview, and it adds selected one without any problem. But problem begins when I remove one of my selected ones, I think the cause is index numbers, because their index numbers are changing when they included to dictionary. I want to remove exact person that I select, but how it's going to be possible?
For example, when I remove by unchecking person3, there must only person1 and person5 in dictionary. If the code and error is not clear, please let me know.
this is how looks of my tableview, members list;
1. person1 ✔️ // -> index 0
2. person2
3. person3 ✔️ // -> index 2
4. person4
5. person5 ✔️ // -> index 4
selected ones added to dictionary
var selectedUser = [NSDictionary?]()
var friendsDic = [NSDictionary?]()
// Inside of selectedUser after the adding members.
[{
email = “person1#gmail.com";
id = jqvDUcBoV9Y4sxir5kR0dg1; // person1 index is still 0
name = person1;
profileImageUrl = "<null>";
}, {
email = “person5#gmail.com"; // Now person5 index is 1
id = hVfS9EU6LWatAynJNyDmum4A2;
name = person5;
profileImageUrl = "https://firebasestorage.googleapis.com/v0/b/findyourfrienddemo.appspot.com/o/profile_images%2FAE5ED4E4-CF53-4AC4B628.jpg?alt=media&token=eb969e-57290d7e02c9";
}, {
email = “person3#gmail.com"; // and person3 index is 2
id = 6LwvafOJLSQsbhJwu8MJjYI3;
name = person3;
profileImageUrl = "<null>";
}]
And this is my whole code:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let cell = tableView.cellForRow(at: indexPath)
let friendUser: NSDictionary?
friendUser = friendsDic[indexPath.row]
if cell!.isSelected {
cell!.isSelected = false
if cell!.accessoryType == UITableViewCellAccessoryType.none {
cell!.accessoryType = UITableViewCellAccessoryType.checkmark
}
else {
cell!.accessoryType = UITableViewCellAccessoryType.none
}
}
if cell!.accessoryType == UITableViewCellAccessoryType.checkmark {
let named = friendUser?["name"] as! String
let urlImg = friendUser?["profileImageUrl"] as? String
let id = friendUser?["id"] as! String
let email = friendUser?["email"] as! String
let dicc: [String: String?] = ["name": named, "email": email, "id": id, "profileImageUrl": urlImg]
databaseRef.child("selectedfriends").child(id).child(CurrentUserID!).setValue(true)
selectedUser.append(dicc as NSDictionary)
MapPage.selectedUsersInfo = selectedUser as! [NSDictionary]
}
if cell!.accessoryType == UITableViewCellAccessoryType.none {
let id = friendUser?["id"] as! String
databaseRef.child("selectedfriends").child(id).child(CurrentUserID!).setValue(false)
var i = indexPath.row
selectedUser.remove(at: i)
MapPage.selectedUsersInfo = selectedUser as! [NSDictionary]
}
}
The code you posted does not make sense. It looks like you have a data structure friendsDic that is an array of dictionaries containing an entry for every cell in your table view.
When the user selects a cell you extract the fields from that cell's friendsDic entry and use those fields to build a NEW dictionary that you append to the end of an array selectedUser.
When the user deselects a cell you try to use the row of the cell in the table view to remove that entry from selectedUser, but the selectedUser array's elements will not be in any particular order (They'll be in the order in which the user checked them.) Even if they do happen to be in the same order as the entries in the friendsDic array, the array indexes won't match up because the non-selected items from friendsDic won't be in the selectedUser array. That's why you are getting index out of range errors.
It looks to me like your design is flawed and needs to be reworked.
You need to step back and explain what you're trying to do (app function) at a higher level, and then logically how you have attempted to implement that functionality.
EDIT:
Please stop describing your data model as a dictionary. It's not. You have an array. The elements of the array are dictionaries. As Hamish said in his comment, an array of structs would be a better choice, but you could make an array of dictionaries work.
I suggest not trying to maintain a second array of selected items. You can set up the table view to support multiple selection, and then use the method indexPathsForSelectedRows to get an array of index paths of the selected rows.

Mapping between array of enums and a dictionary of enums

Here's what I'm trying to do:
Provide a tableView that allows the user to select a formula name (an enum) that will be set as their default
In this view I'll place a checkmark next to the formula that is the current default
Their chosen default formula is what will determine which formula is used to calculate a 1 rep maximum amount (the theoretical amount the user could lift based on actual lifts of lesser weight)
After calculating the value, the user will be able to save a record of the lifts they did, the formula used to calculate the maximum, and the calculated maximum weight.
That record will be saved in Core Data with one of the entities being Lift which will simply be the formula name
I've created a class that I want to handle the work of providing the current default formula to what ever part of the app needs it as well as set the default when a new one is selected.
Here is my enum of the formula names:
enum CalculationFormula: Int {
case Epley
case Brzychi
case Baechle
case Lander
case Lombardi
case MayhewEtAl
case OConnerEtAl
}
and with help from the SO community, I created this class to handle the management of userDefaults:
class UserDefaultsManager: NSUserDefaults {
let formulas = [CalculationFormula.Baechle, CalculationFormula.Brzychi, CalculationFormula.Epley, CalculationFormula.Lander, CalculationFormula.Lombardi, CalculationFormula.MayhewEtAl, CalculationFormula.OConnerEtAl]
let formulaNameDictionary: [CalculationFormula : String] =
[.Epley : "Epley", .Brzychi: "Brzychi", .Baechle: "Baechle", .Lander: "Lander", .Lombardi: "Lombardi", .MayhewEtAl: "Mayhew Et.Al.", .OConnerEtAl: "O'Conner Et.Al"]
let userDefaults = NSUserDefaults.standardUserDefaults()
func getPreferredFormula() -> CalculationFormula? {
guard NSUserDefaults.standardUserDefaults().dictionaryRepresentation().keys.contains("selectedFormula") else {
print("No value found")
return nil
}
guard let preferredFormula = CalculationFormula(rawValue: NSUserDefaults.standardUserDefaults().integerForKey("selectedFormula")) else {
print("Wrong value found")
return nil
}
return preferredFormula
}
func setPreferredFormula(formula: CalculationFormula) {
NSUserDefaults.standardUserDefaults().setInteger(formula.rawValue, forKey: "selectedFormula")
}
You can see I have an array of the enums in the order I want them displayed in the tableView and a dictionary of the enums so I can get each enum's string representation to display in each cell of the tableView. Here's how I populate the cell text label which works:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("formulasCell", forIndexPath: indexPath)
let currentFormula = formulas[indexPath.row].formulaName
cell.textLabel?.text = currentFormula
return cell
}
and here's where I'm setting the checkmark anytime a cell in the tableView is selected
func refresh() {
let preferredFormula = defaults.getPreferredFormula()
for index in 0 ... formulas.count {
let indexPath = NSIndexPath(forItem: index, inSection: 0)
if let cell = tableView.cellForRowAtIndexPath(indexPath) {
cell.accessoryType = preferredFormula == index ? .Checkmark : .None
}
}
}
As I mentioned at the beginning, I need to do many different things with this enum but to keep this question focused on one thing, I'll stay with this checkmark example which now doesn't work after creating my UserDefaultsManager class
The problem is obvious - preferredFormula is now an enum and I can't compare that to the index value which is an Int - but the solution is not. I could get the raw value of the enum but the rawValues aren't guaranteed to be in alignment with the cell indexPaths. Some ideas I've had are:
I could probably change the order of the enum cases so their raw values match the order I've put them in my formulas array, but that seems silly and unreliable
I could use the array index values but that seems equally silly and unreliable
If I just use the array, I don't have the string representations of the cases to display in the cells
It seems that using the array and dictionary together is a viable but the best I could come up with is maybe creating another dictionary that maps the enums to Ints but that would have the same issues I just listed.
Any guidance someone could provide would be greatly appreciated.
You seem to have made things a little more complicated than they need to be.
Firstly, you can use a String raw value for your enum and avoid the associated dictionary:
enum CalculationFormula: String {
case Epley = "Epley"
case Brzychi = "Brzychi"
case Baechle = "Baechle"
case Lander = "Lander"
case Lombardi = "Lombardi"
case MayhewEtAl = "Mayhew Et.Al."
case OConnerEtAl = "O'Conner Et.Al"
}
Second, Your UserDefaultsManager doesn't need to subclass NSUserDefaults, it is simply some utility functions. Also, you are doing a lot of checking in getPreferredFormula that you don't need to. I would suggest re-writing that class to use a computed property like so:
class UserDefaultsManager {
static let sharedInstance = UserDefaultsManager()
private var defaultsFormula: CalculationFormula?
var preferredFormula: CalculationFormula? {
get {
if self.defaultsFormula == nil {
let defaults = NSUserDefaults.standardUserDefaults()
if let defaultValue = defaults.objectForKey("selectedFormula") as? String {
self.defaultsFormula = CalculationFormula(rawValue: defaultValue)
}
}
return self.defaultsFormula
}
set {
self.defaultsFormula = newValue
let defaults = NSUserDefaults.standardUserDefaults()
if (self.defaultsFormula == nil) {
defaults.removeObjectForKey("selectedFormula")
} else {
defaults.setObject(self.defaultsFormula!.rawValue, forKey: "selectedFormula")
}
}
}
}
I have made the class a singleton; although this may have an impact on testability it simplifies issues that could arise if the default is changed in multiple places.
The appropriate place to set/clear the check mark is in cellForRowAtIndexPath so that cell reuse is handled appropriately. This code assumes that formulas is an array of CalculationFormula:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("formulasCell", forIndexPath: indexPath)
let currentFormula = formulas[indexPath.row]
cell.textLabel?.text = currentFormula.rawValue
let preferredFormula = UserDefaultsManager.sharedInstance.preferredFormula
cell.accessoryType = currentForumula == preferredFormula ? .Checkmark : .None
return cell
}

Resources