Collection View - Beginning iPhone Development with Swift: Exploring the iOS SDK (2014)

Beginning iPhone Development with Swift: Exploring the iOS SDK (2014)

Chapter 10. Collection View

In this chapter, we’re going to look at a fairly recent addition to UIKit: the UICollectionView class. You’ll see how it relates to the familiar UITableView, how it differs, and how it can be extended to do things that UITableView can’t even dream about.

For years, iOS developers have used the UITableView component to create a huge variety of interfaces. With its ability to let you define multiple cell types, create them on the fly as needed, and handily scroll them vertically, UITableView has become a key component of thousands of apps. And Apple has truly given its table view class lots of API love over the years, adding new and better ways to supply it with content in each major new iOS release.

However, it’s still not the ultimate solution for all large sets of data. If you want to present data in multiple columns, for example, you need to combine all the columns for each row of data into a single cell. There’s also no way to make a UITableView scroll its content horizontally. In general, much of the power of UITableView has come with a particular trade-off: developers have no control of the overall layout of a table view. You can define the look of each individual cell all you want; but at the end of the day, the cells are just going to be stacked on top of each other in one big scrolling list!

Well, apparently Apple realized this, too. In iOS 6, it introduced a new class called UICollectionView that addresses these shortcomings. Like a table view, this class lets you display a bunch of “cells” of data and handles things like queuing up unused cells for later use. But unlike a table view, UICollectionView doesn’t lay these cells out in a vertical stack for you. In fact, UICollectionView doesn’t lay them out at all! Instead, it uses a helper class to do layout, as you’ll see soon.

Creating the DialogViewer Project

To show some of the capabilities of UICollectionView, we’re going to use it to lay out some paragraphs of text. Each word will be placed in a cell of its own, and all the cells for each paragraph will be clustered together in a section. Each section will also have its own header. This may not seem too exciting, considering that UIKit already contains other perfectly good ways of laying out text. However, this process will be instructive anyway, since you’ll get a feel for just how flexible this thing is. You certainly wouldn’t get very far doing something like Figure 10-1 with a table view!

image

Figure 10-1. Each word is a separate cell, with the exception of the headers, which are, well, headers. All of this is laid out using a single UICollectionView, and no explicit geometry calculations of our own

In order to make this work, we’ll define a couple of custom cell classes, we’ll use UICollectionViewFlowLayout (the one and only layout helper class included in UIKit at this time), and, as usual, we’ll use our view controller class to glue it all together. Let’s get started!

Use Xcode to create a new Single View Application, as you’ve done many times by now. Name your project DialogViewer and use the standard settings we’ve used throughout the book (set Language to Swift and choose Universal for Devices.)

Fixing the View Controller’s Class

There’s nothing in particular we need to do with the app delegate in this app, so let’s jump straight into ViewController.swift and make a simple change, switching the super class to UICollectionView:

import UIKit

class ViewController: UIViewController {
class ViewController: UICollectionViewController {

Next, open Main.storyboard. We need to set up the view controller to match what we just specified in ViewController.swift. Select the one and only View Controller in the Document Outline and delete it, leaving an empty storyboard. Now use the Object Library to locate a Collection ViewController and drag it into the editing area. Select the icon for the View Controller you just dragged out and use the Identity Inspector to change its class to ViewController. In the Attributes Inspector, ensure that the Is Initial View Controller check box is checked. Next, select theCollection View in the Document Outline and use the Attributes Inspector to change its background to white. Finally, you’ll see that the Collection View object in the Document Outline has a child called Collection View Cell. This a prototype cell that you can use to design the layout for your actual cells in Interface Builder. We’re not going to do that in this chapter, so select that cell and delete it.

Defining Custom Cells

Now let’s define some cell classes. As you saw in Figure 10-1, we’re displaying two basic kinds of cells: a “normal” one containing a word and another that is used as a sort of header. Any cell you’re going to create for use in a UICollectionView needs to be a subclass of the system-supplied UICollectionViewCell, which provides basic functionality similar to UITableViewCell. This functionality includes a backgroundView, a contentView, and so on. Because our two cells will have some shared functionality, we’ll actually make one a subclass of the other and use the subclass to override some functionality.

Start by creating a new Cocoa Touch class in Xcode. Name the new class ContentCell and make it a subclass of UICollectionViewCell. Select the new class’s source file and add declarations for three properties and a stub for a class method:

class ContentCell: UICollectionViewCell {
var label: UILabel!
var text: String!
var maxWidth: CGFloat!

class func sizeForContentString(s: String,
forMaxWidth maxWidth: CGFloat) -> CGSize {
return CGSizeZero
}
}

The label property will point at a UILabel used for display. We’ll use the text property to tell the cell what to display, the maxWidth property to control the cell’s maximum width, and we’ll use the sizeForContentString(_, forMaxWidth:) method—which we’ll implement shortly—to ask how big the cell needs to be to display a given string. This will come in handy when creating and configuring instances of our cell classes.

Now add overrides of the UIView init(frame:) and init(coder:) methods, as shown here:

override init(frame: CGRect) {
super.init(frame: frame)
label = UILabel(frame: self.contentView.bounds)
label.opaque = false
label.backgroundColor =
UIColor(red: 0.8, green: 0.9, blue: 1.0, alpha: 1.0)
label.textColor = UIColor.blackColor()
label.textAlignment = .Center
label.font = self.dynamicType.defaultFont()
contentView.addSubview(label)
}

required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}

That code is pretty simple. It just creates a label, sets its display properties, and adds the label to the cell’s contentView. The only mysterious thing here is that it uses the defaultFont() method to get a font, which is used to set the label’s font. The idea is that this class should define which font will be used for displaying content, while also allowing any subclasses to declare their own display font by overriding the defaultFont() method. Notice how this method is called:

label.font = self.dynamicType.defaultFont()

The defaultFont() method is a type method of the ContentCell class. To call it, you would normally use the name of the class, like this:

ContentCell.defaultFont()

In this case, that won’t work—if this call is made from a subclass of ContentCell (such as the HeaderCell class that we will create shortly), we want to actually call the subclass’ override of defaultFont(). To do that, we need a reference to the subclass’s type object. That’s what the expression self.dynamicType gives us. If this expression is executed from an instance of the ContentCell class, it resolves to the type object of ContentCell and we’ll call the defaultFont() method of that class; but in the subclass HeaderCell, it resolves to the type object for HeaderCell and we’ll call HeaderCell’s defaultFont() method instead, which is exactly what we want.

We haven’t created the defaultFont() method yet, so let’s do so:

class func defaultFont() -> UIFont {
return UIFont.preferredFontForTextStyle(UIFontTextStyleBody)
}

Pretty straightforward. This uses the preferredFontForTextStyle() method of the UIFont class to get the user’s preferred font for body text. The user can use the Settings app to change the size of this font. By using this method instead of hard-coding a font size, we make our apps a bit more user-friendly.

To finish off this class, let’s implement the method that we added a stub for earlier, the one that computes an appropriate size for the cell:

class func sizeForContentString(s: String,
forMaxWidth maxWidth: CGFloat) -> CGSize {
let maxSize = CGSizeMake(maxWidth, 1000)
let opts = NSStringDrawingOptions.UsesLineFragmentOrigin

let style = NSMutableParagraphStyle()
style.lineBreakMode = NSLineBreakMode.ByCharWrapping
let attributes = [NSFontAttributeName: self.defaultFont(),
NSParagraphStyleAttributeName: style]

let string = s as NSString
let rect = string.boundingRectWithSize(maxSize, options: opts,
attributes: attributes, context: nil)

return rect.size
}

That method does a lot of things, so it’s worth walking through it. First, we declare a maximum size so that no word will be allowed to be wider than the value of the maxWidth argument, which will be set from the width of the UICollectionView. We also create a paragraph style that allows for character wrapping, so in case our string is too big to fit in our given maximum width, it will wrap around to a subsequent line. We also create an attributes dictionary that contains the default font we defined for this class and the paragraph style we just created. Finally, we use someNSString functionality provided in UIKit that lets us calculate sizes for a string. We pass in an absolute maximum size and the other options and attributes we set up, and we get back a size.

All that’s left for this class is some special handling of the text property. Instead of letting this use an implicit instance variable as we normally do, we’re going to define methods that get and set the value based on the UILabel we created earlier, basically using the UILabel as storage for the displayed value. By doing so, we can also use the setter to recalculate the cell’s geometry when the text changes. Replace the definition of the text property ContentCell.swift with the following code:

var label: UILabel!
var text: String! {
get {
return label.text
}
set(newText) {
label.text = newText
var newLabelFrame = label.frame
var newContentFrame = contentView.frame
let textSize = self.dynamicType.sizeForContentString(newText,
forMaxWidth: maxWidth)
newLabelFrame.size = textSize
newContentFrame.size = textSize
label.frame = newLabelFrame
contentView.frame = newContentFrame
}
}
var maxWidth: CGFloat!

The getter is nothing special; but the setter is doing some extra work. Basically, it’s modifying the frame for both the label and the content view, based on the size needed for displaying the current string.

That’s all we need for our base cell class. Now let’s make a cell class to use for a header. Use Xcode to make another new Cocoa Touch class, naming this one HeaderCell and making it a subclass of ContentCell. We don’t need to touch the header file at all, so jump straight toHeaderCell.swift to make some changes. All we’re going to do in this class is override some methods from the ContentCell class to change the cell’s appearance, making it look different from the normal content cell:

override init(frame: CGRect) {
super.init(frame: frame)
label.backgroundColor = UIColor(red: 0.9, green: 0.9,
blue: 0.8, alpha: 1.0)
label.textColor = UIColor.blackColor()
}

required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}

override class func defaultFont() -> UIFont {
return UIFont.preferredFontForTextStyle(UIFontTextStyleHeadline)
}

That’s all we need to do to give the header cell a distinct look, with its own colors and font.

Configuring the View Controller

Now let’s focus our attention on our view controller. Select ViewController.swift and start by declaring an array to contain the content we want to display:

class ViewController: UICollectionViewController {
private var sections: [[String: String]]!

Next, we’ll use the viewDidLoad() method to create that data. The sections array will contain a list of dictionaries, each of which will have two keys: header and content. We’ll use the values associated with those keys to define our display content. The actual content we’re using is adapted from a well-known play:

override func viewDidLoad() {
super.viewDidLoad()
sections = [
["header": "First Witch",
"content" : "Hey, when will the three of us meet up later?"],
["header" : "Second Witch",
"content" : "When everything's straightened out."],
["header" : "Third Witch",
"content" : "That'll be just before sunset."],
["header" : "First Witch",
"content" : "Where?"],
["header" : "Second Witch",
"content" : "The dirt patch."],
["header" : "Third Witch",
"content" : "I guess we'll see Mac there."]
]
}

Much like UITableView, UICollectionView lets us register the class of a reusable cell based on an identifier. Doing this lets us call a dequeuing method later on, when we’re going to provide a cell. If no cell is available, the collection view will create one for us—just likeUITableView! Add this line to the end of viewDidLoad() to make this happen:

collectionView.registerClass(ContentCell.self,
forCellWithReuseIdentifier: "CONTENT")

We’ll make just one more change to viewDidLoad(). Since this application has no navigation bar, the main view will interfere with the status bar. To prevent that, add the following lines to the end of viewDidLoad():

var contentInset = collectionView.contentInset
contentInset.top = 20
collectionView.contentInset = contentInset

That’s enough configuration in viewDidLoad(), at least for now. Before we get to the code that will populate the collection view, we need to write one little helper method. All of our content is contained in lengthy strings, but we’re going to need to deal with them one word at a time to be able to put each word into a cell. So let’s create an internal method of our own to split those strings apart. This method takes a section number, pulls the relevant content string from our section data, and splits it into words:

func wordsInSection(section: Int) -> [String] {
let content = sections[section]["content"]
let spaces = NSCharacterSet.whitespaceAndNewlineCharacterSet()
let words = content?.componentsSeparatedByCharactersInSet(spaces)
return words!
}

Providing Content Cells

Now it’s time for the group of methods that will actually populate the collection view. These next three methods are remarkably similar to their UITableView correspondents. First, we need a method to let the collection view know how many sections to display:

override func numberOfSectionsInCollectionView(
collectionView: UICollectionView) -> Int {
return sections.count
}

Next, we have a method to tell the collection how many items each section should contain. This uses the wordsInSection() method we defined earlier:

override func collectionView(collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
let words = wordsInSection(section)
return words.count
}

And here’s the method that actually returns a single cell, configured to contain a single word. This method also uses our wordsInSection() method. As you can see, it uses a dequeuing method on UICollectionView, similar to UITableView. Since we’ve registered a cell class for the identifier we’re using here, we know that the dequeuing method always returns an instance:

override func collectionView(collectionView: UICollectionView,
cellForItemAtIndexPath indexPath: NSIndexPath)
-> UICollectionViewCell {
let words = wordsInSection(indexPath.section)
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(
"CONTENT", forIndexPath: indexPath) as ContentCell
cell.maxWidth = collectionView.bounds.size.width
cell.text = words[indexPath.row]
return cell
}

Judging by the way that UITableView works, you might think that at this point we’d have something that works, in at least a minimal way. Build and run your app, and you’ll see that we’re not really at a useful point yet (see Figure 10-2.)

image

Figure 10-2. This isn’t very useful

We can see some of the words, but there’s no “flow” going on here. Each cell is the same size, and everything is all jammed together. The reason for this is that we have more delegate responsibilities we have to take care of to make things work.

Making the Layout Flow

Until now, we’ve been dealing with the UICollectionView, but as we mentioned earlier, this class has a sidekick that takes care of the actual layout. UICollectionViewFlowLayout, which is the default layout helper for UICollectionView, has some delegate methods of its own that it will use to try to pull more information out of us. We’re going to implement one of these right now. The layout object calls this method for each cell to find out how large it should be. Here we’re once again using our wordsInSection() method to get access to the word in question, and then using a method we defined in the ContentCell class to see how large it needs to be.

When the UICollectionViewController is initialized, it makes itself the delegate of its UICollectionView. The collection view’s UICollectionViewFlowLayout will treat the view controller as its own delegate if it declares that it conforms to theUICollectionViewDelegateFlowLayout protocol. The first thing we need to do is change the declaration of our view controller in ViewController.swift so that it declares conformance to that protocol:

class ViewController: UICollectionViewController,
UICollectionViewDelegateFlowLayout {

All of the methods of the UICollectionViewDelegateFlowLayout protocol are optional and we only need to implement one of them. Add the following method to ViewController.swift:

func collectionView(collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
let words = wordsInSection(indexPath.section)
let size = ContentCell.sizeForContentString(words[indexPath.row],
forMaxWidth: collectionView.bounds.size.width)
return size
}

Now build and run the app again, and you’ll see that we’ve taken a pretty large step forward (see Figure 10-3.)

image

Figure 10-3. Paragraph flow is starting to take shape

You can see that the cells are now flowing and wrapping around so that the text is readable, and that the beginning of each section drops down a bit. But each section is jammed really tightly against the ones before and after it. They’re also pressing all the way out to the sides, which doesn’t look too nice. Let’s fix that by adding a bit more configuration. Add these lines to the end of the viewDidLoad() method:

let layout = collectionView.collectionViewLayout
let flow = layout as UICollectionViewFlowLayout
flow.sectionInset = UIEdgeInsetsMake(10, 20, 30, 20)

Here we’re grabbing the layout object from our collection view. We assign this first to a temporary variable, which will be inferred to be of type UICollectionViewLayout. We do this primarily to highlight a point: UICollectionView only knows about this generic layout class, but it’s really using an instance of UICollectionFlowLayout, which is a subclass of UICollectionViewLayout. Knowing the true type of the layout object, we can use a typecast to assign it to another variable of the correct type, enabling us to access methods that only that subclass has—in this case, we need the setter method for the sectionInset property.

Build and run again, and you’ll see that our text cells have gained some much-needed breathing room (see Figure 10-4.)

image

Figure 10-4. Now much less cramped

Providing Header Views

The only thing missing now is the display of our header objects, so it’s time to fix that. You will recall that UITableView has a system of header and footer views, and it asks for those specifically for each section. UICollectionView has made this concept a bit more generic, allowing for more flexibility in the layout. The way this works is that, along with the system of accessing normal cells from the delegate, there is a parallel system for accessing additional views that can be used as headers, footers, or anything else. Add this bit of code to the end of viewDidLoad()to let the collection view know about our header cell class:

collectionView.registerClass(HeaderCell.self,
forSupplementaryViewOfKind: UICollectionElementKindSectionHeader,
withReuseIdentifier: "HEADER")

As you can see, in this case we’re not only specifying a cell class and an identifier, but we’re also specifying a “kind.” The idea is that different layouts may define different kinds of supplementary views and may ask the delegate to supply views for them. UICollectionFlowLayout is going to ask for one section header for each section in the collection view, and we’ll apply them like this:

override func collectionView(collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
atIndexPath indexPath: NSIndexPath)
-> UICollectionReusableView {
if (kind == UICollectionElementKindSectionHeader) {
let cell =
collectionView.dequeueReusableSupplementaryViewOfKind(
kind, withReuseIdentifier: "HEADER",
forIndexPath: indexPath) as HeaderCell
cell.maxWidth = collectionView.bounds.size.width
cell.text = sections[indexPath.section]["header"]
return cell
}
abort()
}

Note the abort() call at the end of this method. This function causes the application to terminate immediately. It’s not the sort of thing you should use frequently in production code. Here, we only expect to be called to create header cells and there is nothing we can do if we are asked to create a different kind of cell—we can’t even return nil, because the method’s return type does not permit it. If we are called to create a different kind of header, it’s a programming error on our part or a bug in UIKit.

Build and run, and you’ll see… wait! Where are those headers? As it turns out, UICollectionFlowLayout won’t give the headers any space in the layout unless we tell it exactly how large they should be. So go back to viewDidLoad() and add the following line at the end:

flow.headerReferenceSize = CGSizeMake(100, 25)

Build and run once more, and now you’ll see the headers in place, as Figure 10-1 showed earlier and Figure 10-5 shows again.

image

Figure 10-5. The completed DialogViewer app

In this chapter, we’ve really just dipped our toes into UICollectionView and what can be accomplished with the default UICollectionFlowLayout class. You can get even fancier with it by defining your own layout classes, but that is a topic for another book.

Now that you’ve gotten familiar with all the major big-picture components, it’s time to look at how to create master-detail apps like the iOS Mail application; so turn the page and let’s get started with that in Chapter 11.