I'm working on an iOS application and have a UITableView containing text messages (a table cell for each message). When a new message arrives a new cell is added in the UITableView. At first I used a single UITableView section and added all cells under it.
All worked fine until I decided to change the scheme and use multiple sections with one row each (it was the cleanest way I found to enforce a space between the cells). The problem I have with this scheme is that every time I try to add a new cell I get this exception:
2015-09-30 11:48:35.659 restcomm-messenger[15069:489766] *** Assertion failure in -[UITableView _endCellAnimationsWithContext:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3505.16/UITableView.m:1702
And I get it when [self.tableView endUpdates] is executed (see below for full code)
I have checked many SO answers in similar questions but I think I have done everything right. Here are the 'interesting' code sections:
New message arrives, update backing store and the insert row to table:
...
// update the backing store
[self.messages addObject:[NSDictionary dictionaryWithObjectsAndKeys:type, #"type", msg, #"text", nil]];
[self.tableView beginUpdates];
// trigger the new table row creation
[self.tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:0 inSection:[self.messages count] - 1]]
withRowAnimation:animation];
[self.tableView endUpdates];
...
Section and row count callbacks:
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return [self.messages count];
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return 1;
}
Fill cell callback:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSString *type = [[self.messages objectAtIndex:indexPath.section] objectForKey:#"type"];
if ([type isEqualToString:#"local"]) {
LocalMessageTableViewCell * cell = [tableView dequeueReusableCellWithIdentifier:#"local-message-reuse-identifier" forIndexPath:indexPath];
cell.senderText.text = [[self.messages objectAtIndex:indexPath.section] objectForKey:#"text"];
return cell;
}
else {
RemoteMessageTableViewCell * cell = [tableView dequeueReusableCellWithIdentifier:#"remote-message-reuse-identifier" forIndexPath:indexPath];
cell.senderText.text = [[self.messages objectAtIndex:indexPath.section] objectForKey:#"text"];
return cell;
}
}
I would expect that to work. Remember that for the original scheme (1 section may rows), it works just fine :(
If you want more details, here's the full code for the related Table View Controller: https://github.com/Mobicents/restcomm-ios-sdk/blob/master/Lab/restcomm-messenger-debug/restcomm-messenger/MessageTableViewController.m
Any hints are welcome
Antonis
Related
Does the model change and the row animation have to be started directly from commitEditingStyle or can it be done in a later run loop iteration?
I am asking because it appears to work on iOS 10, but on iOS 11 it breaks at least the delete animation. Is it simply a bug in iOS 11 or is it a bad idea in general?
Is there a better way to trigger an asynchronous delete operation and animate the table view change on completion?
The first picture shows how it breaks on iOS 11 (The delete button overlaps the next cell). The second picture shows how it looks fine on iOS 10.
This is the interesting snipped:
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath {
if (editingStyle == UITableViewCellEditingStyleDelete) {
dispatch_async(dispatch_get_main_queue(), ^{
[_model removeObjectAtIndex:indexPath.row];
[tableView deleteRowsAtIndexPaths:#[indexPath] withRowAnimation:UITableViewRowAnimationFade];
});
}
}
If I remove the dispatch_async(..., it works as expected on iOS 10 and 11. The first picture shows iOS 11, the second iOS 10.
Here is the full code of the table view controller used to test it:
#import "TableViewController.h"
#implementation TableViewController {
NSMutableArray<NSString *>* _model;
}
- (void)viewDidLoad {
[super viewDidLoad];
_model = [[NSMutableArray alloc] init];
[self.tableView registerClass:UITableViewCell.class forCellReuseIdentifier:#"cell"];
for (NSInteger i = 0; i < 200; i++) {
[_model addObject:[NSString stringWithFormat:#"Test Row %ld Test Test Test Test Test", (long)i]];
}
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return 1;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return _model.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:#"cell" forIndexPath:indexPath];
cell.textLabel.text = _model[indexPath.row];
return cell;
}
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath {
return YES;
}
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath {
if (editingStyle == UITableViewCellEditingStyleDelete) {
dispatch_async(dispatch_get_main_queue(), ^{
[_model removeObjectAtIndex:indexPath.row];
[tableView deleteRowsAtIndexPaths:#[indexPath] withRowAnimation:UITableViewRowAnimationFade];
});
}
}
#end
Update:
Adding this method to the table view controller fixes it for iOS 11 and allows delaying the model change and row animation. (Thanks to ChrisHaze)
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(11_0) {
UIContextualAction* deleteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:#"Delete" handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) {
dispatch_async(dispatch_get_main_queue(), ^{
[_model removeObjectAtIndex:indexPath.row];
[tableView deleteRowsAtIndexPaths:#[indexPath] withRowAnimation:UITableViewRowAnimationFade];
completionHandler(YES);
});
}];
UISwipeActionsConfiguration* config = [UISwipeActionsConfiguration configurationWithActions:#[deleteAction]];
return config;
}
It seems that there have been changes made to the UITableview in iOS 11.
Apple's Build release notes :
"The behavior of the delete swipe action has been changed.When implementing commitEditingStyle: to delete a swiped row, delete the row in the data source and call deleteRowsAtIndexPaths: on the table view to show the swipe delete animation."
After further research, I found that you must call the beginUpdates prior to calling the deleteRowAtIndexPath: method, along with the endUpdates method after.
According to Apple's documentation, not calling these tableView methods will result in potential data model issues and effect the deletion animations.
With that said, there is an answer that includes the required code to a question on the Apple dev forums that addresses a very similar question.
Answering your actual questions:
Model changes need to be called prior to the beginUpdates and endUpdates block, in which the UI changes will occur.
Updating the UITableView will alleviate the synchronization/animation issues.
---------------------------- additional details ----------------------------
After looking into the details within your comment, I've included a link (above) that is the latest Table View Programming Guide provided by Apple. It will take you, or anyone else with this issue, to the section of the guide that includes the details you've added to your question.
There is also a handy note that includes how to make the deleteRowAtIndexPath call from within the commitEditingStyle method if one must.
I'm experiencing pretty mysterious glitch:
After endUpdates was called all removed updated cells becoming just hidden and new cell will created, without reusing existing ones.
Usually even -tableView:didEndDisplayingCell:forRowAtIndexPath: not called, but not always.
Result looks like this:
I have no any clue about reasons of this behavior and will appreciate any ideas how to debug and fix this.
Unfortunately, source code it pretty complex and I'm unable to extract something intergal. if you ask me about related code - i will copy it to there.
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
id item = [self.presenter.mediator itemAtIndexPath:indexPath];
NSString *reuseIdentifier = [self.reuseIdentifierMatcher reuseIdentifierForItem:item];
UITableViewCell<ViewItemProtocol>* cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier
forIndexPath:indexPath];
cell.item = item;
return cell;
}
- (void)applyChangesSet:(TableChangesSet*)changes
{
LOG_VALUE(#([[NSThread currentThread] isMainThread]));
[self.tableView beginUpdates];
for (TableChange *change in changes.changes)
{
[self applyChange:change];
}
[self.tableView endUpdates];
}
applyChange does not contains any UI manipulations, except deleteRowsAtIndexPaths/insertRowsAtIndexPaths/reloadRowsAtIndexPaths;
Debug output is #([[NSThread currentThread] isMainThread]) is 1
Using this code
- (IBAction)testAdd:(id)sender
{
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.numberOfRows inSection:0];
[self.tableView beginUpdates];
self.numberOfRows++;
[self.tableView insertRowsAtIndexPaths:#[indexPath]withRowAnimation:UITableViewRowAnimationBottom];
[self.tableView endUpdates];
}
I'm able to add a new item to a tableView via an 'add' button on the app. This basically adds an item identical to the item already on the table that preceded it.
For example, I have a tableview with the first row displaying a string "TEST", hitting add adds another row that displays "TEST".
I would like to be able to pass in a custom value for the new row, so hitting add outputs a row with say "NEWTHING".
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:#"UITableViewCell" forIndexPath:indexPath];
// Configure the cell...
cell.textLabel.text = self.val2;
return cell;
}
My data source is actually another view controller that takes user inputs and sends it to my tabelViewController, with the text for the item as "val2".
What I actually want to achieve is the ability to hit add, go back to the user input view controller, get the new data and send it back to my tableViewController to be displayed
What you're asking, is the kinda stuff that is to be done in -cellForRowAtIndexPath: (most of the times, it depends on the way you have designed your datasource) but if it doesn't matter to you, then you can do:
- (IBAction)testAdd:(id)sender
{
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.numberOfRows
inSection:0];
self.numberOfRows++;
[self.tableView beginUpdates];
[self.tableView insertRowsAtIndexPaths:#[indexPath]
withRowAnimation:UITableViewRowAnimationBottom];
[self.tableView endUpdates];
UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
[cell.textLabel setText:#"NEWTHING"];
}
But note that when you scroll far up/down and return to this cell, it will most probably show "TEST" (that's where -cellForRowAtIndexPath: will show it's true purpose)
PS: Include your -cellForRowAtIndexPath: method implementation in the question if you want to proceed further
EDIT:
Your -cellForRowAtIndexPath is too static... in the sense that it simply sets self.val2 to cell.textLabel.
Lets say you start with 10 rows, -cellForRowAtIndexPath will be called 10 times and every time, it will set self.val2 onto the current cell's textLabel.
Now... when you add one row (on a button tap), the -cellForRowAtIndexPath will be called for the 11th cell and the same* text will be set to it.
*this technically happened but we quickly changed the cell's text
Basically, the tableView doesn't know how to differentiate between an existing cell and a new added cell because the datasource itself is not dynamic.
To direct the tableView on how to handle different cells, we need to create a more dynamic datasource.
There are different approaches use but I'd generally do it this way:
- (void)viewDidLoad
{
[super viewDidLoad];
self.val2 = #"TEST";
//declare "NSMutableArray *arrDatasource;" globally
//this will be the soul of the tableView
arrDatasource = [[NSMutableArray alloc] init];
int i_numberOfCells = 10;
//populate beginning cells with default text
for (int i = 0; i < i_numberOfCells; i++) {
NSMutableDictionary *dictionary = [[NSMutableDictionary alloc] init];
[dictionary setObject:self.val2 forKey:#"displayText"];
[arrDatasource addObject:dictionary];
}
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
return 1;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
//return number of objects in arrDatasource
return arrDatasource.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:#"UITableViewCell" forIndexPath:indexPath];
//pick up value for key "displayText" and set it onto the cell's label
[cell.textLabel setText:arrDatasource[indexPath.row][#"displayText"]];
//this will be dynamic in nature because you can modify the contents
//of arrDatasource and simply tell tableView to update appropriately
return cell;
}
-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
//make indexPath of new cell to be created
NSIndexPath *indexPathNEXT = [NSIndexPath indexPathForItem:arrDatasource.count inSection:0];
//add the appropriate contents to a dictionary
NSMutableDictionary *dictionary = [[NSMutableDictionary alloc] init];
[dictionary setObject:#"NEWTHING" forKey:#"displayText"];
//add the dictionary object to the main array which is the datasource
[arrDatasource addObject:dictionary];
//add it to tableView
[self.tableView beginUpdates];
[self.tableView insertRowsAtIndexPaths:#[indexPathNEXT]
withRowAnimation:UITableViewRowAnimationAutomatic];
[self.tableView endUpdates];
//this ends up calling -cellForRowAtIndexPath for the newly created cell
//-cellForRowAtIndexPath shows the text (you put in the dictionary in this method above)
}
PS: -cellForRowAtIndexPath: is called whenever cell updates or refreshes or needs to be displayed and so this method needs to be implemented properly
For example, I have 3 articles and when I display articles I want to display one more cell before the first one (that would be then 4 total).
I need to display that first article which is not in array and then articles which are in array.
UPDATE
I have tried next:
- (NSInteger)tableView:(
UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return ([arr count] + 1);
}
But my app then crash sometimes and I see over NSLOG then that app enters cellForRowAtIndexPath before I call [tableView reloadData].
This is something you should not really do.
Well, you can cheat the framework by returning a view (from -tableView:cellForRowAtIndexPath:) which would contain 2 subviews: your "first article" cell and the original first cell; don't forget to modify -tableView:heightForCellAtIndexPath: as well (otherwise your view would get cut).
But in general, you should change you data model behind the table view to dispay 4 cells – this is just a more valid approach.
You can do like this :
Return an additional row using this method :
// Each row array object contains the members for that section
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return [YouArray count]+1;
}
At the end check for this added row :
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
// Create a cell if one is not already available
UITableViewCell *cell = [self.mContactsTable dequeueReusableCellWithIdentifier:#"any-cell"];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:#"any-cell"] autorelease];
}
//Identify the added row
if(indexpath.row==0)
{
NSLog(#"This is first row");
}
else{
// Write your existing code
}
}
You need to add a extra value in the array using insertObject
[arr insertObject:[NSNull null] atIndex:0];
And implement the methods like:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return [arr count];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = #"Cell";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil)
{
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];
}
if([arr onjectAtIndex:indexPath.row] == [NSNull null])
{
// 1st cell extra cell do your stuff
}
return cell;
}
Do you keep all your articles in an array? You should add the new article to the array too. The array is your datasource. I take it that you'd want to insert the new article as the first element in your array if you'd like it to appear in the top cell. As soon as you've updated your array you should call [mytableView reloadData] which triggers all the datasource methods to be called and reload your table's data.
I´m quite new to iOS development and I´m having a terrible time by trying something that should be easy; to add an extra row in a TableView everytime the user clicks on one of the existing rows. There is no real purpose on that action, I´m just wanting to understand the behaviour of TableView.
So I did the following:
I used a Split View-based template and changed the number of rows to 30 in the RootViewController.
- (NSInteger)tableView:(UITableView *)aTableView numberOfRowsInSection:(NSInteger)section {
// Return the number of rows in the section.
return 30;
}
The method tableView:didSelectRowAtIndexPath looks in the following manner:
- (void)tableView:(UITableView *)aTableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
/*
When a row is selected, set the detail view controller's detail item to the item associated with the selected row.
*/
NSMutableArray* paths = [[NSMutableArray alloc] init];
NSIndexPath *indice = [NSIndexPath indexPathForRow:30 inSection:0];
[paths addObject:indice];
detailViewController.detailItem = [NSString stringWithFormat:#"Second Story Element %d with all its information and bla bla bla", indexPath.row];
[[self tableView] beginUpdates];
[self.tableView insertRowsAtIndexPaths:(NSArray *) paths withRowAnimation:UITableViewRowAnimationNone];
[[self tableView] endUpdates];
}
When I execute the program and click on one of the elements, I receive the following error:
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (30) must be equal to the number of rows contained in that section before the update (30), plus or minus the number of rows inserted or deleted from that section (1 inserted, 0 deleted).'
I did not change any other part of the code that the template provides.
I read quite extensively the documentation from Apple and the responses to the following questions:
Add a row dynamically in TableView of iphone
and
how to properly use insertRowsAtIndexPaths?
The second question seems to address the same problem, but I´m not capable to understand what is happening. What do they mean with dataSource? The response that I understand better says the following:
It's a two step process:
First update your data source so numberOfRowsInSection and cellForRowAtIndexPath will return the correct values for your post-insert data. You must do this before you insert or delete rows or you will see the "invalid number of rows" error that you're getting.
What does this update of the data source implies?
Sample code would be HIGHLY appreciated, because I´m totally frustrated.
By the way, all that I´m trying has nothing to do with entering the editing mode, has it?
You need to keep the count returned by tableView:numberOfRowsInSection: in sync!
So when you have 30 rows and then tell the tableview to insert a new row you need to make sure tableView:numberOfRowsInSection: will now return 31.
- (NSInteger)tableView:(UITableView *)aTableView numberOfRowsInSection:(NSInteger)section
{
return self.rowCount;
}
- (void)tableView:(UITableView *)aTableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
self.rowCount++;
[self.tableView beginUpdates];
[self.tableView insertRowsAtIndexPaths:(NSArray *) paths withRowAnimation:UITableViewRowAnimationNone];
[self.tableView endUpdates];
}
In practice you would probably use an array to track your rows return [self.rows count]; etc
The answer is quite simple. When you want to modify a table view you need to perform two simple steps:
Deal with the model
Deal with table animation
You already perform the second step. But you have missed the first one. Usually when you deal with a table you pass it a data source. In other words some data to display within it.
A simple example is using a NSMutableArray (it's dynamic as the name suggests) that contains dummy data.
For example, create a property like the following in .h
#property (nonatomic, strong) NSMutableArray* myDataSource;
and in .m synthesize it as:
#synthesize myDataSource;
Now, you can alloc-init that array and populate it as the following (for example in viewDidLoad method of your controller).
self.myDataSource = [[NSMutableArray alloc] init];
[self.myDataSource addObject:#"First"];
[self.myDataSource addObject:#"Second"];
Then, instead of hardcoding the number of rows you will display (30 in your case), you can do the following:
- (NSInteger)tableView:(UITableView *)aTableView numberOfRowsInSection:(NSInteger)section {
return [self.myDataSource count];
}
Now, in you didSelectRowAtIndexPath delegate you can add a third element.
- (void)tableView:(UITableView *)aTableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[self.myDataSource addObject:#"Third"];
[[self tableView] beginUpdates];
[self.tableView insertRowsAtIndexPaths:(NSArray *) paths withRowAnimation:UITableViewRowAnimationNone];
[[self tableView] endUpdates];
}
It looks like one big problem is with tableView:numberOfRowsInSection:. You need to return the correct number of rows in that method.
To do that, it's usually best to maintain an NSArray or NSMutableArray of items for the table view so in that function, you can say: return [arrayOfValues count];. Keep the array as a property of your view controller class so that it's readily accessible in all methods.
The array can also be used in cellForRowAtIndexPath:. If you have an array of NSString, you can say cell.text = [arrayOfValues objectAtRow:indexPath.row];.
Then, when you want to add an item to the table view, you can just add it to the array and reload the table, e.g. [tableView reloadData];.
Try implementing this concept and let me know how it goes.
You can Also do that for dayanamic table cell
-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return [arrayStationStore count];
}
-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *cellIndentyfire;
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIndentyfire];
if (cell == nil)
{
cell = [[UITableViewCell alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIndentyfire];
}
cell.textLabel.text = [arrayStationStore objectAtIndex:indexPath.row];
return cell;
}
-(NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
// Check if current row is selected
BOOL isSelected = NO;
if([tblStationName cellForRowAtIndexPath:indexPath].accessoryType == UITableViewCellAccessoryCheckmark)
{
isSelected = YES;
}
if(isSelected)
{
[tblStationName cellForRowAtIndexPath:indexPath].accessoryType = UITableViewCellAccessoryNone;
[arrayReplace removeObject:indexPath];
NSLog(#"array replace remove is %# ",arrayReplace);
}
else
{
[tblStationName cellForRowAtIndexPath:indexPath].accessoryType = UITableViewCellAccessoryCheckmark;
[arrayReplace addObject:indexPath];
NSLog(#"array replace add is %# ",arrayReplace);
}
return indexPath;
}