In the first installment of this series, we created the project in Xcode, updated the project's structure, and put it under source control. This lesson focuses on building the basic user interface of the application.
If you want to follow along, open the project you created in the previous installment or clone the GitHub repository.
Building the User Interface
The weather application we're building runs on iPhone and iPad. We want to create a user interface that looks great and feels right on both device families. There are several ways to accomplish this. The approach I have chosen for takes advantage of size classes.
Thunderstorm will present three views to the user:
- the current weather
- the forecast for the day
- the forecast for the week
On devices with a compact, horizontal size class (for example, iPhone SE), the user can navigate between these views by swiping left and right. On devices with a regular, compact size class (for example, iPad Pro), these views are presented side by side. To accomplish this, we make use of a collection view.
Creating the RootViewController
Class
The view controller that manages the collection view will be the root view controller of the project. Select the ViewController
class in the Project Navigator and delete it from the project. Create a new group in the View Controllers group, name it Root View Controller, and link it to a folder named Root View Controller. We covered the relation between groups and folders in the previous lesson.
Add a new UIViewController
class to the Root View Controller group and name it RootViewController
. There is no need to create a XIB file for the class as we will be using storyboards.
Create an outlet for the collection view and make the RootViewController
class conform to the UICollectionViewDataSource
and UICollectionViewDelegateFlowLayout
protocols. Notice that I created an extension for each protocol. This is a nice trick to keep related code organized.
import UIKit
class RootViewController: UIViewController {
// MARK: - Properties
@IBOutlet var collectionView: UICollectionView!
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
}
}
extension RootViewController: UICollectionViewDataSource {
}
extension RootViewController: UICollectionViewDelegateFlowLayout {
}
Open Main.storyboard and the select the view controller of the View Controller Scene. Open the Identity Inspector on the right and set Class to RootViewController.
Drag a collection view from the Object Library to the view of the root view controller and make it span the width and height of the view. Connect the collection view to the collectionView
outlet of the RootViewController
instance.
Add the necessary layout constraints to pin the collection view to the top, bottom, leading, and trailing edges of the view of the root view controller.
Select the collection view, open the Connections Inspector on the right, and set its dataSource
and delegate
properties to the view controller of the scene, that is, the root view controller.
With the collection view selected, open the Attributes Inspector and:
- set Items to 0
- set Scroll Direction to Horizontal
- uncheck Shows Horizontal Indicator
- uncheck Shows Vertical Indicator
- check Paging Enabled
If you have some experience building iOS applications, then these steps should not pose any problems.
Populating the Collection View
Before we can build the application, we first need to implement the required methods of the UICollectionViewDataSource
protocol. Open the RootViewController
class and define an enum, RootViewType
, to help us with this. Note that we declare the enum within the RootViewController
class.
import UIKit
class RootViewController: UIViewController {
enum RootViewType: Int {
case now = 0
case day
case week
static var count: Int {
return RootViewType.week.rawValue + 1
}
}
// MARK: - Properties
@IBOutlet var collectionView: UICollectionView!
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
}
}
This makes working with the collection view easier and more elegant. The static computed property count
helps us implement the collectionView(_:numberOfItemsInSection:)
method of the UICollectionViewDataSource
protocol.
extension RootViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return RootViewType.count
}
}
We also need to tell the collection view which collection view cells it needs to display. In the viewDidLoad()
method, we invoke a helper method, setupView()
.
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
In setupView()
, we invoke another helper method, setupCollectionView()
. I like to keep methods short and concise.
// MARK: - View Methods
private func setupView() {
setupCollectionView()
}
In setupCollectionView()
, we register the UICollectionViewCell
class for the someCell
reuse identifier. We clean up the implementation of setupCollectionView()
later in this lesson.
// MARK: -
private func setupCollectionView() {
collectionView.register(UICollectionViewCell.classForCoder(), forCellWithReuseIdentifier: "someCell")
}
We can now implement the collectionView(_:cellForItemAt:)
method of the UICollectionViewDataSource
protocol. We set the background color of the content view of the collection view cell to a green color to visualize what we have so far.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
// Dequeue Reusable Cell
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "someCell", for: indexPath)
// Configure Cell
cell.contentView.backgroundColor = .green
return cell
}
Build and run the application in the simulator.
Adding More View Controllers
It's time to add the view controllers for each root view type:
- day
- now
- week
Each collection view cell of the collection view contains a view controller. For that, we need to create three UICollectionViewCell
subclasses and three UIViewController
subclasses.
Open the Project Navigator and create three groups and corresponding folders in the View Controllers group. Name them:
- Day View Controller
- Now View Controller
- Week View Controller
Create three UIViewController
subclasses and add each subclass to its corresponding group:
DayViewController
NowViewController
WeekViewController
The implementation of these view controllers is very basic for now.
import UIKit
class DayViewController: UIViewController {
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
// MARK: - View Methods
private func setupView() {
}
}
import UIKit
class NowViewController: UIViewController {
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
// MARK: - View Methods
private func setupView() {
}
}
import UIKit
class WeekViewController: UIViewController {
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
// MARK: - View Methods
private func setupView() {
}
}
Subclassing UICollectionViewCell
Add a group and corresponding folder named Collection View Cells to the Root View Controller group. We need to create a UICollectionViewCell
subclass for every root view type.
DayCollectionViewCell
NowCollectionViewCell
WeekCollectionViewCell
The implementation is very similar and easy to understand. We declare a static property for the reuse identifier of the UICollectionViewCell
subclass and a constant property, viewController
, for the UIViewController
subclasses we created earlier.
In the initializers, we instantiate the view controller. We also invoke setupViewController()
, a helper method in which we configure the view controller.
import UIKit
class DayCollectionViewCell: UICollectionViewCell {
// MARK: - Type Properties
static let reuseIdentifier = "DayCollectionViewCell"
// MARK: - Properties
let viewController: DayViewController
// MARK: - Initialization
override init(frame: CGRect) {
// Initialize View Controller
viewController = DayViewController()
super.init(frame: frame)
setupViewController()
}
required init?(coder aDecoder: NSCoder) {
// Initialize View Controller
viewController = DayViewController()
super.init(coder: aDecoder)
setupViewController()
}
private func setupViewController() {
// Configure View Controller
viewController.view.backgroundColor = .blue
// Add View Controller to Content View
contentView.addSubview(viewController.view)
if let view = viewController.view {
view.translatesAutoresizingMaskIntoConstraints = false
view.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0.0).isActive = true
view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 0.0).isActive = true
view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 0.0).isActive = true
view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0.0).isActive = true
}
}
}
The only difference between the UICollectionViewCell
subclasses is the reuse identifier, the type of the viewController
property, and the background color of the view controller.
It's now time to update the RootViewController
class. We start by updating the setupCollectionView()
method.
private func setupCollectionView() {
collectionView.register(NowCollectionViewCell.classForCoder(), forCellWithReuseIdentifier: NowCollectionViewCell.reuseIdentifier)
collectionView.register(DayCollectionViewCell.classForCoder(), forCellWithReuseIdentifier: DayCollectionViewCell.reuseIdentifier)
collectionView.register(WeekCollectionViewCell.classForCoder(), forCellWithReuseIdentifier: WeekCollectionViewCell.reuseIdentifier)
}
This allows us to update the collectionView(_:cellForItemAt:)
method of the UICollectionViewDataSource
protocol.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let type = RootViewType(rawValue: indexPath.item) else {
fatalError()
}
switch type {
case .day:
// Dequeue Reusable Cell
return collectionView.dequeueReusableCell(withReuseIdentifier: NowCollectionViewCell.reuseIdentifier, for: indexPath)
case .now:
// Dequeue Reusable Cell
return collectionView.dequeueReusableCell(withReuseIdentifier: DayCollectionViewCell.reuseIdentifier, for: indexPath)
case .week:
// Dequeue Reusable Cell
return collectionView.dequeueReusableCell(withReuseIdentifier: WeekCollectionViewCell.reuseIdentifier, for: indexPath)
}
}
We create an instance of the RootViewType
enum and dequeue a collection view cell based on the value of the enum. Notice that we throw a fatal error if we're unable to create a RootViewType
instance. You can read more about the why and how here.
If you build and run the application, you should see three collection view cells, each with a different color. This is a sign that we're on the right track.
Sizing the Collection View Cells
The last piece of this puzzle is correctly setting the size of the collection view cells. Even though the user interface looks different on iPhone and iPad, it's more correct to say that the user interface looks different depending on the size class.
If the size class for the horizontal dimension is compact, the collection view cell spans the width of the collection view. If the size class for the horizontal dimension is regular, the collection view cell spans a third of the width of the collection view. To make this work, we implement four methods of the UICollectionViewDelegateFlowLayout
protocol.
extension RootViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let bounds = collectionView.bounds
return CGSize(width: (bounds.width * aspectRatio), height: bounds.height)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return minimumInteritemSpacingForSection
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return minimumLineSpacingForSection
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
return insetForSection
}
}
As you can see, I've used several helper properties to clean up the implementation of these methods. I try to avoid hardcoded values in method implementations as much as possible.
// MARK: - Properties
@IBOutlet var collectionView: UICollectionView!
// MARK: -
fileprivate var aspectRatio: CGFloat {
switch traitCollection.horizontalSizeClass {
case .compact:
return 1.0
default:
return 1.0 / 3.0
}
}
fileprivate let minimumInteritemSpacingForSection: CGFloat = 0.0
fileprivate let minimumLineSpacingForSection: CGFloat = 0.0
fileprivate let insetForSection = UIEdgeInsets()
The most interesting property is aspectRatio
, a computed property whose value is based on the horizontal size class of the view controller's traitCollection
property.
If you build and run the application, you notice that everything looks fine ... sort of. There's one issue we need to address. We need to update the layout of the collection view when the size of the view controller's view changes. We can do this by overriding a method of the UIContentContainer
protocol, a protocol every UIViewController
conforms to. The method we need to override is viewWillTransition(to:with:)
.
// MARK: - Content Container Methods
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
collectionView.collectionViewLayout.invalidateLayout()
}
Whenever the size of the view changes, we invalidate the layout of the collection view layout. This ensures the collection view is updated correctly. Build and run the application again. Rotate the device a few times to make sure everything is working as expected.
What's Next?
With a basic user interface in place, it's time to start focusing on populating the application with data. We do that in the next installment.