A table view cell doesn't know about the table view it belongs to and that's fine. In fact, that's how it should be. However, people who are new to this concept are often confused by it. For example, if the user taps a button in a table view cell, how do you obtain the index path of the cell so you can fetch the corresponding model? In this tutorial, I'll show you how not to do this, how it's usually done, and how to do this with style and elegance.
1. Introduction
When the user taps a table view cell, the table view invokes tableView:didSelectRowAtIndexPath:
of the UITableViewDelegate
protocol on the table view delegate. This method accepts two arguments, the table view and the index path of the cell that was selected.
The problem that we're going to tackle in this tutorial, however, is a bit more complex. Assume we have a table view with cells, with each cell containing a button. When the button is tapped, an action is triggered. In the action, we need to fetch the model that corresponds with the cell's position in the table view. In other words, we need to know the index path of the cell. How do we infer the cell's index path if we only get a reference to the button that was tapped? That's the problem we'll solve in this tutorial.
2. Project Setup
Step 1: Create Project
Create a new project in Xcode by selecting the Single View Application template from the list of iOS Application templates. Name the project Blocks and Cells, set Devices to iPhone, and click Next. Tell Xcode where you'd like to store the project and hit Create.
Step 2: Update Deployment Target
Open the Project Navigator on the left, select the project in the Project section, and set the Deployment Target to iOS 6. We do this to make sure that we can run the application on both iOS 6 and iOS 7. The reason for this will become clear later in this tutorial.
Step 3: Create UITableViewCell
Subclass
Select New > File... from the File menu and choose Objective-C class from the list of Cocoa Touch templates. Name the class TPSButtonCell
and make sure it inherits from UITableViewCell
.
Open the class's header file and declare two outlets, a UILabel
instance named titleLabel
and a UIButton
instance named actionButton
.
#import <UIKit/UIKit.h> @interface TPSButtonCell : UITableViewCell @property (weak, nonatomic) IBOutlet UILabel *titleLabel; @property (weak, nonatomic) IBOutlet UIButton *actionButton; @end
Step 4: Update View Controller
Open the header file of the TPSViewController
class and create an outlet named tableView
of type UITableView
. The TPSViewController
also needs to adopt the UITableViewDataSource
and UITableViewDelegate
protocols.
#import <UIKit/UIKit.h> @interface TPSViewController : UIViewController <UITableViewDataSource, UITableViewDelegate> @property (weak, nonatomic) IBOutlet UITableView *tableView; @end
We also need to take a brief look at the view controller's implementation file. Open TPSViewController.m and declare a static variable of type NSString
that we'll use as the reuse identifier for the cells in the table view.
#import "TPSViewController.h" @implementation TPSViewController static NSString *CellIdentifier = @"CellIdentifier"; // ... // @end
Step 5: User Interface
Open the project's main storyboard, Main.Storyboard, and drag a table view to the view controller's view. Select the table view and connect its dataSource
and delegate
outlets with the view controller instance. With the table view still selected, open the Attributes Inspector and set the number of Prototype Cells to 1
. The Content attribute should be set to Dynamic Prototypes. You should now see one prototype cell in the table view.
Select the prototype cell and set its Class to TPSButtonCell
in the Identity Inspector. With the cell still selected, open the Attributes Inspector and set the Style attribute to Custom and the Identifier to CellIdentifier.
Drag a UILabel
instance from the Object Library to the cell's content view and repeat this step for a UIButton
instance. Select the cell, open the Connections Inspector, and connect the titleLabel
and actionButton
outlets with their counterparts in the prototype cell.
Before we dive back into the code, we need to make one more connection. Select the view controller, open the Connections Inspector one more time, and connect the view controller's tableView
outlet with the table view in the storyboard. That's it for the user interface.
3. Populating the Table View
Step 1: Create a Data Source
Let's populate the table view with some notable movies that were released in 2013. In the TPSViewController
class, declare a property of type NSArray
and name it dataSource
. The corresponding instance variable will hold the movies that we'll show in the table view. Populate dataSource
with a dozen or so movies in the view controller's viewDidLoad
method.
#import "TPSViewController.h" @interface TPSViewController () @property (strong, nonatomic) NSArray *dataSource; @end
- (void)viewDidLoad { [super viewDidLoad]; // Setup Data Source self.dataSource = @[ @{ @"title" : @"Gravity", @"year" : @(2013) }, @{ @"title" : @"12 Years a Slave", @"year" : @(2013) }, @{ @"title" : @"Before Midnight", @"year" : @(2013) }, @{ @"title" : @"American Hustle", @"year" : @(2013) }, @{ @"title" : @"Blackfish", @"year" : @(2013) }, @{ @"title" : @"Captain Phillips", @"year" : @(2013) }, @{ @"title" : @"Nebraska", @"year" : @(2013) }, @{ @"title" : @"Rush", @"year" : @(2013) }, @{ @"title" : @"Frozen", @"year" : @(2013) }, @{ @"title" : @"Star Trek Into Darkness", @"year" : @(2013) }, @{ @"title" : @"The Conjuring", @"year" : @(2013) }, @{ @"title" : @"Side Effects", @"year" : @(2013) }, @{ @"title" : @"The Attack", @"year" : @(2013) }, @{ @"title" : @"The Hobbit", @"year" : @(2013) }, @{ @"title" : @"We Are What We Are", @"year" : @(2013) }, @{ @"title" : @"Something in the Air", @"year" : @(2013) } ]; }
Step 2: Implement the UITableViewDataSource
Protocol
The implementation of the UITableViewDataSource
protocol is very easy. We only need to implement numberOfSectionsInTableView:
, tableView:numberOfRowsInSection:
, and tableView:cellForRowAtIndexPath:
.
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return self.dataSource ? 1 : 0; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.dataSource ? self.dataSource.count : 0; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { TPSButtonCell *cell = (TPSButtonCell *)[tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath]; // Fetch Item NSDictionary *item = [self.dataSource objectAtIndex:indexPath.row]; // Configure Table View Cell [cell.titleLabel setText:[NSString stringWithFormat:@"%@ (%@)", item[@"title"], item[@"year"]]]; [cell.actionButton addTarget:self action:@selector(didTapButton:) forControlEvents:UIControlEventTouchUpInside]; return cell; }
In tableView:cellForRowAtIndexPath:
, we use the same identifier that we set in the main storyboard, CellIdentifier
, which we declared earlier in the tutorial. We cast the cell to an instance of TPSButtonCell
, fetch the corresponding item from the data source, and update the cell's title label. We also add a target and action for the UIControlEventTouchUpInside
event of the button.
Don't forget to add an import statement for the TPSButtonCell
class at the top of TPSViewController.m.
#import "TPSButtonCell.h"
To prevent the application from crashing when a button is tapped, implement didTapButton:
as shown below.
- (void)didTapButton:(id)sender { NSLog(@"%s", __PRETTY_FUNCTION__); }
Build the project and run it in the iOS Simulator to see what we've got so far. You should see a list of movies and tapping the button on the right logs a message to the Xcode console. Great. It's time for the meat of the tutorial.
4. How Not To Do It
When the user taps the button on the right, it will send a message of didTapButton:
to the view controller. You almost always need to know the index path of the table view cell that the button is in. But how do you get that index path? As I mentioned, there are three approaches you can take. Let's first look at how not to do it.
Take a look at the implementation of didTapButton:
and try to find out what's wrong with it. Do you spot the danger? Let me help you. Run the application first on iOS 7 and then on iOS 6. Take a look at what Xcode outputs to the console.
- (void)didTapButton:(id)sender { // Find Table View Cell UITableViewCell *cell = (UITableViewCell *)[[[sender superview] superview] superview]; // Infer Index Path NSIndexPath *indexPath = [self.tableView indexPathForCell:cell]; // Fetch Item NSDictionary *item = [self.dataSource objectAtIndex:indexPath.row]; // Log to Console NSLog(@"%@", item[@"title"]); }
The problem with this approach is that it's error prone. On iOS 7, this approach works just fine. On iOS 6, however, it doesn't work. To make it work on iOS 6, you'd have to implement the method as shown below. The view hierarchy of a number of commons UIView
subclasses, such as UITableView
, has changed in iOS 7 and the result is that the above approach doesn't produce a consistent result.
- (void)didTapButton:(id)sender { // Find Table View Cell UITableViewCell *cell = (UITableViewCell *)[[sender superview] superview]; // Infer Index Path NSIndexPath *indexPath = [self.tableView indexPathForCell:cell]; // Fetch Item NSDictionary *item = [self.dataSource objectAtIndex:indexPath.row]; // Log to Console NSLog(@"%@", item[@"title"]); }
Can't we just check if the device is running iOS 7? That's a very good idea. However, what will you do when iOS 8 changes the internal view hierarchy of UITableView
yet again? Are you going to patch your application every time a major release of iOS is introduced? And what about all those users that don't upgrade to the latest (patched) version of your application? I hope it's clear that we need a better solution.
5. A Better Solution
A better approach is to infer the cell's index path in the table view based on the position of the sender
, the UIButton
instance, in the table view. We use convertPoint:toView:
to accomplish this. This method converts the button's center from the button's coordinate system to the table view's coordinate system. It then becomes very easy. We call indexPathForRowAtPoint:
on the table view and pass pointInSuperview
to it. This gives us an index path that we can use to fetch the correct item from the data source.
- (void)didTapButton:(id)sender { // Cast Sender to UIButton UIButton *button = (UIButton *)sender; // Find Point in Superview CGPoint pointInSuperview = [button.superview convertPoint:button.center toView:self.tableView]; // Infer Index Path NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint:pointInSuperview]; // Fetch Item NSDictionary *item = [self.dataSource objectAtIndex:indexPath.row]; // Log to Console NSLog(@"%@", item[@"title"]); }
This approach may seem cumbersome, but it actually isn't. It's an approach that isn't affected by changes in the view hierarchy of UITableView
and it can be used in many scenarios, including in collection views.
6. The Elegant Solution
There is one more solution to solve the problem and it requires a bit more work. The result, however, is a display of modern Objective-C. Start by revisiting the header file of the TPSButtonCell
and declare a public method named setDidTapButtonBlock:
that accepts a block.
#import <UIKit/UIKit.h> @interface TPSButtonCell : UITableViewCell @property (weak, nonatomic) IBOutlet UILabel *titleLabel; @property (weak, nonatomic) IBOutlet UIButton *actionButton; - (void)setDidTapButtonBlock:(void (^)(id sender))didTapButtonBlock; @end
In the implementation file of TPSButtonCell
create a private property named didTapButtonBlock
as shown below. Note that the property attributed is set to copy
, because blocks need to be copied to keep track of their captured state outside of the original scope.
#import "TPSButtonCell.h" @interface TPSButtonCell () @property (copy, nonatomic) void (^didTapButtonBlock)(id sender); @end
Instead of adding a target and action for the UIControlEventTouchUpInside
event in the view controller's tableView:cellForRowAtIndexPath:
, we add a target and action in awakeFromNib
in the TPSButtonCell
class itself.
- (void)awakeFromNib { [super awakeFromNib]; [self.actionButton addTarget:self action:@selector(didTapButton:) forControlEvents:UIControlEventTouchUpInside]; }
The implementation of didTapButton:
is trivial.
- (void)didTapButton:(id)sender { if (self.didTapButtonBlock) { self.didTapButtonBlock(sender); } }
This may seem like a lot of work for a simple button, but hold you horses until we've refactored tableView:cellForRowAtIndexPath:
in the TPSViewController
class. Instead of adding a target and action to the cell's button, we set the cell's didTapButtonBlock
. Getting a reference to the corresponding item of the data source becomes very, very easy. This solution is by far the most elegant solution to this problem.
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { TPSButtonCell *cell = (TPSButtonCell *)[tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath]; // Fetch Item NSDictionary *item = [self.dataSource objectAtIndex:indexPath.row]; // Configure Table View Cell [cell.titleLabel setText:[NSString stringWithFormat:@"%@ (%@)", item[@"title"], item[@"year"]]]; [cell setDidTapButtonBlock:^(id sender) { NSLog(@"%@", item[@"title"]); }]; return cell; }
Conclusion
Even though the concept of blocks has been around for decades, Cocoa developers have had to wait until 2011. Blocks can make complex problems easier to solve and they make complex code simpler. Since the introduction of blocks, Apple has started making extensive use of them in their own APIs so I encourage you to follow Apple's lead by taking advantage of blocks in your own projects.
Comments