Customizing Table Appearance

So, we now have our live podcast data in the table, which is a great accomplishment. But…it does look kind of plain. Everything’s black and white, the fonts are kind of “meh,” and most of the titles are getting cut off.

We can do a few things with the table to make it look a lot better, so let’s end the chapter by doing that.

Section Headers

One easy thing we can do is to provide section headers. After all, we organized the data as one podcast per section, with all the episodes for a podcast as the section’s contents. Once we have multiple sections, we’re going to want a visual separation.

We can provide section headers and footers through the table’s delegate. To this point, we’ve focused on our table’s data source, which provides the table’s contents. By contrast, the delegate customizes the table’s appearance and its user interaction. For headers that appear above a section (and footers below), the delegate offers two methods: one that just returns a string for a header, and another for a whole customized view.

Let’s take the simple approach: by implementing tableView(titleForHeaderInSection:), we can just provide the title that we parsed from a given podcast’s feed. Add the following method in EpisodeListViewController.swift:

 func​ tableView(_ tableView: ​UITableView​,
  titleForHeaderInSection section: ​Int​) -> ​String​? {
 let​ feed = feeds[section]
 return​ feed.title
 }

This one is dirt simple: we need to return a string, so we get the PodcastFeed that corresponds to the section index, and just return the title we parsed. Run this and see the results:

images/tables/simulator-section-header.png

Try scrolling the table around a little. Notice that the section header “sticks” to the top of the table view. Once we have a second section, scrolling will cause it to replace the first section’s header, so there’s always an on-screen indication of what we’re looking at. Simple change, nice readability boost.

Custom Table Cells

The header is nice, but the biggest problem with our table is that the cells are very plain and don’t contain much of the information we parsed for each episode. Each cell only shows a title, and even that is cut off. We have got to do better.

Fortunately, we can. In the last section, we created plain, default UITableViewCells and populated those. There’s another option: create custom cells in the storyboard, and populate those.

images/tables/ib-cell-dynamic-prototypes.png

Go to Main.storyboard and select the table. Bring up its Attributes Inspector (4), and notice the first section has a pop-up called Content. This determines if our table is made up of Dynamic Prototypes or Static Cells. The difference is that the dynamic style is populated at runtime—as we’re doing by filling in the table with podcast data—while static table cells are used for creating a fixed number of cells in the storyboard for use in things like menus.

The next line down is Prototype Cells and has a text field that’s currently set to 0. Change it to 1 and watch what happens: the table gets a single table cell as a subview. This is the cell we’re going to customize.

First, though, we need a custom subclass of UITableViewCell that this prototype cell can be an instance of. Do FileNewFile to create a new file. On the sheets that follow, choose Cocoa Touch Class, call it EpisodeCell, make it a subclass of UITableViewCell, and save it to the default directory with all our other files.

That’s all we need the source for right now, so go back to Main.storyboard, and select the cell. Go to the Identity Inspector (3) and change the class to be the EpisodeCell class we just created.

Now, let’s think about the cell’s appearance. We would do well to have our episode titles use multiple lines if needed, and show some of the other fields we parsed for the episode.

We’re obviously going to need a bigger cell. Select the table, and go to the Size Inspector (the ruler icon in the right pane, also 5). The first entry here is for the default height of each row, currently 44. That’s not going to fly; set it to 100.

images/tables/ib-image-icon.png

This enlarges the prototype cell in the table, big enough that we can start adding some subviews to it. We’ll drag in three from the Object Library, starting with the image view, which is the gray icon with a palm tree:

  1. Drag an image view into the left side of the cell. Use the pin pop-up to set its height and width to 80, and pin its left side spacing to be 10 points from its container, the Content View. Add these constraints, then open up the align pop-up and add a vertical centering constraint. Use the Update Frames button to clean up the size and position of the image view.

  2. Drag a label to the top and right of the image view. Using the pin menu, give it 8 points of space on the left and right. Then, use Shift-click or Command-click to select both this label and the image view, bring up the align pop-up, and add a Top Edges constraint. Type over the default Label text to rename it Title.

  3. Drag one more label to the bottom and right of the image view. Again, use the pin menu to give it 8 points of space on its left and right. Then, select this label and the image view, bring up the align pop-up, and create a Bottom Edges constraint. Rename the label Duration.

That improves our layout, but we can also punch up the appearance of the labels. Select the Title label and bring up the Attributes Inspector. First, set its Lines to 2, so we have more room to work with before we have to truncate the string. Then, try setting Color to something other than plain old black (we’ve used a purple for the screenshots). Finally, choose a different font. You can use one of the “dynamic type” system fonts like Title 3 or Headline for a font that will grow or shrink based on the user’s accessibility settings, or just pick a specific custom font by name and size. Customize the Duration label too, maybe making it distinct from the title label (we used a smaller, orange, italic font).

When you’re done, the layout should look something like the following figure:

images/tables/ib-cell-layout.png

Next, we need connections from these subviews into the EpisodeCell class. Select the cell and bring up the Assistant Editor with the toolbar button, making sure that the storyboard is in the left pane and EpisodeCell.swift is in the right. You should be able to Control-drag as usual to make connections from the storyboard objects to the source file. Name the connections artworkImageView, titleLabel, and durationLabel as appropriate, so we end up with the following properties in EpisodeCell.swift:

 @IBOutlet​ ​var​ artworkImageView: ​UIImageView​!
 @IBOutlet​ ​var​ titleLabel: ​UILabel​!
 @IBOutlet​ ​var​ durationLabel: ​UILabel​!

When Assistant Editor Fails

images/aside-icons/tip.png

Sometimes, even when you select the right object in the storyboard in the left pane of the Assistant Editor, and its custom class is set correctly, Xcode doesn’t show the corresponding source file in the right pane. We figure it’s a bug. When this happens, the best option is to switch from Automatic to Manual in the breadcrumb bar at the top of the right pane, and use its pop-up menus to navigate to the EpisodeCell.swift class.

images/tables/ib-assistant-editor-manual-breadcrumbs.png

We’re almost done in the storyboard and can switch out of Assistant Editor back to Standard Editor. There’s one more important thing to do: select the cell itself and bring up the Attributes Inspector. Notice the second entry is Identifier; this is a string that we use from code to fetch this one cell we’ve just customized. Enter EpisodeCell for the identifier.

OK, payoff time. Switch back to EpisodeListViewController.swift. We need to rewrite tableView(cellForRowAtIndexPath:). Our previous version created a UITableViewCell and set its one text label’s contents. The new version will get the custom cell we just created, and fill in the fields by using its outlets.

1: func​ tableView(_ tableView: ​UITableView​,
2:  cellForRowAt indexPath: ​IndexPath​) -> ​UITableViewCell​ {
3: let​ episode = feeds[indexPath.section].episodes[indexPath.row]
4: let​ cell = tableView.dequeueReusableCell(withIdentifier: ​"EpisodeCell"​,
5:  for: indexPath) ​as!​ ​EpisodeCell
6:  cell.titleLabel.text = episode.title
7:  cell.durationLabel.text = episode.iTunesDuration
8: 
9: return​ cell
10: }

Line 3 is the same as before: get the PodcastEpisode from our feeds array that this row represents. The big change is lines 4-5. This uses the UITableView method dequeueReusableCell(withIdentifier:forIndexPath:) to load a cell we have associated with the table via the identifier string (EpisodeCell) that we just set in the storyboard. Because we already set its class in the storyboard with the Identity Inspector, we can safely use as! EpisodeCell to force it to our custom class. And since the custom cell class has outlets for titleLabel and durationLabel, we can set their contents from our PodcastEpisode.

We’re going to put off the image for a moment, so go ahead and run it now to see how we’re doing. With the larger cell height, custom fonts, colors, and multiline labels, we’re already looking a lot better.

images/tables/simulator-custom-cell-no-image.png

Loading Images in Table Cells

Now, to deal with our image view. At first glance, there’s a pretty straightforward way to load its image. The UIImageView has an image property that is a UIImage. Combine this with the fact that it’s possible to load an image from a Data object, and that we can fill a Data with the contents of a URL. That gives us a naïve way to fill it (but don’t actually do this, because it’s really bad):

 cell.artworkImageView.image =
 UIImage​(data: ​try​ ​Data​(contentsOf: episode.enclosureURL!))

The reason this is bad is what we talked about in Grand Central Dispatch: this call performs the entire download on the main queue, blocking all UI until it completes. Worse, it does so for every cell. So not only would it take a long time to load the table, but as soon as we scroll, it would slow down each time a new cell needs to appear from the top or bottom. So obviously this approach won’t work.

We did learn in the last chapter about URLSession and how it can perform downloads off of the main queue. This seems like it should be the answer, but with table cells, it’s actually a little trickier.

Notice the method we use to get our cell from the storyboard is called dequeueReusableCell(withIdentifier:forIndexPath:). If you’re curious about the whole “dequeue” part, you’re right to be. Here’s the story: creating table cells is expensive, and when you’re scrolling quickly through a long table, you don’t want things to slow down to create cells that are just going to fly by in an instant anyways. So once a cell scrolls off the top or bottom of the table, it is actually stored for potential reuse.

When we call the dequeue method, the table looks to see if it has a used table cell for us to reuse, and only creates a new cell from the prototype in the storyboard if it doesn’t. Reusing cells this way makes things go a lot faster.

But when we throw asynchronicity into the mix, as GCD necessarily does, things can get screwy. Consider this scenario:

  1. We start populating a cell for podcast episode foo, by setting its labels and starting to download its artwork.

  2. But we’re scrolling and the cell goes offscreen before the image load finishes.

  3. We need a cell for episode bar, so we set its labels and start to download its artwork.

  4. We stop scrolling, so the cell stays onscreen. Let’s say that bar’s artwork is a really small file or it’s on a faster server, so it loads pretty quickly. In fact, it finishes before foo’s image does.

  5. foo’s image finally finishes downloading, and it sets the image on the cell. Unfortunately, the cell is now being shown with bar’s labels, and we’re showing the wrong image.

So if we want to load the episode image asynchronously, how do we avoid this problem? It seems like we could fix the problem on step 5 by making sure the cell is still expecting image data for this URL, and not set the image view if it’s not.

So, let’s literally do exactly that. Switch to EpisodeCell.swift and add the following property:

 var​ loadingImageURL : ​URL​?

We will set this when we start loading an image, and check it again when we’re done.

Now, go back to EpisodeListViewController.swift. In tableView(cellForRowAtIndexPath:), right after setting the labels and before returning the cell, add the following (this is the last block of code in this chapter, promise!):

1: cell.artworkImageView.image = ​nil
if​ ​let​ url = episode.iTunesImageURL {
cell.loadingImageURL = url
let​ session = ​URLSession​(configuration: .​default​)
5: let​ dataTask = session.dataTask(with: url) { dataMb, responseMb, errorMb ​in
if​ ​let​ data = dataMb, url == cell.loadingImageURL {
DispatchQueue​.main.async {
cell.artworkImageView.image = ​UIImage​(data: data)
}
10:  }
}
dataTask.resume()
}

On line 1, we clear out any image previously set for this cell. Next, on line 2, we make sure we even parsed an iTunesImageURL at all. If so, 3 sets the cell’s loadingImageURL to this URL; if the cell gets reused, this will be overwritten. Then, on lines 4-5, we create a URLSession and its URLSessionDataTask to download the URL data.

When the download finishes, line 6 checks to see that the data task actually did receive data, and that the cell’s iTunesImageURL still matches the url that the task started with. If they don’t match, the cell was reused and the data task has old data we don’t want to use anymore, so we bail. But if they do match, we put ourselves on the main queue (line 7) so that we can populate the image of the artworkImageView (line 8).

Well, that’s some pretty twisty code, but does it work? Give it a shot: run the app.

images/tables/simulator-table-with-images.png

We have images! And, just as importantly, we still have speed! Swipe the table up and down and you should see it still scrolls quickly, loading images when it can, but not stopping the UI to do so.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset