I am creating a chat application where the main view is a tableView with each row representing a chat. The row has a label for the person's name as well as a label with the latest chat message. If a user taps a row, it will segue into a chat view. I am using MessageKit for the chat framework. All is working well except I am having trouble displaying the most recent chat consistently.
Here is how my tableView looks. Please ignore the black circles, I was quickly clearing out personal photos I was using for testing.
I have a custom tableViewCell with the method below. This creates an observer for this specific chat starting with the most recent message.
func getLatestChatMessage(with chatID: String) {
guard let loggedInUserID = Auth.auth().currentUser?.uid else { return }
DatabaseService.shared.chatsRef.child(chatID).queryLimited(toLast: 1).observe(.childAdded) { [weak self] (snapshot) in
guard let messageDict = snapshot.value as? [String:Any] else { return }
guard let chatText = messageDict["text"] as? String else { return }
guard let senderId = messageDict["senderId"] as? String else { return }
DispatchQueue.main.async {
if senderId == loggedInUserID {
self?.latestChatMessage.textColor = .black
} else {
self?.latestChatMessage.textColor = UIColor(red: 0/255, green: 122/255, blue: 255/255, alpha: 1)
}
self?.latestChatMessage.text = chatText
}
}
}
I call this function in the TableView's method cellForRowAt after observing the chatID for this user.
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
DatabaseService.shared.matchedChatIdRef.child(loggedInUserID).child(match.matchedUserId).observeSingleEvent(of: .value) { (snapshot) in
if let chatID = snapshot.value as? String {
cell.getLatestChatMessage(with: chatID)
}
}
}
Ideally this should create an observer for each chat and update the label every time there is a new message sent. The issue I am having is it sometimes works but other times seems to stop observing.
For example, if I am on the chat tableView and get a new message for one of the cells, it updates. However, if I then tap that chat to segue into a specific chat and send a message then tap the back button to go back to my tableView, the label stops updating. It appears as though my observer gets removed. Does anyone have any ideas why my observers in each cell stop working? Also, I am interested if anyone has a recommendation for a better solution to this? I am worried that creating an observer for each cell may be a poor solution.
EDIT: I've found that the observer stops working after selecting the back button or swiping back to the tableView from the chat in the screenshot below.
Woke up this morning and realized my issue...In the ChatViewController I was calling removeAllObservers()
I updated my code to below to just remove the observer in the chat and it fixed my issue:
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(true)
if let observerHandle = observerHandle, let chatID = chatId {
DatabaseService.shared.chatsRef.child(chatID).removeObserver(withHandle: observerHandle)
}
}
Related
I am building an app where a user can read articles. Each article is written by an author, so, on the article view, when the user clicks on the author's profile picture it navigates to the Author's profile view.
In the Author Profile view, there is a "Follow" button, and in the same view, there are the statistics of the Author (ex. how many articles he/she wrote, how many followers they have, etc.). Something very close to this:
When the author profile view loads, everything is OK. But as soon as the user touches the "Follow" button, the app crashes with the following error:
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Section identifier count does not match data source count. This is most likely due to a hashing issue with the identifiers.'
These are the steps I follow:
User in the Author Profile view
User sees the "Follow" button and the stats showing how many users are following the author
Say the author has 10 followers. The user touches the "Follow" button
Behind the scenes, I go into Firebase, get the author, and add the userID into an array of strings: followers: ["asdm132123", "asdadsa12931", "!123123jsdfjsf"] by using .observeSingleEvent() and setValue()
Then I read the data using the .observe() method of Firebase, to update the button state from "Follow" --> "Following" and increase the followers' value from "10" --> "11"
Edit: Code part:
enum AuthorProfileSection {
case details
case stats
case articles
}
Datasource creation:
func configureDataSource() -> UICollectionViewDiffableDataSource<AuthorProfileSection, AnyHashable> {
let dataSource = UICollectionViewDiffableDataSource<AuthorProfileSection, AnyHashable>(collectionView: collectionView) { (collectionView, indexPath, object) -> UICollectionViewCell? in
if let object = object as? Author {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AuthorDetailsCell.reuseIdentifier, for: indexPath) as! AuthorDetailsCell
cell.configure(with: object)
return cell
}
if let object = object as? AuthorStatsForAuthorProfile {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AuthorStatCell.reuseIdentifier, for: indexPath) as! AuthorStatCell
cell.configure(with: object)
return cell
}
if let object = object as? Article {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AuthorArticleCell.reuseIdentifier, for: indexPath) as! AuthorArticleCell
cell.configure(with: object)
return cell
}
return nil
}
return dataSource
}
Updating snapshot
fileprivate func updateSnapshot(animated: Bool = false) {
guard let authorUID = authorUID else { return }
Firebase.Database.database().fetchSelectedAuthorProfileData( authorUID: authorUID, fromArticleUID: self.articleUID) { authorProfileSection in
var snapshot = self.dataSource.snapshot()
// sections
snapshot.appendSections([.details, .stats, .articles])
snapshot.appendItems([authorProfileSection.details], toSection: .details)
snapshot.appendItems(authorProfileSection.stats ?? [], toSection: .stats)
snapshot.appendItems(authorProfileSection.articles ?? [], toSection: .articles)
self.dataSource.apply(snapshot, animatingDifferences: animated)
}
}
And the cell:
class AuthorStatCell: UICollectionViewCell, SelfConfiguringCell {
typealias someType = AuthorStatsForAuthorProfile
static var reuseIdentifier = "AuthorStatCell"
override init(frame: CGRect) {
super.init(frame: frame)
buildUI()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(with data: AuthorStatsForAuthorProfile) {
print(data)
}
fileprivate func buildUI() {
backgroundColor = UIColor(red: 0.20, green: 0.83, blue: 0.60, alpha: 1.00)
layer.cornerRadius = 15
}
}
And this is the result (in red the part that changes if I touch the "follow" button):
Here is where the app crashes. Any idea why?
I suspect, the issue is inside updateSnapshot() you are getting the current snapshot and adding 3 new sections to it every time you touch follow button
var snapshot = self.dataSource.snapshot()
// sections
snapshot.appendSections([.details, .stats, .articles])
try this
var snapshot = NSDiffableDataSourceSnapshot<AuthorProfileSection, AnyHashable>()
Or if you are calling another func like createSnapshot Before updateSnapshot then just try removing the line of appendSections
var snapshot = self.dataSource.snapshot()
// sections
// remove it
//snapshot.appendSections([.details, .stats, .articles])
snapshot.appendItems([authorProfileSection.details], toSection: .details)
snapshot.appendItems(authorProfileSection.stats ?? [], toSection: .stats)
snapshot.appendItems(authorProfileSection.articles ?? [], toSection: .articles)
self.dataSource.apply(snapshot, animatingDifferences: animated)
I’m not sure 100% 🤔 about it bcs I just on my phone rn
I'm trying to add a similar UIContextMenu as you can find in iMessages. When you long press a message, the context menu with some options should display.
I use tableView(_:contextMenuConfigurationForRowAt:point:) and other methods. So far so good.
But I came to 2 problems which I'm not able to solve:
the biggest one is the change of a preview. When the context menu is displayed and you receive a new message (which causes tableview to reload), the preview will change its content. So suddenly there is a different message than you originally selected. But I don't get it why because tableview methods for context menu aren't called... how could I resolve that?
Apple Messages stops adding a new message. But for example Viber is still able to receive a new message while there is a context menu.
I wanted to handle it somehow like Apple with the help of tableView(_:willDisplayContextMenu:animator:) ... but there is a second problem - this method is only for iOS +14.0.. ! So there is no way how I can I know that there will be context menu prior iOS 14?
I'll appreciate any help. Thanks.
I was able to solve the first problem somehow. The main idea is using snapshots instead of cell's view. This way even if tableView gets reload, the snapshot stays same.
You have to implement these 2 methods and supply snapshot there:
tableView(_:previewForHighlightingContextMenuWithConfiguration:)
tableView(_:previewForDismissingContextMenuWithConfiguration:)
In my code it looks like this:
func tableView(_: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
guard
let messageId = configuration.identifier as? String,
let indexPath = dataSource.indexPath(for: messageId),
let cell = tableView.cellForRow(at: indexPath) as? YourCustomCell else {
return nil
}
return makeTargetedPreview(cell: cell)
}
func makeTargetedPreview(cell: YourCustomCell) -> UITargetedPreview? {
guard
let previewView = cell.viewYouWantToDisplay,
let snapshot = previewView.snapshotView(afterScreenUpdates: false)
else {
return nil
}
// 1. Prepare how should the view in the preview look like
let parameters = UIPreviewParameters()
parameters.backgroundColor = .clear
parameters.visiblePath = UIBezierPath(roundedRect: previewView.bounds, cornerRadius: previewView.layer.cornerRadius)
// 2. Prepare UIPreviewTarget so we can use snapshot
let previewTarget = UIPreviewTarget(
container: previewView,
center: CGPoint(x: previewView.bounds.midX, y: previewView.bounds.midY)
)
// 3. Return UITargetedPreview with snapshot
// important ! We can't use cell's previewView directly as it's getting changed when data reload happens
return UITargetedPreview(view: snapshot, parameters: parameters, target: previewTarget)
}
Note: implementation of previewForDismissing(...) is similar.
I have a UITableView that gets populated by the following firebase database:
"games" : {
"user1" : {
"game1" : {
"currentGame" : "yes",
"gameType" : "Solo",
"opponent" : "Computer"
}
}
}
I load all the games in viewDidLoad, a user can create a new game in another UIViewController, once a user does that and navigates back to the UITableView I want to update the table with the new row. I am trying to do that with the following code:
var firstTimeLoad : Bool = true
override func viewDidLoad() {
super.viewDidLoad()
if let currentUserID = Auth.auth().currentUser?.uid {
let gamesRef =
Database.database().reference().child("games").child(currentUserID)
gamesRef.observe(.childAdded, with: { (snapshot) in
let game = snapshot
self.games.append(game)
self.tableView.reloadData()
})
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if firstTimeLoad {
firstTimeLoad = false
} else {
if let currentUserID = Auth.auth().currentUser?.uid {
let gamesRef = Database.database().reference().child("games").child(currentUserID)
gamesRef.observe(.childAdded, with: { (snapshot) in
self.games.append(snapshot)
self.tableView.reloadData()
})
}
}
}
Lets say there is one current game in the data base, when viewDidLoad is run the table displays correctly with one row. However anytime I navigate to another view and navigate back, viewDidAppear is run and for some reason a duplicate game seems to be appended to the games even though no child is added.
The cells are being populated by the games array:
internal func tableView(_ tableView: UITableView, cellForRowAt indexPath:
IndexPath) -> UITableViewCell {
let cell = Bundle.main.loadNibNamed("GameTableViewCell", owner:
self, options: nil)?.first as! GameTableViewCell
let game = games[indexPath.row]
if let gameDict = game.value as? NSDictionary {
cell.OpponentName.text = gameDict["opponent"] as? String
}
return cell
}
UPDATE:
Thanks to everyone for their answers! It seems like I misunderstood how firebase .childAdded was functioning and I appreciate all your answers trying to help me I think the easiest thing for my app would be to just pull all the data every time the view appears.
From what I can see, the problem here is that every time you push the view controller and go back to the previous one, it creates a new observer and you end up having many observers running at the same time, which is why your data appears to be duplicated.
What you need to do is inside your viewDidDisappear method, add a removeAllObservers to your gameRef like so :
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
guard let currentUserId = Auth.auth().currentUser?.uid else {
return
}
let gamesRef = Database.database().reference().child("games").child(currentUserId)
gamesRef.removeAllObservers()
}
I cannot see all your code here so I am not sure what is happening, but before adding your child added observer, you need to remove all the elements from your array like so :
games.removeAll()
Actually, as per best practices, you should not call your method inside your ViewDidLoad, but instead you should add your observer inside the viewWillAppear method.
I cannot test your code right now but hopefully it should work like that!
Let me know if it doesn't :)
UPDATE:
If you want to initially load all the data, and then pull only the new fresh data that is coming, you could use a combination of the observeSingleEvent(of: .value) and observe(.childAdded) observers like so :
var didFirstLoad = false
gamesRef.child(currentUserId).observe(.childAdded, with: { (snapshot) in
if didFirstLoad {
// add your object to the games array here
}
}
gamesRef.child(currentUserId).observeSingleEvent(of: .value, with: { (snapshot) in
// add the initial data to your games array here
didFirstLoad = true
}
By doing so, the first time it loads the data, .childAdded will not be called because at that time didFirstLoad will be set to false. It will be called only after .observeSingleEvent got called, which is, by its nature, called only once.
Try following code and no need to check for bool , Avoid using bool here its all async methods , it created me an issue in between of my chat app when its database grows
//Remove ref in didLoad
//Remove datasource and delegate from your storyboard and assign it in code so tableView donates for data till your array don't contain any data
//create a global ref
let gamesRef = Database.database().reference()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.games.removeAllObjects()
if let currentUserID = Auth.auth().currentUser?.uid {
gamesRef.child("games").child(currentUserID)observe(.childAdded, with: { (snapshot) in
self.games.append(snapshot)
self.tableView.dataSource = self
self.tableView.delegate = self
self.tableView.reloadData()
})
gamesRef.removeAllObserver() //will remove ref in disappear itself
//or you can use this linen DidDisappear as per requirement
}
else{
//Control if data not found
}
}
//TableView Delegate
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if self.games.count == 0{
let emptyLabel = UILabel(frame: CGRect(x: 0, y: 0, width: self.view.bounds.size.width, height: self.view.bounds.size.height))
emptyLabel.text = "No Data found yet"
emptyLabel.textAlignment = .center
self.tableView.backgroundView = emptyLabel
self.tableView.separatorStyle = .none
return 0
}
else{
return self.games.count
}
}
observe(.childAdded) is called at first once for each existing child, then one time for each child added.
Since i also encounter a similar issue, assuming you don't want to display duplicate objects, in my opinion the best approach, which is still not listed in the answers up above, is to add an unique id to every object in the database, then, for each object retrieved by the observe(.childAdded) method, check if the array which contains all objects already contains one with that same id. If it already exists in the array, no need to append it and reload the TableView. Of course observe(.childAdded) must also be moved from viewDidLoad() to viewWillAppear(), where it belongs, and the observer must be removed in viewDidDisappear. To check if the array already includes that particular object retrieved, after casting snapshot you can use method yourArray.contains(where: {($0.id == retrievedObject.id)}).
In my swift app I have a UITableView with one static cell and many dynamic cells.
Static cell contains different fields, such as labels, map (taken from MapKit) and a button, that indicates whether user voted up or not.
Now, when user presses the button, I want to change its color, possibly without refreshing anything else.
So far my code looks like this:
var currentUserVote:Int = 0
func tableView(_ tview: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if (indexPath as NSIndexPath).row == 0 {
let cell = tview.dequeueReusableCell(withIdentifier: "cellStatic") as! VideoDetailsCell
fetchScore(cell.score)
let voteUpImage = UIImage(named: "voteUp");
let tintedVoteUpImage = voteUpImage?.withRenderingMode(UIImageRenderingMode.alwaysTemplate)
cell.voteUpButton.setImage(tintedVoteUpImage, for: UIControlState())
checkUsersVote() { responseObject in
if(responseObject == 1) {
cell.voteUpButton.tintColor = orangeColor
} else if (responseObject == -1){
cell.voteUpButton.tintColor = greyColor
} else {
cell.voteUpButton.tintColor = greyColor
}
self.currentUserVote = responseObject
}
//map handling:
let regionRadius: CLLocationDistance = 1000
let initialLocation = CLLocation(latitude: latitude, longitude: longitude)
centerMapOnLocation(initialLocation, map: cell.mapView, radius: regionRadius)
//cell.mapView.isScrollEnabled = false
cell.mapView.delegate = self
.
.
.
return cell
} else {
//handle dynamic cells
}
}
So in the method above I'm checking if user voted already and based on that I'm setting different color on the button. I'm also centering the map on a specific point.
Now, since it's a static cell, I connected IBAction outlet to that button:
#IBAction func voteUpButtonAction(_ sender: AnyObject) {
if(currentUserVote == 1) {
self.vote(0)
}else if (currentUserVote == -1){
self.vote(1)
} else {
self.vote(1)
}
}
and the vote method works as follows:
func vote(_ vote: Int){
let indexPath = IndexPath(row: 0, section: 0)
let cell = tview.dequeueReusableCell(withIdentifier: "cellStatic") as! VideoDetailsCell
switch(vote) {
case 1:
cell.voteUpButton.tintColor = orangeColor
case 0:
cell.voteUpButton.tintColor = greyColor
case -1:
cell.voteUpButton.tintColor = greyColor
default:
cell.voteUpButton.tintColor = greyColor
}
tview.beginUpdates()
tview.reloadRows(at: [indexPath], with: .automatic)
tview.endUpdates()
currentUserVote = vote
//sending vote to my backend
}
My problem is, that when user taps the button, he invokes the method vote, then - based on the vote, the button changes color, but immediately after that method cellForRow is called and it changes the color of the button again. Also, it refreshes the map that's inside of it.
What I want to achieve is that when user taps the button, it should immediately change its color and that's it. Map stays untouched and the button is not changed again from cellForRow.
Is there a way of refreshing only that particular button without calling again cellForRow?
First of all, you confuse static and dynamic cells. You can use static cells only in the UITableViewController and you can't use static and dynamic cell at the same time.
I strongly recommend you not to use cell for storing map and button. All elements from the cell will be released after scrolling it beyond the screen.
I can advise you use TableViewHeaderView for this task. In this case you will be able set button and map view as #IBOutlet.
(See more about adding tableview headerView. You can also set it from interface builder.)
Another way is change tableView.contentInset and set your view with map and button as subview to tableView. This method is used when you need create Stretchy Headers.
It should be quite easy, simply do it in your button handler. The sender argument there is the button object that caused the action. When you were connecting it from IB there was a dropdown to select sender type, you may have missed it and the whole thing would have been obvious with UIButton type there.
Simply change your handler like this :
#IBAction func voteUpButtonAction(_ sender: UIButton) {
if(currentUserVote == 1) {
self.vote(0)
}else if (currentUserVote == -1){
self.vote(1)
} else {
self.vote(1)
}
sender.backgroundColor = yourFavouriteColor
}
Another approach would be to create an IBOutlet for your button, since its from a static cell, and then you would be able to reference it from any place in your view controller.
In this call:
func tableView(_ tview: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
I see it calls checkUsersVote() which I'm guessing should get the updated value set in the vote() call. Could the problem be that you aren't doing this
currentUserVote = vote
until after reloadRows() is called?
I have a TableViewController in which selecting a row makes a network call, shows an activityIndicator, and in the completion block of the network call, stops the indicator and does a push segue.
In testing, I found that when going to the new view controller, going back, and selecting another row, it shows the activity indicator, but never stops or calls the push segue. The code for didSelect:
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
self.tableView.userInteractionEnabled = false
self.searchController?.active = false
self.idx = indexPath
let cell = tableView.cellForRowAtIndexPath(indexPath) as! CustomTableViewCell
cell.accessoryView = self.activityIndicator
self.activityIndicator.startAnimating()
self.networkingEngine?.getObjectById("\(objectId)", completion: { (object) -> Void in
if object != nil {
self.tableView.userInteractionEnabled = true
self.chosenObject = object
self.activityIndicator.stopAnimating()
self.tableView.reloadRowsAtIndexPaths([self.idx], withRowAnimation: .None)
self.tableView.deselectRowAtIndexPath(self.idx, animated: true)
self.performSegueWithIdentifier("segueToNewVC", sender: self)
}
else {
}
})
}
and my network call using Alamofire
func getObjectbyId(objectId: String, completion:(Object?) -> Void) {
Alamofire.request(Router.getObjectById(objectId: objectId))
.response { (request, response, data, error) -> Void in
if response?.statusCode != 200 {
completion(nil)
}
else if let parsedObject = try!NSJSONSerialization.JSONObjectWithData(data as! NSData, options: .MutableLeaves) as? NSDictionary {
let object = parsedObject["thing"]
dispatch_async(dispatch_get_main_queue(), { () -> Void in
completion(object)
})
}
}
}
So I made breakpoints, and it actually is going into the completion block the second time and calling it to stop indicator, reload that row, deselect the row, re-enable the tableView userInteraction, and perform the segue. It calls my prepareForSegue as well, but as soon as it finishes, it just sits with the indicator still spinning, and the RAM usage skyrockets up to almost 2GB until the simulator crashes.
I believe it has to do with multi-threading but I can't narrow down the exact issue since I'm putting my important stuff on the main thread.
The problem had to do with making the accessoryView of the cell an activityIndicator and then removing it and reloading it all while segueing away. I couldn't figure out how to keep it there and stop the multi-threading issue but I settled for a progress AlertView. If anybody has a solution, I'd love to know how this is fixed.