Blocks and Table View Cells on iOS


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.

Select Project Template
Configure Project

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.

Configure Project Settings

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.

Create UITableViewCell Subclass
Create UITableViewCell Subclass

Open the class's header file and declare two outlets, a UILabel instance named titleLabel and a UIButton instance named actionButton.

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.

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.

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.

Setting Up the User Interface

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.

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:.

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.

To prevent the application from crashing when a button is tapped, implement didTapButton: as shown below.

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.

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.

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.

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.

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.

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.

The implementation of didTapButton: is trivial.

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.

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.

Tags:

Comments

Related Articles