Tuesday, January 24, 2012

UIStoryboard Power Drill


I've read some good reasons why Storyboards are not ready for prime time. Some of the articles like Jonathan at toxic software.com simply help me find out that I wasn't doing something wrong, it just wasn't meant for that. But there are some benefits from using a storyboard that I wanted to keep, so I got adamant about finding workarounds. Here's one:




Drill down UITableViews - Standard



1. Provided you've started with a subclassed UITableViewController, or a TableView in a UIViewController that is already the root relationship from a UINavigationController.

2. You link the push outlet from the cell prototype back to the table view controller.
This will automatically provide the control to push-in another of the same ViewController when a cell is clicked.


3. Give the loopback segue an identifier string in the attributes inspector.

4. Instead of implementing tableView:didSelectRowAtIndexPath: in the subclassed viewcontroller you will use
prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender

This method will be passed the segue object that is the "tableLoopBack" the sender object for this segue type will be the UITableViewCell.

5. Prepare what data to send to the next instance the the table.


- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
    
    NSArray* myObjectArray;
    UITableView* myTable;
    
    if ([segue.identifier isEqualToString:@"tableLoopBack"]) {
        SubClassedTable* nextSct = segue.destinationViewController;
        UITableViewCell* myCell = (UITableViewCell*)sender;
        NSIndexPath* idx = [myTable indexPathForCell:myCell];
        nextSct.myObject = [self.myObjectArray objectAtIndex:idx.row];
    }
}

6. In the TableDataSourceDelegate have it decide how to propagate your tables data array based now on the defined and set value of "myObject". This is very different for everyones data types.

An example, using Core Data and have say AMusicLib > Artists > Albums > Songs > Info

The myObject would ideally be of type NSManagedObject* so that you could be any of the above. When the dataDelegates are called like tableview:numberOfRowsInSection: you would want to implement something like the following to handle the many layers.

- (int)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    if ([self.myObject isKindOfClass:[Music class]]) {
        Music* myMusicLib = self.myObject;
        return [myMusicLib.artists count];
    } else if ([self.myObject isKindOfClass:[Artist class]]) {
        Artist* myArtist = self.myObject;
        return [myArtist.albums count];
    } else if ([self.myObject isKindOfClass:[Album class]]) {
        Album* myAlbum = self.myObject;
        return [myAlbum.songs count];
    }
}

Now in the implementation of the tableview:cellForRowAtIndexPath: use a similar kindOf conditional so that the cells properties can feed from the difference of the objects at each layer.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    static NSString *cellID = @"musicCell";
    UITableViewCell* newCell = [tableView dequeueReusableCellWithIdentifier:cellID];

    if ([self.myObject isKindOfClass:[Music class]]) {
        Music* myMusicLib = self.myObject;
        Artist* aArtist = [[myMusicLib.artists allObjects] objectAtIndex:indexPath.row];
        newCell.textLabel.text = aArtist.name;
    } else if ([self.myObject isKindOfClass:[Artist class]]) {
        Artist* myArtist = self.myObject;
        Album* aAlbum = [[myArtist.albums allObjects] objectAtIndex:indexPath.row];
        newCell.textLabel.text = aAlbum.title;
        newCell.imageView.image = [UIImage imageNamed:aAlbum.art];
    } else if ([self.myObject isKindOfClass:[Album class]]) {
        Album* myAlbum = self.myObject;
        Song* aSong = [[myAlbum.songs allObjects] objectAtIndex:indexPath.row];
        newCell.textLabel.text = aSong.name;
        NSDate* aTime = [NSDate dateWithTimeIntervalSince1970:[aSong.length intValue]];
        NSDateFormatter *dateFormatter = [[[NSDateFormatter alloc] init] autorelease];
        [dateFormatter setDateFormat:@"m:ss"];
        newCell.detailTextLabel.text = [dateFormatter stringFromDate:aTime];
    }
    return newCell;
}
And finally because earlier we used a property self.myObjectArray, this property could have been a consistent type simple array, but because I walked you through handing down different managedObjects this myObjectArray getter would also need to return an NSArray from the different managedObject's intended NSSet. Same as just above, just return [myArtist.albums allObjects] or [myAlbum.songs allSongs] and so on for the prepareForSegue can hand off the right object to the next controller.


Drill Down TableViews - Options

The above is simple but can be limiting. The segue always pushes down to another of the same table view. Now you want options, lets go this why, or that.

1. Don't use the push outlet on the cell prototype. This is whats limiting the control of where we go.

2. Do Implement the tableView:didSelectRowAtIndexPath: this is where we will decide what segue we take, Or none.

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
    if (!sec) {
        Section* aSect = [[self.sectionFetchedController fetchedObjects] objectAtIndex:indexPath.row];
        [self performSegueWithIdentifier:@"tableLoopBack" sender:aSect];
    } else {
        Clip* aClip = [self.clipFetchedController objectAtIndexPath:indexPath];
        [[VideoPlayerController Player] setMoviePlayerForClip:aClip];
    }
}

What we are effectively doing here is getting the option to loop into another table, or stay and do something else like load a video in a modal MPMoviePlayerController. Or even call another segue that goes to detail view.

3. Continue to make use of the prepareForSegue method

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
    if ([segue.identifier isEqualToString:@"tableLoopBack"]) {
        VideoListController* vlc = segue.destinationViewController;
        Section* mySec = (Section*)sender;
        vlc.title = mySec.title;
        vlc.sec = mySec;
    }
}

This is so data is still passed to the new instance of this controller for drill down purposes, even if not all cells drill.

4. And now the hack, if in step 1 we do not link the cell to push to the this viewController, we still need to define a segue and name it "tableLoopBack".
Add a UIBarButtonItem to the viewController and set the push outlet on it to loopback, identify that new segue connection as "tableLoopBack" and you have access to it when calling performSegue.

5. Add another UIBarButtonItem, and link it to another ViewController like one that layout details level. Identify that segue as another name like "detailSegue" and performSegue from didSelectRowAtIndexPath: where you can pass down the object full of details right into the sender, which will also be handed through the prepareForSegue, so make sure you hand it down again to the desired property of the new destinationViewController.


Helpful? Vote me up: Stackoverflow.com

7 comments:

  1. Hey!
    This looks promising and hopefully something I can use in an app. Perhaps you can give me some guidance: I have one database with all needed information which I need to load and populate in a drill down with 3 tableviews and then onto detail view. View 3 will be determined by what user choose from 1 and 2... sort of fetching from database with "if (1=x & 2=y)then create cells". Perhaps this doesn't make any sense, but any help will be great.
    //Mans

    ReplyDelete
  2. I might understand your question three ways:
    1. You want to have a few different cell prototypes, which you can do by adding them to the table view layout and naming the identifier of each that pertains to the 3 levels. During cellForRowAtIndexPath: you will dequeueReusableCellWithIdentifier: of your desired layout.
    2. You want to land on any of 3 different detail views based on the drill down. Then you would track ivars for level1 & level2 (if you can't already determine this parenting by using the coredata inverse relationships) then you would attach a UIBarButtonItem for each of the different detail views. i.e. you would have 3 different segues to chose from so you can get to "playerDetailsSegue" , "gameMatchDetailsSeuge" or "ScoreDetailsSegue"
    3. Your fetchRequest changes per level, which you can also set as a conditional of the fetchRequest getter for that controller, where you are adding more info to the NSPredicate with each ivar that is not NULL. ( @"match.player = %@"', self.player )

    ReplyDelete
  3. Hello Jason.
    I would really like to thank you for your concept. I was looking for anything like that for quite a while.
    Even though I understand it quite well, I am not really sure how to make it work. Is there any chance you would make a small example project with some data in it?

    Thank you a lot,
    Martin.

    ReplyDelete
    Replies
    1. Hi Martin, Give me a couple days. I'll post something here.

      Delete
    2. Thank you for the new post, Jason, you're the best!

      Delete
  4. How would I add the the image to the image view you have there. I add another string in the plist with key being "image" and value being the album cover , ie "daftpunk.png". I can't figure out how to get it to show up though from the plist.

    This example has helped me so much. I was stuck for so long until I saw this. You are the man. Going now to vote you up on the stack.

    Thanks again and thank you for any help in advance.

    ReplyDelete
    Replies
    1. Hey Bob,
      So if you have the sample project from my newer post on this topic you will run across the prepareForSegue: method. In there you will see the name being assigned by the string value in the plist. Yes, I'd say add a string on the nodes that you want to have images. And then you can assign that string value.

      Sythesize a nsstring property so you set a value to it publicly. Then,
      nextController.imagename = [[self.items objectAtIndex:idx.row] objectForKey:@"image"];

      In the detailsController viewDidLoad you can now use the string to assign the image property of the UIImageView. self.albumImage.image = [UIImage imageNamed:self.imagename];

      PS. Sorry for the late reply.

      Delete