Sunday, October 20, 2013

Creating an inclusive list in UITableView


What I wanted to do:


I was trying to figure out a way to create a multiple selection list in UITableView where more than one row can have a checkmark. I wanted to use this as a menu that would allow the user to select categories from a list and return the selected category list to the calling view controller.
This would work like the list iOS uses in its Clock/alarm app.  

Here is a short video showing what I want to do:






I wanted to be able to select/deselect multiple rows. In addition to this basic functionality, I wanted to add the capability for the user to have a ‘select all’ option where they could select or deselect the entire list.

How I did it:


There are two methods that are modified to achieve this:

didSelectRowAtIndexPath - where you handle checkmarking the current cell that is selected.  Here you checkmark the cell if it isn’t marked already and take the check mark off if it is.

cellForRowAtIndexPath - where you handle the creation of the visible cells.  Here you verify whether the cell should have a checkmark or not. ( I will explain why you need this below)

In my code I have a list of category names stored in an array called categoryList and another mutable array that tracks which categories are currently checkmarked.  

self.categoryList=[[NSArray alloc]initWithObjects:@"Achievement",@"Attitude",@"Brave",@"Character",@"Courage",@"Determination",@"Enthusiasm",@"Failure",@"Fame", @"Friendship", @"Happiness",@"Inspirational",@"Leadership", @"Love", @"Motivational",@"Opportunity",@"Perseverance", @"Spiritual", nil];

self.selectedCategoryArray =[[NSMutableArray alloc]init];



I initialize these in ViewDidLoad.

Category names are displayed in a dynamic cell UITableView.

To make a simple inclusive selection list, you can follow the example on the Apple developer site which shows:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
   
   [tableView deselectRowAtIndexPath:[tableView indexPathForSelectedRow] animated:NO];
//get cell info
   UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];

   if (cell.accessoryType == UITableViewCellAccessoryNone) {
       cell.accessoryType = UITableViewCellAccessoryCheckmark;

   } else if (cell.accessoryType == UITableViewCellAccessoryCheckmark) {
       cell.accessoryType = UITableViewCellAccessoryNone;
   }

}

This code simply adds a checkmark to the current cell if it doesn’t have one already and takes the checkmark off if it does.  
If you run this code you will have the ability to check and uncheck multiple items in your list.

However you will notice a problem when you scroll to check or uncheck items that are at the bottom of your list, you might see that the checkmarks travel down, and items you did not select are checkmarked and vice versa.  This happens because cells are reused when you scroll down and the accessory information does not hold when this happens.

In order to ensure that the right cells are always checked, you need to add/remove a checkmark from the cell every time it is created by cellForRowAtIndexPath.  To do this, you need to track the selection or deselection of cells using an array, then check that array in cellForRowAtIndexPath to confirm that the cell should have a checkmark.  I will show this code after adding a few lines to didSelectRowAtIndexPath that will track this info in selectedCategoryArray:


- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
   
   [tableView deselectRowAtIndexPath:[tableView indexPathForSelectedRow] animated:NO];
   UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];

   if (cell.accessoryType == UITableViewCellAccessoryNone) {
       cell.accessoryType = UITableViewCellAccessoryCheckmark;
      //store the selected category in array
       [self.selectedCategoryArray addObject:[self.categoryList objectAtIndex:indexPath.row]];




   } else if (cell.accessoryType == UITableViewCellAccessoryCheckmark) {
       cell.accessoryType = UITableViewCellAccessoryNone;
     //remove selected category from array
       [self.selectedCategoryArray removeObject:[self.categoryList objectAtIndex:indexPath.row]];



   }

}

I add the selected cell's category name into the selectedCategoryArray or remove the category name depending on whether there is a checkmark already in the cell or not.

When the cell is created in cellForRowAtIndexPath, I make a decision about the checkmark by checking to see whether it is selected ( if the selectedCategoryArray contains the category name of the cell being created at this indexPath.row) :

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
   UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"BasicCell" forIndexPath:indexPath];
if([self.selectedCategoryArray count]) {

       if([self.selectedCategoryArray containsObject:[self.categoryList objectAtIndex:indexPath.row]]) {
      
           cell.accessoryType = UITableViewCellAccessoryCheckmark;
       } else {
           cell.accessoryType = UITableViewCellAccessoryNone;
       }
  }
   [self configureCell:cell atIndexPath:indexPath];
   
    return cell;
}



Now, I have a fully functioning inclusive list in which I can select/deselect multiple categories.

Adding “select all” capability


Here is what I want the app to do:




I want to:
1. Add an option for the user to be able to select all rows when ‘all’ is selected.  
2. When the ‘all’ is checked I want all rows checkmarked, and all checkmarks removed when 'all' is unchecked.
3. When ‘all’ is checked, and all rows are checkmarked, if at that time a row is clicked, then check marks in all other rows should be removed (including ‘all’) and only that row should contain the checkmark.

Some more logic needs to be added to both the didSelectRowAtIndexPath and CellForRowAtIndexPath to do this.

Step 1 : Add 'all' option


To add the ‘all’ option I simply added the string to the categoryList array, the dynamic UITableView takes care of displaying it:

self.categoryList=[[NSArray alloc]initWithObjects:@”all”,@"Achievement",@"Attitude",@"Brave",@"Character",@"Courage",@"Determination",@"Enthusiasm",@"Failure",@"Fame", @"Friendship", @"Happiness",@"Inspirational",@"Leadership", @"Love", @"Motivational",@"Opportunity",@"Perseverance", @"Spiritual", nil];

Step 2: 'all' checks or unchecks all rows

To add the functionality I had to add some IF statements to see if ‘all’ is selected then handle adding checkmarks and removing checkmarks from ALL rows.

Here is a function that loops through all the indexpaths in the table and add checkmarks to all rows:

-(void)checkmarkAllRows{

   //loop through all index paths and add or remove checkmark.Since we have only
   //1 section, section 0, we can have a constant for that value
   
   for(int i=0;i<[self.categoryList count];i++) {

       //create indexpath
       NSIndexPath *myIndexPath = [NSIndexPath indexPathForRow:i inSection:0];
//get cell info for that indexpath
       UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:myIndexPath];
       cell.accessoryType = UITableViewCellAccessoryCheckmark;
   }
}




To take checkmarks off:

-(void)uncheckAllRows {
   
   for(int i=0;i<[self.categoryList count];i++) {
       
       NSIndexPath *myIndexPath = [NSIndexPath indexPathForRow:i inSection:0];
       UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:myIndexPath];
       cell.accessoryType = UITableViewCellAccessoryNone;

   }
}

Here is how the didSelectRowAtIndexPath method checks if the selected category name is ‘all’ then adds/removes checkmarks from ALL rows:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
   
   [tableView deselectRowAtIndexPath:[tableView indexPathForSelectedRow] animated:NO];
   
   
   UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
   if (cell.accessoryType == UITableViewCellAccessoryNone) {
       
       cell.accessoryType = UITableViewCellAccessoryCheckmark;
       
       [self.selectedCategoryArray addObject:[self.categoryList objectAtIndex:indexPath.row]];
       
       //if 'all' row is selected then check all rows
       if([[self.categoryList objectAtIndex:indexPath.row] isEqualToString:@"all"]) {
           [self checkmarkAllRows];
       }
       
   } else if (cell.accessoryType == UITableViewCellAccessoryCheckmark) {
       
       cell.accessoryType = UITableViewCellAccessoryNone;
       
       [self.selectedCategoryArray removeObject:[self.categoryList objectAtIndex:indexPath.row]];
       
       if([[self.categoryList objectAtIndex:indexPath.row] isEqualToString:@"all"]) {
           [self uncheckAllRows];
           [self.selectedCategoryArray removeAllObjects];
           
       }         
   }

}



Remember that because of the reusing of cells, we have to account for ‘all’ in cellForRowAtIndexPath also.  In this method, I just added a condition that adds a checkmark to the cell if ‘all’ is present in the selectedCategoryArray:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
   UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"BasicCell" forIndexPath:indexPath];
   
   if([self.selectedCategoryArray count]) {
       
if([self.selectedCategoryArray containsObject:[self.categoryList objectAtIndex:indexPath.row]]
|| [self.selectedCategoryArray containsObject:@"all"]) {
      
           cell.accessoryType = UITableViewCellAccessoryCheckmark;
           
       } else {
           cell.accessoryType = UITableViewCellAccessoryNone;
        }
   }
   [self configureCell:cell atIndexPath:indexPath];
   return cell;
}

Now clicking the ‘all’ cell will check/uncheck all cells!



Step 3: Additional list behavior

I chose to make the list behave in the following way: while ‘all’ is checked, if another cell is selected, this will assume the user wants to select only that cell and so all other cells will be unchecked (Other options would be to uncheck that cell and leave the others checked so you can exclude that category, or disable selection of that cell. Depends on what kind of menu you want to create).

For this to work some more conditions need to be added, basically if a cell is selected and if ‘all’ is present in the selectedCategoryArray, then checkmarks from all cells should be removed and only the selected cell should be checked.  

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
   
   [tableView deselectRowAtIndexPath:[tableView indexPathForSelectedRow] animated:NO];
   
    UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
   if (cell.accessoryType == UITableViewCellAccessoryNone) {
       cell.accessoryType = UITableViewCellAccessoryCheckmark;
       [self.selectedCategoryArray addObject:[self.categoryList objectAtIndex:indexPath.row]];
       
       //if 'all' row is selected then check all rows
       if([[self.categoryList objectAtIndex:indexPath.row] isEqualToString:@"all"]) {
           [self checkmarkAllRows];
       }
       
   } else if (cell.accessoryType == UITableViewCellAccessoryCheckmark) {
       
       cell.accessoryType = UITableViewCellAccessoryNone;
       
       [self.selectedCategoryArray removeObject:[self.categoryList objectAtIndex:indexPath.row]];
       
       if([[self.categoryList objectAtIndex:indexPath.row] isEqualToString:@"all"]) {
           [self uncheckAllRows];
           [self.selectedCategoryArray removeAllObjects];
           
       } else if([self.selectedCategoryArray containsObject:@"all"]) {
           [self uncheckAllRows];
           [self.selectedCategoryArray removeAllObjects];
           
           cell.accessoryType = UITableViewCellAccessoryCheckmark;
           [self.selectedCategoryArray addObject:[self.categoryList objectAtIndex:indexPath.row]];

           
       }
       
   }

}



At this point, you should have a list that meets all three requirements and works like the video shown at the beginning of this post!
I can then pass the selectedCateoryArray to a calling view controller that can use the information to configure the selections in my app.


Reference

https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/TableView_iPhone/ManageSelections/ManageSelections.html#//apple_ref/doc/uid/TP40007451-CH9-SW10