What is "stable identifier" for SwiftUI Cell? - uitableview

While listening Use SwiftUI with UIKit (16:54), I heard, that the reporter said:
"When defining swipe actions, make sure your buttons perform their actions using a stable identifier for the item represented.
Do not use the index path, as it may change while the cell is visible, causing the swipe actions to act on the wrong item."
- what??? All these years I was fighting with prepareForReuse() and indexPath in cells trying to somehow fix bugs related to cell reusing.
What is this "stable identifier"?
Why does no one talk about it?
On stackoverflow you can find answers only related to prepareForReuse() function. No "stable identifier".
Is it reuseIdentifier?
If so, how I suppose to use it?
Creating for each cell its own reuseIdentifier, like this:
for index in 0..<dataSourceArray.count {
tableView.register(MyTableViewCell.self, forCellReuseIdentifier: "ReuseIdForMyCell" + "\(index)")
}

She made a mistake, if you download the sample associated with the video you'll see the deleteHandler captures the item from outside already, it doesn't look it up again when the handler is invoked. She was trying to say that if you look up an item in the handler then there is a chance if other rows have been added or removed then its index will have changed so if you use the rows old index to look up the item then you would delete the wrong one. But since no lookup is required that will never happen, so she shouldn't have even mentioned it. Here is the code in question:
// Configures a list cell to display a medical condition.
private func configureMedicalConditionCell(_ cell: UICollectionViewListCell, for item: MedicalCondition) {
cell.accessories = [.delete()]
// Create a handler to execute when the cell's delete swipe action button is triggered.
let deleteHandler: () -> Void = { [weak self] in
// Make sure to use the item itself (or its stable identifier) in any escaping closures, such as
// this delete handler. Do not capture the index path of the cell, as a cell's index path will
// change when other items before it are inserted or deleted, leaving the closure with a stale
// index path that will cause the wrong item to be deleted!
self?.dataStore.delete(item)
}
// Configure the cell with a UIHostingConfiguration inside the cell's configurationUpdateHandler so
// that the SwiftUI content in the cell can change based on whether the cell is editing. This handler
// is executed before the cell first becomes visible, and anytime the cell's state changes.
cell.configurationUpdateHandler = { cell, state in
cell.contentConfiguration = UIHostingConfiguration {
MedicalConditionView(condition: item, isEditable: state.isEditing)
.swipeActions(edge: .trailing) {
Button(role: .destructive, action: deleteHandler) {
Label("Delete", systemImage: "trash")
}
}
}
}
}

Related

How to reload UITableView by use RxDataSources correctly?

I'm trying to build a tableView witch has many cells with a button, what I want to do is when I click the button in a cell, the cell should be go to the bottom of the table, here's my code:
let datasource = RxTableViewSectionedAnimatedDataSource<ToDoListSection>(
configureCell: { [weak self] _, tableView, indexPath, item in
guard let self = self else { return UITableViewCell() }
let cell = tableView.dequeueReusableCell(withIdentifier: ToDoTableViewCell.reuseID, for: indexPath) as? ToDoTableViewCell
cell?.todoTextView.text = item.text
cell?.checkBox.setSelect(item.isSelected)
cell?.checkBox.checkBoxSelectCallBack = { selected in
if selected {
var removed = self.datasList[indexPath.section].items.remove(at: indexPath.row)
removed.isSelected = selected
self.datasList[indexPath.section].items.append(removed)
self.datasList[indexPath.section] = ToDoListSection(
original: self.datasList[indexPath.section],
items: self.datasList[indexPath.section].items
)
self.sections.onNext(datasList)
} else {
// Todo
}
}
return cell ?? UITableViewCell()
}, titleForHeaderInSection: { dataSource, section in
return dataSource[section].header
})
sections.bind(to: table.rx.items(dataSource: datasource))
.disposed(by: disposeBag)
however, because I sent a onNext event in the configureCell closure, I received a waring:
⚠️ Reentrancy anomaly was detected.
Debugging: To debug this issue you can set a breakpoint in /Users/me/Desktop/MyProject/Pods/RxSwift/RxSwift/Rx.swift:96 and observe the call stack.
Problem: This behavior is breaking the observable sequence grammar. next (error | completed)?
This behavior breaks the grammar because there is overlapping between sequence events.
Observable sequence is trying to send an event before sending of previous event has finished.
Interpretation: This could mean that there is some kind of unexpected cyclic dependency in your code,
or that the system is not behaving in the expected way.
Remedy: If this is the expected behavior this message can be suppressed by adding .observe(on:MainScheduler.asyncInstance)
or by enqueuing sequence events in some other way.
and the action on the screen is not I want.
what should I do?
how to reload the TableView correctly?
The fundamental problem here is that you are calling onNext inside an Observer that is observing the emission. Another way to word this is that you are emitting a new value before the system is finished processing the current value.
As the warning says, the simplest way to deal with this (and likely the best way in this case) is to insert .observe(on:MainScheduler.asyncInstance) between sections. and bind(to:). What this does is stall the emission for a cycle so your configureCell function has a chance to return.

Having some weird animation bug using TrailingSwipeActionsConfigurationForRowAt to delete cells

I'm trying to simply delete cells with this method of tableView. For some reason, doing so creates this weird animation bug that I can't figure out. Here is my code:
let deleteSubcatAction = UIContextualAction(style: .destructive, title: "Delete") { (action, view, handler) in
print("Delete Subcategory Action Tapped")
let subcategory = Global.budget.categories[indexPath.section].subcategories[indexPath.row]
Global.budget.categories[indexPath.section].subcategories.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .left) }
deleteSubcatAction.backgroundColor = .red
let rowConfig = UISwipeActionsConfiguration(actions: [deleteSubcatAction])
return rowConfig
When I drag the cell to the left, I see the delete action as I expect and pressing the action calls the method as it should. However, the cell isn't removed from the table. Instead, the actions stay locked in place and the rest of the content of the cell slides back to the right as if I had released my finger without pressing an action. Then, if I tap elsewhere in my app, the actions minimize back to the right, and the cell simply disappears without any sort of removal animation. Here's a video of the bug I recorded. What's going on here?
First thing that pops out is that you aren't calling the completion handler (in your case, the handler property) at the end of your UIContextualAction closure. As in, handler(true).
The second thing is that you aren't doing anything to ensure that the action is properly animated in the table view. The non-intrusive way of doing this (without reloading the entire table view from the data source) is by using:
tableView.beginUpdates()
tableview.endUpdates()
See the API docs for more info.
P.S. Ideally, you should have your data source hooked up to your table view in such a way that the table view reacts to events triggered by observers of your data source, rather than deleting table rows "manually" in response to a UI action.

RxSwift - auto reloading UITableView initialized with Observable

I have main UITableView with some Todos that are populated used an array of Observables:
// on viewDidLoad
self.todosViewModel.todos.asObservable()
.bind(to: tableView.rx.items(cellIdentifier: "TodoViewCell", cellType: TodoTableViewCell.self)) { (row, todo, cell) in
cell.status.image = todo.getStatusImage()
cell.title.text = todo.title.value
cell.todoDescription.text = todo.description.value
cell.dueDate.text = String(describing: Utilities.dateFormatter.string(from: todo.dueDate.value))
}.disposed(by: disposeBag)
On another screen I can add/edit the data and then, click "save" button to append a new todo or modify the one being edited. This works great, except that the tableView don't reload automatically, forcing me to call tableView.reloadData() on viewDidAppear, which is triggered when the other screen is dismissed.
So,
Is there a native way for me to reload a table automatically when then todosViewModel variable is updated?
EDIT:
If on the screen of edition of the todo I reassociate the todosViewModel's value property with the same value, it also works:
self.todosViewModel.todos.value = self.todosViewModel.todos.value
That's pretty ugly, but I only know how to reload a tableView using one of those ways.
When I append a new todo or reassociate a new value to the base todos it works. When I edit, no.
That's the whole point. For the UITableView to update, your todos have to emit when an item inside has been edited or you have to bind something from your todo within the cell (this way you cannot change hight of the cell but you can push some dynamic information onto the cell).
Another option would be to map some observable from todo to the index of the cell where it's presented and call UITableView.reloadRows to update your edited cell.
I would recommend you to take a look at RxDataSources examples.

UICollectionView state restoration: restore all UICollectionViewCells

I searched a lot through Google and SO, so please forgive me, if this question has already been answered!
The problem:
I have a UICollectionView with n UICollectionViewCells. Each cell contains a UIView from a XIB file. The Views are used for data entry, so all cells have a unique reuseIdentifier. Each View has also a unique restorationIdentifier. Everything works in normal usage, but not when it comes to state restoration:
The first 3 or 4 cells are getting restored properly because they are visible on the screen on startup, but the remaining cells, which are not visble, are not getting restored.
Current solution:
So I've discovered so far that a View is only restored if it's added to userinterface at startup.
My current working solution is to set the height of all cells to 1 in the process of restoring. Now every cell is loaded and all views are restored.
When applicationFinishedRestoringState() is called, I reload the CollectionView with the correct height.
Now my question is: I'm not happy with this solution, is there a more clean way to achieve restoring of all the UIViews?
I think you are getting a bit confused between your data model and your views. When first initialised, your table view is constructed from a data model, pulling in stored values in order to populate whatever is in each cell. However, your user does not interact directly with the data model, but with the view on the screen. If the user changes something in the table view, you need to signal that change back up to the view controller so that it can record the change to the data model. This means in turn that if the view needs to be recreated the view controller has the information it needs to rebuild whatever was in the table when your app entered the background.
I have put together a simple gitHub repository here: https://github.com/mpj-chandler/StateManagementDemo
This comprises a CustomTableViewController class which manages a standard UITableView populated with CustomTableViewCells. The custom cells contain three switch buttons, allowing the state of each cell to be represented by an array of Boolean values.
I created a delegate protocol for the cells such that if any of the switches is tripped, a signal is sent back to the view controller:
protocol CustomTableViewCellDelegate {
func stateDidChange(sender: CustomTableViewCell) -> Void
}
// Code in CustomTableViewCell.swift:
#objc fileprivate func switched(sender: UISwitch) -> Void {
guard let index : Int = switches.index(of: sender) else { return }
state[index] = sender.isOn
}
// The cell's state is an observed parameter with the following didSet method:
fileprivate var state : [Bool] = Array(repeating: false, count: 3) {
didSet {
if state != oldValue, let _ = delegate {
delegate!.stateDidChange(sender: self)
}
}
}
CustomTableViewController is registered to the CustomTableViewCellDelegate protocol, so that it can record the change in the model as follows:
// Code in CustomTableViewController.swift
//# MARK:- CustomTableViewCellDelegate methods
internal func stateDidChange(sender: CustomTableViewCell) -> Void {
guard let indexPath : IndexPath = tableView.indexPath(for: sender) else { return }
guard indexPath.row < model.count else { print("Error in \(#function) - cell index larger than model size!") ; return }
print("CHANGING MODEL ROW [\(indexPath.row)] TO: \(sender.getState())")
model[indexPath.row] = sender.getState()
}
You can see here that the function is set up to output model changes to the console.
If you run the project in simulator and exit to the home screen and go back again you will see the state of the tableView cells is preserved, because the model reflects the changes that were made before the app entered the background.
Hope that helps.

Pass the result to a Button within a UITableViewCell after executing an on tap closure?

I'm looking for any possible way of passing the result of an networking update to a button in a UITableViewCell as a closure.
I have some UITableViewCells that are products. In these cells, I have an 'Add to Cart' button. I set a buttonTap closure in my UITableViewCell cellforRowAtIndexPath method, setup a touch handler within the cell for the button, and when that handler is called, execute the buttonTap closure. I handle my cart updating on a cart object which lives on the main controller.
The result of the cart update action returns true if they can add more items to their cart. Then, I update the button accordingly. I like this approach because I don't have to deal with delegates and I can keep all of the cart logic itself far far away from the cell; the cell just knows how to make a button enabled/disabled/loading/etc.
/// Buttom tap callback callback.
public typealias Selection = () -> Bool
class MealTableViewCell: UITableViewCell {
var buttonTap: Selection?
// Runs when tapping the button
func didTapAdd() {
if let buttonBlock = buttonTap {
self.button.isLoading = true
// Simulate loading
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.button.enabled = buttonBlock()
self.button.isLoading = false
}
}
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = .... (fetch cell)
cell.buttonTap = {
// returns true if the user can add more items to their cart
return self.cart.update(product: product, quantity: 1)
}
}
My question is that currently, my cart is all local with no API calls. I'm currently switching the cart over to an API driven one, with network calls to add and remove items. That means I can no longer return a BOOL, or return at all, from my cart.update(product:,quantity:) method as it is now an async call.
So, I can do something like rewrite that method signature to be
self.cart.update(product: product, quantity: 1, success: { canAddMore in
// API call succeeded
}, failure: { error in
// fall failed
})
The question is that how can I pass canAddMore to the tableViewCell? If I redefine what Selection means to take in a block that takes a bool as a param, I can't pass that param in from the controller as it would only be passed in when the block is executed on the cell itself.
How can I do something like
cell.buttonTap = {
cell.buttonTap = {
self.cart.update(product: product, quantity: 1), success: { canAddMore in
// !!!! What can I call here to pass canAddMore to the cell.
}, failure: { error in
}
}
}
canAddMore can be any value really, a BOOL is just this example. My big goal is to avoid coupling any knowledge of the cell's loading and buttons to the controller itself. If I use delegates, I would have to have a two way delegate makes the cell a delegate of the controller, and I've always felt that's the sort of wrong direction to approach this. I'm not positive it's really possible to pass the result of a closure back to the cell, but I am hoping there is!
EDIT: The big question I'm really trying to answer is if it is at all possible to pass data back to the cell (or any object) that originally called closure through that closure. There's a million ways to do data modifications in a table view, but that's sort of the main thing I'm trying to address.
I'm also "looking" to avoid storing the canAddMore state (e.g. the quantity remaining for that product) in the main 'products' array that powers the tableview. The initial state is set there, returned from a /products endpoint, but after that, inventory being available or not is returned by the carts API action.
I don't think you want to do what you think you want to do :)
In a nutshell, instead of trying to "talk back" to the cell that called the closure, you probably want to track the "canAddMore" state of each product in your Products data array, and then update the table row(s) when the state changes. So...
User taps "Add to Cart"
Give visual feedback in that row to show that you are processing the tap (gray out the button, or show a spinner, whatever looks good)
Call back to the closure to start the Add-to-cart API call
When the API call returns, update your local Products data to indicate the "canAddMore" state
reload the row(s) in the table to update the Button (make it active, inactive, change the title, whatever)
You almost certainly need to be doing something similar anyway, so the Buttons in each row will be updated when the user scrolls and the cells are reused.
A general approach is to update the cell's content with tableView.reloadRows(at: [indexPath], with: true) in your callback. This will call func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell for the specified cell. Don't forget that this has to be done in the main queue.

Resources