I have a collection view in which each cell possess the ability to be interacted with by the user. Each cell has a like button and a number of likes label. When the button is pressed, the button should turn cyan, and the label (which holds the number of likes) should increment. This setup currently works. However, when I scroll through the collection view and scroll back, the button reverts to its original color (white) and the label decrements down to its original value. I have heard of an ostensibly helpful method called prepareForReuse(), but perhaps I'm not using it correctly. Here is my code:
Here is the array which holds all the cells
var objects = [LikableObject]()
Here is the class definition for these objects
class LikableObject {
var numOfLikes: Int?
var isLikedByUser: Bool?
init(numOfLikes: Int, isLikedByUser: Bool) {
self.numOfLikes = numOfLikes
self.isLikedByUser = isLikedByUser
}
}
Mind you, there is more functionality present in this object, but they are irrelevant for the purposes of this question. One important thing to be noted is that the data for each cell are grabbed using an API. I'm using Alamofire to make requests to an API that will bring back the information for the numOfLikes and isLikedByUser properties for each cell.
Here is how I load up each cell using the collection view's delegate method:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ObjectCell", for: indexPath) as! ObjectCell
cell.configureCell(
isLikedByUser: objects[indexPath.row].isLikedByUser!,
numOfLikes: objects[indexPath.row].numOfLikes!,
)
return cell
}
The ObjectCell class has these three fields:
var isLikedByUser: Bool?
#IBOutlet weak var numOfLikes: UILabel!
#IBOutlet weak var likeBtn: UIButton!
And that configureCell() method, which belongs to the cell class, is here:
public func configureCell(numOfLikes: Int, isLikedByUser: Bool) {
self.isLikedByUser = isLikedByUser
self.numOfLikes.text = String(numOfLikes)
if isLikedByUser {
self.likeBtn.setFATitleColor(color: UIColor.cyan, forState: .normal)
} else {
self.likeBtn.setFATitleColor(color: UIColor.white, forState: .normal)
}
}
And lastly, the prepareForReuse() method is here:
override func prepareForReuse() {
if isLikedByUser! {
self.likeBtn.setTitleColor(UIColor.cyan, for: .normal)
} else {
self.likeBtn.setTitleColor(UIColor.white, for: .normal)
}
}
This doesn't work. And even if it did, I still don't know a way to keep the numOfLikes label from decrementing, or if it should anyway. I'm speculating that a big part of this problem is that I'm not using the prepareForReuse() method correctly... Any help is appreciated, thank you.
prepareForReuse is not the place to modify the cell, as the name states, you "only" have to prepare it for reuse. if you changed something (for example isHidden property of a view), you just have to change them back to initial state.
What you should do though, you can implement didSet for isLikedByUser inside the cell, and apply your modifications to likeBtn in there. (this is of-course the fast solution)
Long solution: It's an anti-pattern that your cell has a property named isLikedByUser, TableViewCell is a View and in all architectures, Views should be as dumb as they can about business logic. the right way is to apply these modifications in configure-cell method which is implemented in ViewController.
If you feel you'll reuse this cell in different viewControllers a lot, at least defined it by a protocol and talk to your cell through that protocol. This way you'll have a more reusable and maintainable code.
Currently all of this is good , the only missing part is cell reusing , you have to reflect the changes in the number of likes to your model array
class ObjectCell:UICollectionViewCell {
var myObject:LikableObject!
}
In cellForRowAt
cell.myObject = objects[indexPath.row]
Now inside cell custom class you have the object reflect any change to it , sure you can use delegate / callback or any observation technique
The prepareForResuse isn't needed here.
You do need to update the model underlying the tableview. One way to verify this is with mock data that is pre-liked and see if that data displays properly.
Related
I am trying to implement MVVM pattern without 3-d party libraries for data binding.
I have a UICollectionView with a StoriesViewModel.
StoriesViewModel is a class which holds an array of models.
To each cell I assign a respective ViewModel which I create in a cellForItemAt method, like this:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.storiesCollectionViewCell, for: indexPath) as! StoriesCollectionViewCell
let story = viewModel.getStory(for: indexPath)
let storiesCellViewModel = StoriesCellViewModel(story: story)
cell.viewModel = storiesCellViewModel
cell.setup()
return cell
}
Cells are dequeueReusable and images are being reused, which I can't fix with simple line imageView.image = nil at the start of cellForItemAt method, which worked for me previously when I used MVC. I guess it's something with assigning viewModels to cells.
Cell code (without views and constraints):
class StoriesCollectionViewCell: UICollectionViewCell {
//
var viewModel: StoriesCellViewModel?
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func commonInit() {
//
setupViews()
}
private func setupViews() {
//
}
func setup() {
storyImageView.image = nil
viewModel?.fetchImageData(completion: { (imageData) in
DispatchQueue.main.async {
self.storyImageView.image = UIImage(data: imageData)
}
})
titleLabel.text = viewModel?.title
authorLabel.text = viewModel?.author
brandNameLabel.text = viewModel?.brandName
}
}
I also tried viewModel = nil in prepareForReuse method, and it didn't work. Is that a common problem in approaching MVVM pattern and what is the way to fix it? Thanks in advance.
What I suspect is going on is that by the time the image is loaded to be displayed in your cell, the cell itself might have already been dequeued to be reused for another row of the collection view.
This is a common issue that can occur when you're using reusable cells.
One way to solve this might be to add a check when the image data is finally loaded, to make sure you're still dealing with the same cell as before (i.e.: still configured with the same view model that initiated the image load).
Maybe you can use an unique identifier for each view model to do this:
viewModel?.fetchImageData(completion: { (imageData) in
DispatchQueue.main.async {
// Check here if it still makes sense to attribute this image to this cell
self.storyImageView.image = UIImage(data: imageData)
}
})
In general you shouldn't store specific image in a non-specific cell, i.e.; a reusable cell.
Because by the time your async fetch returns, that cell may already be reused to display another image.
The usual approach is to create a separate cache, which is updated by async fetch, followed by a table view reload in which indexPath.row is used as array index to access the cache.
This is pretty straight-forward in Apple MVC.
So the problem now is how to implement it in MVVM.
I'd say there's no benefit to using view model for reusable cells. You are just making things difficult for yourself.
But no one dares to challenge MVVM these days, it seems. Devs always blame themselves for not fully grasping the power of MVVM. No, challenge it. MVVM does not magically solve everything.
That being said, I'll attempt answering your question.
self.storyImageView.image = UIImage(data: imageData)
You store a specific image in a non-specific reusable (variable) cell.
If you call fetch in cell view model, then it needs a callback to pass that image to view controller which then triggers a table view reload. You should avoid capturing cell references and make run-time condition checks. The safest bet is to prepare data and trigger SDK to do the rest.
In fact, I would argue that view model is about updating view whenever model changes. But this requires proper binding, which is lacking in UIKit. So all these efforts are just so that you can do networking which produces side effects in callback and indirectly trigger a view update.
You may verify this by not calling fetch in cell view model. Then your view model becomes useless. MVVM becomes MVC with extra steps. There's no point passing a view model to setup cell properties when those properties are public and meant to be setup in cellForRow: by SDK design.
I would like to know if it is "correct" to store a reference to the Model that a UITableViewCell represents in it.
The reason I ask is due to the necessity of knowing the Model in case of a click action in a button inside it.
Is there a better (a.k.a: desirable) way of doing this?
Example:
class Person {
var name: String
var lastName: String
var age: Int
}
protocol PersonCellDelegate: NSObjectProtocol {
// should the second parameter be the model that the cell represents?
func songCell(_ cell: PersonCell, didClickAtEditButtonOfPerson person: Person)
}
class PersonCell: UITableViewCell {
#IBOutlet private weak var nameLabel: UILabel!
#IBOutlet private weak var lastNameLabel: UILabel!
#IBOutlet private weak var ageLabel: UILabel!
#IBOutlet private weak var editButton: UIButton!
// does the cell need to store its reference?
var person: Person! {
didSet {
nameLabel.text = person.name
// ...
}
}
weak var delegate: PersonCellDelegate?
// ...
}
A table view cell is a view. Less it knows about the application logic, better it is.
You could retrieve the entity used using the indexPath(for:) method :
protocol MyTableViewCellDelegate: AnyObject {
func myTableViewCellDidSomething(_ cell: MyTableViewCell)
}
class MyTableViewCell: UITableViewCell {
weak var delegate: MyTableViewCellDelegate?
}
class ViewController: UITableViewController, MyTableViewCellDelegate {
var personList: [Person] = []
func myTableViewCellDidSomething(_ cell: MyTableViewCell) {
guard let indexPath = tableView.indexPath(for: cell) else { return }
let person = personList[indexPath.row]
// ...
}
}
You ask:
does the cell need to store its reference?
No. In fact, that locks you into reference semantics and you might consider value semantics for the Person object. I also think it muddies the ownership model. Who now owns this Person object?
And even if you were committed to reference semantics, and wanted to use this pattern to detect Person changes, be wary that your didSet pattern is only half of the solution. The Person type is mutable and you’re detecting when the object is replaced with a new Person object, but not when the individual properties of Person change. If you’re going to go down this didSet road with mutable reference types, you might also want to add KVO for the relevant properties, too.
This pattern entails a fairly tight coupling of view objects and model objects. As others have suggested, you might consider other patterns for addressing this and/or reducing the burden on the view controller.
If you’re looking for automatic updating of the cell when the Person object mutates (and potentially vice versa), you can consider binding patterns, such as offered by libraries like RxSwift, Bond, etc.
I’d also refer you to Dave Delong’s presentation A Better MVC, which walks you through considerations if you don’t want to give up on MVC, but figure out ways to work with it, or Medium’s iOS Architecture Patterns, which is an introduction to other options.
In strict MVC, view should not access model directly.
When the user click the button in the cell, call delegate method. Let the delegate (usually is view controller) handle the click event (like modify model).
After updating the model, controller will update the view if needed.
I do use it in some cases, especially as you do here, in a custom cell.
I don't rely on the UItableViewCell to hold the data for me, that I have in the model, as the cell can be reused, when it is off screen
It depends:
If you can move cells in the table view (manually or by pressing a button) using insertRows and deleteRows then it's almost the only way (along with protocol/delegate) to be able to get the index path of a cell efficiently without reloading the entire table view.
In a straight table view where no cells are moved don't pass the model to the cell. You can use callback closures which capture the index path and even the model item.
So, there isn't one right way, so I can just tell you would I would do.
If I didn't move cells, I would keep having model property. In cell class you shouldn't set properties of outlets, since cells are reusable. You just let controller know that data source is changed and you should reload rows/certain row.
var person: Person! // instead, tell controller, that person has been changed
Next, I would left delegate pattern and I would use closure variables. It makes code more Swifty (in future you can search for RxSwift).
class PersonCell: UITableViewCell {
var personChanged: (Person) -> Void = { _ in }
var person: Person!
func foo() {
// change person's properties
personChanged(person)
}
func setCell() {
nameLabel.text = person.name
}
}
Then set all the things such as label's text in cellForRowAt UITableViewDelegate's method. Also don't forget to set cell's closure and declare what should happen after person is changed
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = // ...
cell.person = people[indexPath.row]
cell.setCell()
cell.personChanged = { [weak self] person in
guard let self = self else { return }
self.people[indexPath.row] = person
self.tableView.reloadRows(at: [indexPath], with: .automatic)
}
return cell
}
The problem
When scrolling up and down in my (programmatically) created collectionView the cells doesn't seem to dequeued properly. This is resulting in duplication of it contents.
Video
Bug replication
Wished behaviour
I wish that the cells correctly getting dequeued and that the content does not get duplicated.
Code snippet
Code snippets are provided via Pastebin below. I had to add some code to satisfy the markdown editor here on SO...
open class CollectionDataSource<Provider: CollectionDataProviderProtocol, Cell: UICollectionViewCell>: NSObject, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout where Cell: ConfigurableCell, Provider.T == Cell.T {
https://pastebin.com/CzHYxTDD
class ProductCell: UICollectionViewCell, ConfigurableCell {
}
https://pastebin.com/9Nkr3s4B
If anything else is need, please ask in the comments.
Each time you call
func configure(_ item: ProductViewModel, at indexPath: IndexPath) {
setupProductImage(with: item.productImage)
setupStackView()
setupProductLines(with: item.productLines)
}
You create new instance productLineLabel = UILabel() inside setupProductLines() and add it to the stackView
You should change this behavior or rather clear the stack view in prepareForReuse method.
Keep in mind, that addArrangedSubview increases suviews retain count for newly added elements. If you stop your applications execution using Debug View Hierarchy button (fig 1), most likely you will see more labels than you expect in the cell.
fig 1.
The problem
Each time I call:
func configure(_ item: ProductViewModel, at indexPath: IndexPath) {
setupProductImage(with: item.productImage)
setupStackView()
setupProductLines(with: item.productLines)
}
I create a new instance of productLineLabel = UILabel()
Therefore it will be duplicated each time the configure(_ item:) is being called from the cellForRowAtIndexPath.
The solution
I used prepareForReuse recommended by llb to remove the subviews that were kind of class UIStackview (containing UILabels). I wrote the following extension to make this less tedious:
func addSubviews(with subviews: [UIView], in parent: UIView) {
subviews.forEach { parent.addSubview($0) }
}
The implementation
The only thing what was left to do was calling the custom extension function from prepareForReuse like so:
override func prepareForReuse() {
let foundStackView = subviews.filter({$0.isKind(of: UIStackView.self)})[0] as? UIStackView
guard let labels = foundStackView?.arrangedSubviews.filter({$0.isKind(of: UILabel.self)}) else { return }
foundStackView?.removeArrangedSubviews(labels, shouldRemoveFromSuperview: true)
}
Credits go to llb, see comments below! <3 Thanks.
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'm trying to activate a function inside my custom cell by setting the value of a boolean inside the custom cell class. This is my best attempt at doing this:
func blurViewActive(gestureRecognizer:UIGestureRecognizer) {
if (gestureRecognizer.state == UIGestureRecognizerState.Began) {
println("STATE BEGAN")
var point = gestureRecognizer.locationInView(self.tv)
if let indexPath = self.tv.indexPathForRowAtPoint(point) {
let data = messageList[indexPath.row] as Messages
let mcell: TableViewCell = self.tv.dequeueReusableCellWithIdentifier("cell") as TableViewCell
mcell.read = true
}
}
}
but this doesn't work, and I really have no idea how to do this any other way.
Here is the code for my custom cell class:
class TableViewCell: UITableViewCell, UIGestureRecognizerDelegate {
#IBOutlet weak var labelOutl: UILabel!
var timer = NSTimer()
var counter = 10
var read = Bool()
#IBOutlet weak var dateLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
if read == true{
println("hello")
}
}
override func setSelected(selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
}
func timerStarted(){
timer = NSTimer.scheduledTimerWithTimeInterval(1, target: self, selector: "update", userInfo: nil, repeats: true)
}
func update(){
println(--counter)
}
}
My expected outcome is that once read has been set to "true" in my view controller, the function inside awakeFromNib-function should be executed instantaneously.
There seems to be a number of points of confusion here.
dequeueReusableCellWithIdentifier returns a cell for use in the table view it is called on. This will be either a newly instantiated cell or an existing cell not currently displayed in any of the table's visible rows. Setting the read property on this cell will therefore have no immediate visible effect.
If you want access to a visible cell you could use cellForRowAtIndexPath but even then changes made to that cell will not necessarily update the UI. Instead you probably want to update whatever model backs that cell and call reloadRowsAtIndexPaths to update a specific cell.
Additionally awakeFromNib will be called only when a new cell is created. That will be before it is returned from dequeueReusableCellWithIdentifier and therefore well before you can take any action on it, like setting its read property. It will also not be called once per row in your table or per displayed row since you are using a reuse identifier. Instead the table view will create at least one cell for each visible row and reuse them as row scroll into and out of sight. This is convenient because minimizing the number of objects created helps reduce memory use and reduces load which could slow down scrolling performance. However it means that your data source needs to be prepared to update these cells as they are reused from one row to the next.