For years, iOS developers 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 became a key component of thousands of apps. While Apple has increased the capability of table views with every major new iOS release, 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 came with a particular trade-off: developers have no control of the overall layout of a table view. You define the look of each individual cell, but the cells are just going to be stacked on top of each other in one big scrolling list.
In iOS 6, Apple introduced a new class called UICollectionView addressing these shortcomings. Similar to a table view, UICollectionView allows you to display a bunch of “cells” of data and handles functionality such as queuing up unused cells to use later. 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, UICollectionView uses a helper class to do layout.
Creating the DialogViewer Project
Let’s start by talking about UICollectionView. To show some of its capabilities, you’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.
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 your own.
To make this work, you’ll define a couple of custom cell classes, you’ll use UICollectionViewFlowLayout (the one and only layout helper class included in UIKit at this time), and, as usual, you’ll use your view controller class to make it all come together.
Use Xcode to create a new application with the Single View App template, as you’ve done many times by now. Name your project DialogViewer and use the standard settings you’ve used throughout the book (set Language to Swift and choose Universal for Devices). Open ViewController.swift and change its superclass to UICollectionView.
class ViewController: UICollectionViewController {
Open Main.storyboard. You need to set up the view controller to match what you 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 view controller and drag it into the editing area. If you examine the Document Outline, you’ll see that the collection view controller comes with a nested collection view. Its relation to the collection view is very much like the relationship between UITableViewController and its nested UITableView. Select the icon for the collection view controller and use the Identity Inspector to change its class to ViewController, which you just made into a subclass of UICollectionViewController. In the Attributes Inspector, ensure that the Is Initial View Controller check box is selected. Next, select the collection view in the Document Outline and use the Attributes Inspector to change its background color to white. Finally, you’ll see that the collection view has a child called Collection View Cell. This is a prototype cell that you can use to design the layout for your actual cells in Interface Builder, just like you have been doing with table view cells. You’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, you’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 class , which provides basic functionality similar to UITableViewCell. This functionality includes a backgroundView, a contentView, and so on. Because your two types of cell will have some shared functionality, you’ll actually make one a subclass of the other and use the subclass to override some of the functionality of the base class.
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, as shown in Listing 10-1.
Listing 10-1. Your ContentCell Class Definition
class ContentCell: UICollectionViewCell {
var label: UILabel!
var text: String!
var maxWidth: CGFloat!
class func sizeForContentString(s: String,
forMaxWidth maxWidth: CGFloat) -> CGSize {
return CGSize.zero
}
}
The label property will point at a UILabel. You’ll use the text property to tell the cell what to display and the maxWidth property to control the cell’s maximum width. You’ll use the sizeForContentString(_:forMaxWidth:) method—which you’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 your cell classes.
Now add overrides of the UIView init(frame:) and init(coder:) methods, as shown in Listing 10-2.
Listing 10-2. Init Override Routines for Your Cell ContentCell Class
override init(frame: CGRect) {
super.init(frame: frame)
label = UILabel(frame: self.contentView.bounds)
label.isOpaque = false
label.backgroundColor =
UIColor(red: 0.8, green: 0.9, blue: 1.0, alpha: 1.0)
label.textColor = UIColor.black()
label.textAlignment = .center
label.font = self.dynamicType.defaultFont()
contentView.addSubview(label)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
The code in Listing 10-2 is pretty simple. It 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. We haven’t created the defaultFont() method yet, so let’s do so.
class func defaultFont() -> UIFont {
return UIFont.preferredFontForTextStyle(UIFontTextStyleBody)
}
It’s pretty straightforward. It 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, you make your apps a bit more user-friendly. 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 you will create shortly), you want to actually call the subclass’ override of defaultFont(). To do that, you need a reference to the subclass’s type object. That’s what the expression self.dynamicType gives you. If this expression is executed from an instance of the ContentCell class, it resolves to the type object of ContentCell, and you’ll call the defaultFont() method of that class; but in the HeaderCell subclass, it resolves to the type object for HeaderCell, and you’ll call HeaderCell’s defaultFont() method instead, which is exactly what you want. To finish off this class, let’s implement the method that you added a stub for earlier, the one that computes an appropriate size for the cell, as shown in Listing 10-3.
Listing 10-3. Compute an Approximate Cell Size
class func sizeForContentString(s: String,
forMaxWidth maxWidth: CGFloat) -> CGSize {
let maxSize = CGSize(width: maxWidth, height: 1000.0)
let opts = NSStringDrawingOptions.usesLineFragmentOrigin
let style = NSMutableParagraphStyle()
style.lineBreakMode = NSLineBreakMode.byCharWrapping
let attributes = [ NSFontAttributeName: defaultFont(),
NSParagraphStyleAttributeName: style]
let string = s as NSString
let rect = string.boundingRect(with: maxSize, options: opts,
attributes: attributes, context: nil)
return rect.size
}
The method in Listing 10-3 does a lot of things, so it’s worth walking through it. First, you 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. You also create a paragraph style that allows for character wrapping, so in case your string is too big to fit in your given maximum width, it will wrap around to a subsequent line. You also create an attributes dictionary that contains the default font you defined for this class and the paragraph style you just created. Finally, you use some NSString functionality provided in UIKit that lets you calculate sizes for a string. We pass in an absolute maximum size and the other options and attributes that you set up, and you 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 you normally do, you’re going to define methods that get and set the value based on the UILabel you created earlier, basically using the UILabel as storage for the displayed value. By doing so, you can also use the setter to recalculate the cell’s geometry when the text changes. Replace the definition of the text property in ContentCell.swift with the code in Listing 10-4.
Listing 10-4. The Text Property Definition in the ContentCell.swift File
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 = type(of: self).sizeForContentString(s: newText,
forMaxWidth: maxWidth)
newLabelFrame.size = textSize
newContentFrame.size = textSize
label.frame = newLabelFrame
contentView.frame = newContentFrame
}
}
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 you need for your 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. Let’s open HeaderCell.swift and make some changes. All you’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, as shown in Listing 10-5.
Listing 10-5. The HeaderCell Class
class HeaderCell: ContentCell {
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.black()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override class func defaultFont() -> UIFont {
return UIFont.preferredFont(forTextStyle: UIFontTextStyleHeadline)
}
}
That’s all you should have to do to give the header cell a distinct look, with its own colors and font.
Configuring the View Controller
Select ViewController. swift and start by declaring an array to contain the content you want to display, as shown in Listing 10-6.
Listing 10-6. Your Content to Be Displayed— Place This in the ViewController.swift File
private var 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."]
]
The sections array contains a list of dictionaries, each of which has two keys: header and content. You’ll use the values associated with those keys to define your display content.
Much like UITableView, UICollectionView lets you register the class of a reusable cell based on an identifier. Doing this lets you call a dequeuing method later, when you’re going to provide a cell. If no cell is available, the collection view will create one for you—just like UITableView. Add this line to the end of the viewDidLoad() method to make this happen:
self.collectionView?.register(ContentCell.self, forCellWithReuseIdentifier: "CONTENT")
Since this application has no navigation bar, the content of the main view will be visible beneath 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() for now. Before you get to the code that populates the collection view, you need to write one little helper method. All of your content is contained in lengthy strings, but you’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 your own to split those strings apart. This method takes a section number, pulls the relevant content string from your section data, and splits it into words.
func wordsInSection(section: Int) -> [String] {
let content = sections[section]["content"]
let spaces = NSCharacterSet.whitespacesAndNewlines
let words = content?.components(separatedBy: spaces)
return words!
}
Providing Content Cells
Now it’s time to create the group of methods that will actually populate the collection view. These next three methods are all defined by the UICollectionViewDataSource protocol, which is adopted by the UICollectionViewController class. The UICollectionViewController assigns itself as the data source of its nested UICollectionView, so these methods will be called automatically by the UICollectionView when it needs to know about its content.
First, you need a method to let the collection view know how many sections to display:
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return sections.count
}
Next, you have a method to tell the collection how many items each section should contain. This uses the wordsInSection() method you defined earlier.
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let words = wordsInSection(section: section)
return words.count
}
Listing 10-7 shows the method that actually returns a single cell, configured to contain a single word. This method also uses your wordsInSection() method . As you can see, it uses a dequeuing method on UICollectionView, similar to the one in UITableView. Since you’ve registered a cell class for the identifier you’re using here, you know that the dequeuing method always returns an instance.
Listing 10-7. Setting Up the Collection View Cell
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let words = wordsInSection(section: indexPath.section)
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "CONTENT", for: 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 you’d have something that works, in at least a minimal way. Build and run the app. You’ll see that you’re not really at a useful point yet, as shown in Figure 10-2.
Figure 10-2. Not quite what you’re looking for…yet
You can see some of the words , but there’s no “low” going on here. Each cell is the same size, and everything is all jammed together. The reason for this is that you have some collection view delegate responsibilities you have to take care of to make things work.
Creating the Layout Flow
Until now, you’ve been dealing with the UICollectionView, but this class has a sidekick that takes care of the actual layout. UICollectionViewFlowLayout, which is the default layout helper for UICollectionView, includes delegate methods of its own that it will use to try to pull out more information from you. You’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 you’re once again using your wordsInSection() method to get access to the word in question and then using a method you 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 the UICollectionViewDelegateFlowLayout protocol. The first thing you need to do is change the declaration of your 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 you need to implement only one of them. Add the method in Listing 10-8 to ViewController.swift.
Listing 10-8. Resizing the Cells Using the UICollectionViewDelegateFlowLayout Protocol
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. You’ll see that you’ve taken a step forward, as shown in Figure 10-3.
Figure 10-3. Your paragraph flow begins taking 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 you’re grabbing the layout object from your collection view. You assign this first to a temporary variable, which will be inferred to be of type UICollectionViewLayout. You do this primarily to highlight a point: UICollectionView knows only 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, you can use a typecast to assign it to another variable of the correct type, enabling you to access properties that only that subclass has. In this case, you use the sectionInset property to tell the UICollectionViewLayout to leave some empty space around each item in the collection view. In this case, that means there will now be a little space around every word, as you’ll see if you run the example again (see Figure 10-4).
Figure 10-4. Things are much less cramped
Implementing the Header Views
The only thing missing now is the display of your 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 your header cell class:
self.collectionView?.register(HeaderCell.self,
forSupplementaryViewOfKind: UICollectionElementKindSectionHeader,
withReuseIdentifier: "HEADER")
As you can see, in this case not only are you specifying a cell class and an identifier, but you’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, which you’ll supply, as shown in Listing 10-9.
Listing 10-9. Getting Your Header Cell View
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
if (kind == UICollectionElementKindSectionHeader) {
let cell =
collectionView.dequeueReusableSupplementaryView(
ofKind: kind, withReuseIdentifier: "HEADER",
for: 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, you only expect to be called to create header cells, and there is nothing you can do if you are asked to create a different kind of cell—you can’t even return nil because the method’s return type does not permit it. If you are called to create a different kind of header, it’s a programming error on your part or a bug in UIKit.
Build and run. You’ll see…wait! Where are those headers? As it turns out, UICollectionFlowLayout won’t give the headers any space in the layout unless you tell it exactly how large they should be. So, go back to viewDidLoad() and add the following line at the end:
flow.headerReferenceSize = CGSize(width: 100, height: 25)
Build and run once more. You’ll see the headers in place, as Figure 10-1 showed earlier and Figure 10-5 shows again.
Figure 10-5. The completed DialogViewer app
Summary
In this chapter, I’ve really just touched on 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.
Stack views are something else you should look into when considering applications using collection views. They may offer an alternative approach that could save you time. As this book is tending to get larger and larger with the new Swift, Xcode, and iOS features, I’m leaving stack views as an exercise for you.