Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TableView with two instances of NSFetchedResultsController

After days of research and re-coding I am pretty much stumped. My goal is to get a test app running with a single tableview populated from two separate fetchedResultControllers.

I have a series of items on a shopping list, each with a department and a boolean 'collected' flag. Uncollected items should be listed by department, followed by a single section containing all collected items (regardless of department). As a user checks off uncollected items, they should move down to the 'collected' section. If s/he un-checks a collected item, it should move back into its correct department.

enter image description here

To achieve the first part (uncollected items), I set up a simple fetchedResultsController that fetches all items where collected = NO, and sectioned the results by department:

- (NSFetchedResultsController *)firstFRC {
    // Set up the fetched results controller if needed.
    if (firstFRC == nil) {

        // Create the fetch request for the entity.
        NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init];

        // fetch items
        NSEntityDescription *entity = [NSEntityDescription entityForName:@"Item" inManagedObjectContext:managedObjectContext];
        [fetchRequest setEntity:entity];

        // only if uncollected
        NSPredicate *predicate = [NSPredicate predicateWithFormat:@"(collected = NO)"];
        [fetchRequest setPredicate:predicate];

        // sort by name
        NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"name" ascending:YES];
        NSArray *sortDescriptors = [[NSArray alloc] initWithObjects:sortDescriptor, nil];

        [fetchRequest setSortDescriptors:sortDescriptors];

        // fetch results, sectioned by department
        NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest managedObjectContext:managedObjectContext sectionNameKeyPath:@"department" cacheName:nil];
        aFetchedResultsController.delegate = self;
        self.firstFRC = aFetchedResultsController;
    }

    return firstFRC;
} 

I set the number of rows, sections, and section headers as follows:

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    // Return the number of sections.
    return firstFRC.sections.count;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    // Return the number of rows in the section.
    return [[firstFRC.sections objectAtIndex:section] numberOfObjects];
}

- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
    return [[[firstFRC sections] objectAtIndex:section] name];
}

I also use the boilerplate controllerWillChangeContent, didChangeObject, and controllerDidChangeContent to add/remove cells as the FRC's change.

For brevity, I won't include the code for displaying the cell, but I essentially pull the correct item based on the cell's index path, set the text/subtitle of the cell, and attach one of two checkmark images, depending on whether the item is collected or not. I also wire up this image (which is on a button) to toggle from checked to unchecked when it is touched, and update the item accordingly.

This part all works fine. I can view my list of items by department, and when I mark one as collected, I see it drop off the list as expected.

Now I attempted to add the bottom section, containing all of the collected items (in a single section). First I set up a second fetchedResultsConroller, this time to fetch only uncollected items, and without sectioning. I also had to update the following:

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    // Return the number of sections - add one for 'collected' section
    return firstFRC.sections.count + 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    // Return the number of rows in the section
    if (section < firstFRC.sections.count) {
        return [[firstFRC.sections objectAtIndex:section] numberOfObjects];
    }
    else {
        return secondFRC.fetchedObjects.count;
    }
}

- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
    if (section < firstFRC.sections.count) {
        return [[[firstFRC sections] objectAtIndex:section] name];
    }
    else{
        return @"Collected";
    }
}

I then updated the cellForRowAtIndexPath in a similar fashion, so that the item I retrieve comes from the right FRC:

    Item *item;
    if (indexPath.section < firstFRC.sections.count) {
        item = [firstFRC objectAtIndexPath:indexPath];
    }
    else {
        item = [secondFRC objectAtIndexPath:[NSIndexPath indexPathForRow:indexPath.row inSection:0]];
    }

    [[cell textLabel] setText:[item name]];

…rest of cell configuration

This works great when I launch. The tableview displays exactly as anticipated:

enter image description here

The Problem (finally)

The (first) problem comes when I select the checkmark for an uncollected item. I expect the item to be removed from the department it is listed under, and moved to the 'collected' section. Instead, I get:

CoreData: error: Serious application error. An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:. Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (2) must be equal to the number of rows contained in that section before the update (2), plus or minus the number of rows inserted or deleted from that section (1 inserted, 0 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out). with userInfo (null)

If I attempt the opposite, then I receive a different error:

* Terminating app due to uncaught exception 'NSRangeException', reason: '* -[__NSArrayM objectAtIndex:]: index 2 beyond bounds [0 .. 1]'

I suspect that in both cases there is a problem with consistency with the number of sections/rows in the FRCs and the tableview when an item moves from one FRC to the other. Although that second error makes me think there is maybe a simpler problem related to my retrieval of items.

Any direction or ideas would be appreciated. I can provide more of my code if it would help, and have also created a small test app with a single view to demonstrate the issue. I can upload it if necessary, but mostly I wanted to test the issue in a small scale sandbox.

Update - additional code requested

As requested, this is what happens when a checkmark is touched:

- (void)checkButtonTapped:(id)sender event:(id)event
{
    NSSet *touches = [event allTouches];
    UITouch *touch = [touches anyObject];
    CGPoint currentTouchPosition = [touch locationInView:self.tableView];
    NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint: currentTouchPosition];
    if (indexPath != nil)
    {
        [self tableView: self.tableView accessoryButtonTappedForRowWithIndexPath: indexPath];
    }
}

- (void)tableView:(UITableView *)tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath *)indexPath
{   
    Item *item;
    if (indexPath.section < firstFRC.sections.count) {
        item = [firstFRC objectAtIndexPath:indexPath];
    }
    else {
        item = [secondFRC objectAtIndexPath:[NSIndexPath indexPathForRow:indexPath.row inSection:0]];
    }

    UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
    UIButton *button = (UIButton *)cell.accessoryView;  

    if (![item collected]) {
        [item setCollected:YES];        
        [button setBackgroundImage:[UIImage imageNamed:@"checked.png"] forState:UIControlStateNormal];
    }
    else if ([item collected]){
        [item setCollected:NO];
        [button setBackgroundImage:[UIImage imageNamed:@"unchecked.png"] forState:UIControlStateNormal];
    }
    NSError *error = nil;
    if (![item.managedObjectContext save:&error]) {
        NSLog(@"Error saving collected items");
    }
}
like image 583
Ben Packard Avatar asked Jan 25 '12 03:01

Ben Packard


1 Answers

Well, I think I might have cracked it. As is often the case, stripping it down and reading just a few comments pointed me in the right direction.

When I get to the delegate method controllerDidChangeObject, I attempt to insert a row at the indexPath provided (for the 'checked' item). Except that when inserting into my additional section, this indexPath has no awareness of the fact that there are a bunch of other sections before it. So it receives section 0 and attempts to insert there. Instead, if the indexPath comes from the second FRC, I should be incrementing the section number by the number of sections in the first FRC's table. So, I replaced:

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath { 

UITableView *tableView = self.tableView;

    switch(type) {
        case NSFetchedResultsChangeInsert:
                [tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];

        break;

with

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath {

UITableView *tableView = self.tableView;

switch(type) {
    case NSFetchedResultsChangeInsert:

        if ([controller.sectionNameKeyPath isEqualToString:@"department"]) {
            [tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
        }
        else {
            [tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:newIndexPath.row inSection:firstFRC.sections.count]] withRowAnimation:UITableViewRowAnimationFade];         }
        break;

I will need to do this for each of insert, delete, update etc. I will mark this as the answer after I have validated this and to allow time for other comments.

like image 124
Ben Packard Avatar answered Sep 29 '22 04:09

Ben Packard