Back to blog

Working with Table View Controllers in Swift


Aasif Khan
By Aasif Khan | December 8, 2021 6:01 pm  | 5-min read

In this tutorial I’ll show you step-by-step how table view controllers work, and how you can use them. We’ll go into the full gamut of UITableViewController, by diving into Object-Oriented Programming, delegation and the behind-the-scenes mechanisms of table views.

A table view controller displays structured, repeatable information in a vertical list. You use the UITableViewController class in your iOS app to build a table view controller.

Working with a table view controller also means working with a few important iOS development concepts, such as subclassing, the delegation design pattern, and reusing views.

It’s important for professional and practical iOS developers (you!) to master working with table view controllers. Once you’ve gotten used to working on such a multifaceted UI component, like UITableViewController, other more complex aspects of iOS development will start to make sense too.

How a Table View Controller Works

If you’ve used any iOS app before, you’ve used table view controllers before. They’re used that frequently in iOS apps!

Here’s an example of a table view controller:

A table view controller typically has these visible components:

  • A table view, which is the user interface component, or view, that’s shown on screen. A table view is an instance of the UITableView class, which is a subclass of UIScrollView.
  • Table view cells, which are the repeatable rows, or views, shown in the table view. A table view cell is an instance of a UITableViewCell class, and that class is often subclassed to create custom table view cells.

A table view controller also relies on the use of these components, behind-the scenes:

  • A table view delegate, which is responsible for managing the layout of the table view and responding to user interaction events. A table view delegate is an instance of the UITableViewDelegate class.
  • A table view data source, which is responsible for managing the data in a table view, including table view cells and sections. A data source is an instance of the UITableViewDataSource class.

A navigation controller is often used in conjuction with a table view controller to enable navigation between the table view and subsequent view controllers, and to show a navigation bar above the table view.

The most interesting part of working with table view controllers is the table view controller itself! How so?

Are you familiar with the Model-View-Controller architecture? As per the Model-View-Controller architecture, a table view and a table view cell are views, and a table view controller is a controller.

Views are responsible for displaying information visibly to the user, with a user interface (UI). Controllers are responsible for implementing logic, managing data and taking decisions. Said differently: you can’t see a controller, but it’s there, managing what you see through views.

When you use a table view controller in your app, you’re subclassing the UITableViewController class. The UITableViewController class itself is a subclass of UIViewController.

Here’s the class hierarchy of an example table view controller that displays contact information:

  • an instance of ContactsTableViewController
  • subclasses UITableViewController
  • subclasses UIViewController

As per the principles of Object-Oriented Programming (OOP), when class RaceCar subclasses class Vehicle, it inherits the properties and functions of that superclass, such as maxSpeed and drive().

The Vehicle class is then called the superclass of RaceCar. This principle is called inheritance.

Confusing? It can be! Think about it like this: in order for your table view controller to work OK, you’ll need to inherit a bunch of code, so you don’t have to write all that code yourself. The class hierarchy, and OOP, is there to structure that inheritance.

You can work with table views without using a table view controller. Simply add a UITableView to a view controller, provide it with implementations of the table view delegate and data source functions, and you’re done.

The UITableViewController class provides default implementations of these table view delegate and table view data source functions. That’s a crucial aspect of working with a table view controller!

As you’ll see in the next chapters of this tutorial, we’ll override these functions with our own implementations. We can customize the table view controller by doing that.

Setting Up a Simple Table View Controller

Alright, let’s put all that into practice. In this section, we’re going to build a simple table view controller. You’re going to implement the functions needed to make it work, and I’ll explain how they work as we go along. Let’s get a move on!

You can use 3 different approaches of working with UIs in Xcode:

  1. Creating views programmatically, i.e. coding them by hand
  2. Setting up UIs in Interface Builder and connecting them with Swift code via XIBs
  3. Setting up UIs and their transitions in Interface Builder using Storyboards
  4. (Technically, you can use SwiftUI too, but as for table view controllers, that’s beyond the scope of this tutorial.)

It’s tedious and unproductive to code UIs by hand. If you’re not careful with Storyboards, they end up hiding complexity from the developer. We’re going to work with Storyboards, while figuring out exactly how they work behind the scenes.

Here’s what you’re going to do:

First, create a new Xcode project via File → New → Project…. Make sure to choose the App template, and pick Storyboard for Interface and UIKit App Delegate for Life Cycle.

Then, take these steps:

  1. Right-click your project in the Project Navigator and choose New File…
  2. Choose the Cocoa Touch Class template (iOS)
  3. Choose UITableViewController for Subclass of
  4. Name the class ContactsTableViewController
  5. Don’t tick Also create XIB file
  6. Finally, click Next and save the file alongside the other Swift files

Lastly, do this:

  1. Go into Main.storyboard and remove the existing View Controller Scene
  2. Add a new Table View Controller to the storyboard via the Library
  3. With the table view controller selected, go to the Attributes Inspector and tick the Is Initial View Controller checkbox
  4. Finally, go to the Identity Inspector and set Class to ContactsTableViewController

That’s it! You now have a table view controller inside the project’s storyboard, and you’ve connected it to the ContactsTableViewController Swift class.

As you’ve guessed, your ContactsTableViewController class is a subclass of UITableViewController. You can see that in the Swift file, at the top, in the class declaration.

class ContactsTableViewController: UITableViewController {
This syntax means: the class ContactsTableViewController is a subclass of UITableViewController.

When you right-click on “UITableViewController” while holding the Option-key, you can see in the class declaration that UITableViewController is a subclass of UIViewController, and conforms to the UITableViewDelegate and UITableViewDataSource protocols.

That’s the power of the table view controller! It doesn’t just give us the individual components to make a table view, the controller also provides a default implementation. That’s why we subclass UITableViewController, instead of creating an empty view controller with a table view.

At this point, you can run your app with Command-R or the Play button, and see the empty table view controller appear on screen.

Why is that, by the way? We haven’t coded anything yet! That’s because the table view controller has a default implementation, that just shows empty cells on screen. Neat!

Depending on your version of Xcode, we’ve used the scene delegate to set up the initial view controller of your app project. Learn more here: The Scene Delegate in Xcode

A XIB and a NIB are basically the same thing – they contain layout information. A XIB has an XML format, whereas a NIB has a binary format. The XML is compiled to binary when you build your app, so that’s why UIKit’s functions always talks about a “nib”, while Xcode always calls it a “xib”.

Coding The Table View Controller Data Source

Now that your table view controller has been set up, let’s bring it to life! In this chapter, we’ll focus on the different functions you’ll need to implement to make your table view controller work.

As explained earlier, these functions either belong to the table view controller delegate, or the table view controller data source. Both of these protocols use delegation to customize the table view.

The most important functions for UITableViewDataSource are:

  • numberOfSections(in:)
  • tableView(_:numberOfRowsInSection:)
  • tableView(_:cellForRowAt:)

Other relevant functions for UITableViewDelegate are:

  • tableView(_:didSelectRowAt:)
  • tableView(_:willDisplay:for:)

You can find more functions in the Apple Developer Documentation for UITableViewDelegate and UITableViewDataSource.

Adding The Contacts Data

You’re going to start by adding the contact information data for the table view controller. Add the following property to the ContactsTableViewController class:

let contacts:[[String]] = [
[“Elon Musk”, “+1-201-3141-5926”],
[“Bill Gates”, “+1-202-5358-9793”],
[“Tim Cook”, “+1-203-2384-6264”],
[“Richard Branson”, “+1-204-3383-2795”],
[“Jeff Bezos”, “+1-205-0288-4197”],
[“Warren Buffet”, “+1-206-1693-9937”],
[“The Zuck”, “+1-207-5105-8209”],
[“Carlos Slim”, “+1-208-7494-4592”],
[“Bill Gates”, “+1-209-3078-1640”],
[“Larry Page”, “+1-210-6286-2089”],
[“Harold Finch”, “+1-211-9862-8034”],
[“Sergey Brin”, “+1-212-8253-4211”],
[“Jack Ma”, “+1-213-7067-9821”],
[“Steve Ballmer”, “+1-214-4808-6513”],
[“Phil Knight”, “+1-215-2823-0664”],
[“Paul Allen”, “+1-216-7093-8446”],
[“Woz”, “+1-217-0955-0582”]
]

That’s a rolodex we’d all like to have, right? Here’s how it works:

  • The let contacts declares a constant with name contacts. You’ve added it as a property to the class, so every class instance has access to this constant throughout the class’ code.
  • The type of contacts is [[String]], which is array of arrays of strings. You’re essentially creating an array, of which the items are arrays of strings. (A variable name and its type are separated with a colon 🙂
  • The = [ … ] code assigns an array literal to contacts, filled out with the names and phone numbers of a few billionaires.

At a later point, we can use the number of items in the array with contacts.count. And we can get individual names and phone numbers with contacts[i][0] and contacts[i][1], using subscript syntax.

Registering a Table View Cell Class

Before you can use cells in a table view controller, you’ll need to register them with the table view. You can do so in two ways:

  1. By providing a table view cell class and an identifier
  2. By providing a table view cell XIB and an identifier

When you’re using a custom table view cell, you most likely want to register a XIB for that. When you’re using the default table view cells, or some other programmatic cell, you register the class. We’ll use the class, for now!

Add the following code to the viewDidLoad() function:

tableView.register(UITableViewCell.self, forCellReuseIdentifier: “cellIdentifier”)

Make sure to add it below the super.viewDidLoad() line. As you probably know, the viewDidLoad() function is part of the view controller life-cycle, and belongs to the UIViewController class.

You’re overriding the viewDidLoad() function to respond to this event in the life-cycle of a view controller, so you can set up your view after it has been loaded. In our case, we’re using the function to register the table view cell.

When you register a table view cell, you also have to provide an identifier. This is simply to associate the class of the cell with a name you can use later, when dequeuing the cell in tableView(_:cellForRowAt:).

Are you still with me? Let’s move on!

Implementing “numberOfSections(in:)”

The first delegate function we’re going to implement is numberOfSections(in:).

A table view can have multiple sections or groups. Every group has a header that floats on top of the vertical row of cells. In a Contacts app, you could group contacts together alphabetically. This is actually done in the Contacts app on iPhone, where contacts are grouped A-Z.

The app we’re building has just one section. Add the following function to the class:

override func numberOfSections(in tableView: UITableView) -> Int
{
return 1
}

Simple, right? The function returns 1 when called.

Implementing “tableView(_:numberOfRowsInSection:)”

A similar function is tableView(_:numberOfRowsInSection:). Instead of providing the number of sections, it provides the number of rows in a section. Because a table view shows cells in a vertical list, every cell corresponds to a row in the table view.

The app we’re building has just one section, and that one section has a number of items equal to the number of items in the contacts array. So, that’s contacts.count!

Add the following function to the class:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
{
return contacts.count
}

See how that works? We simply return contacts.count. If you were to add another name and phone number to contacts, it would show up nicely in the table view too.

Understanding Rows and Sections

Our Contacts app is one-dimensional, it just shows one list of names and phone numbers, and it doesn’t use groups. But what if you have a grouped table view?

In most cases, your data source, like the contacts array, would be multi-dimensional too. You’d organize groups on the first level, and individual items on the second level, “below” the groups.

Like this:

– Countries
– A
– Afghanistan
– Albania
– …
– B
– Bahamas
– Bahrain
– …
– C
– Cambodia
– Cameroon
– …

The number of groups is equal to countries.count, and the number of countries in a single group is equal to countries[x].count, where x is the section index. That section index is provided as a parameter in tableView(_:numberOfRowsInSection:).

Did you notice how these two functions have a parameter called tableView? That’s part of the Object-Oriented Programming principle. You can technically use a table view data source and delegate to customize multiple table views. You’d use the tableView to identify which table view you are working with.

Imagine you have a Contacts app that can show phone numbers by name, or phone numbers organized by company. You could implement that in multiple ways, for instance by reusing your table view controllers. Or what if you want to reuse the layout of your Contacts app to display similar information, like restaurants, venues or Skype usernames? That’s where code re-use with OOP comes in!

Providing Cells to The Table View Controller

We’re getting there! Let’s move on to the most important function of a table view controller: tableView(_:cellForRowAt:).

We’ll implement the function before diving into the details, but there’s a couple things you need to understand about it:

  • When it’s called
  • What an index path is
  • How it re-uses cells

First, add the following function to the ContactsTableViewController class:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
{

}

Here’s how it works:

  • The function overrides its superclass implementation from UITableViewController. By now you know how that works, right? We’re overriding the default implementation, and substitute our own. That’s because UITableViewController has already implemented the table view delegate and data source for us.
  • Like before, the function has one parameter tableView that we can use to identify the table view that this function is called on.
  • Another parameter is indexPath, with argument label cellForRowAt. The index path identifies the cell’s row and section indices. More on that later.
  • The function return type is UITableViewCell. Hey, that’s interesting. This function is called by the table view controller, every time we need to provide a table view cell!

When you scroll through the contacts in this app, every time a cell needs to be displayed on screen, the function tableView(_:cellForRowAt:) is called. Every time! I’ll prove it to you in a moment.

Next up, let’s write the function body. Add the following code inside the function:

let cell = tableView.dequeueReusableCell(withIdentifier: “cellIdentifier”, for: indexPath)

print(“\(#function) — section = \(indexPath.section), row = \(indexPath.row)”)

cell.textLabel?.text = contacts[indexPath.row][0]

return cell

Here’s what happens:

  • First, we dequeue a cell with an identifier. It’s exactly the same identifier we used before, when registering the cell. That way the table view knows what type of cell we want. The dequeued cell is assigned to the cell constant. Now we have a table view cell to work with. More on dequeuing later.
  • Then, we print out some information to the Console. This is so we can see when this function is called, when the app runs.
  • Then, we assign the name of the contact to the text label of this table view cell. The contacts[indexPath.row][0] contains the value of the name of the contact, which we get to by using indexPath.row. Every instance of UITableViewCell has a property textLabel of UILabel, and every label of that type has a property text. You use it to set the text on the label.

Don’t worry, we’ll go over each of these things in more detail. First, see if you can run your app. Do you see contact names? Do you see the debug output in the Console? Try scrolling the app!

When is “tableView(_:cellForRowAt:)” Called?

If you ran the Contacts app, and played around with scrolling up and down, you can’t help but notice that every time you scroll, debug output shows up in the Console.

Every time a cell that wasn’t on screen before appears, the function tableView(_:cellForRowAt:) is called, and a new line appears in the Console.

So when is tableView(_:cellForRowAt:) called? Every time a table view cell needs to be shown on screen!

The table view controller has determined that a cell is needed, so it calls tableView(_:cellForRowAt:). Our implementation of that function dequeues a cell, changes it, and provides it back to the table view controller. The table view controller, and the UIKit framework, then renders it graphically on screen.

What is an Index Path?

Every time the table view controller needs a cell from tableView(_:cellForRowAt:), it provides an index path as an argument for the function. Within the function body you can use the parameter indexPath to know exactly which cell the table view controller needs.

An index path is like an address, or a coordinate in a grid. A typical graph has an X axis and a Y axis, so you could express a coordinate in that graph as x, y like 0, 1 and 42, 3. Similarly, a spreadsheet has rows and columns with indices.

A table view uses sections and rows. As discussed before, you can use sections to group cells together. Our app only has one section, and it has contacts.count rows. The rows of the table view run from top to bottom.

Said differently: the sections and rows of a table view are what columns and rows are to a spreadsheet. An index path defines a location in the table view, by using a row and a section.

The rows and sections are represented by numbers, called indices. These indices start at zero, so the first row and section will have index number 0.

When you look back at the previous screenshot, it makes much more sense. The first cell has index path 0, 0, the second cell 0, 1, continuing up to the last visible cell with index path 0, 11.

The Table View Reuse Mechanism

What’s most noteworthy about the table view is its mechanism for cell reuse. It’s quite simple, actually.

  • Every time a table view controller needs to show a cell on screen, the function tableView(_:cellForRowAt:) is called, as we’ve discussed before.
  • Instead of creating a new table view cell every time that function is called, it picks off a previously created cell from a queue.
  • The cell resets to an empty state, clears its appearance, and the cell is customized again in tableView(_:cellForRowAt:).
  • Whenever a cell is scrolled off-screen, it’s not destroyed. It’s added to the queue, waiting to be reused.

It’s quite clever, right? Instead of creating and deleting cells, you simply reuse them. But… why?

It’s much less memory intensive to reuse cells. The table view controller would constantly write to memory when creating and deleting cells. Managing the memory would also be more intensive. When cells are reused, memory is used more efficiently, and less memory operations are needed.

Also, itt’s slightly less CPU intensive to reuse cells instead of creating and deleting them, because there simply are less operations involved in reusing, compared to creating and deleting cells.

When you’re quickly scrolling through a table view, you’re not seeing new cells – you’re seeing the same cells over and over again, with new information.

The code involved with cell reuse is this:

let cell = tableView.dequeueReusableCell(withIdentifier: “cellIdentifier”, for: indexPath)

The function dequeueReusableCell(withIdentifier:) attempts to dequeue a cell. When no cells are in the queue, it will create a cell for us. The identifier is used to keep every type of cell on their own queue, and to make sure the correct class is used to create new cells.

Responding to User Interaction

One thing is missing from our table view controller: the ability to call people in our contacts list! But before we do that, let’s make sure you can also see a contact’s phone number in the table view controller.

The default UITableViewCell class has 4 different types, as expressed in the UITableViewCellStyle enumeration. You can choose between:

  • .default – a simple view with one line of black text
  • .value1 – a simple view with one line of black text on the left, and a small blue label on the right (used in the Settings app)
  • .value2 – a simple view with one line of black text on the right, and a small blue label on the left (used in the Contacts app)
  • .subtitle – a simple view with one line of black text, and a smaller line of gray text below it

Most developers use custom table view cells these days, so you won’t see these cell types that often. But they’re there!

We have to slightly adjust the code in tableView(_:cellForRowAt:). Replace the first line of the function with the following code:

var cell = tableView.dequeueReusableCell(withIdentifier: “cellIdentifier”)

if cell == nil
{
cell = UITableViewCell(style: .subtitle, reuseIdentifier: “cellIdentifier”)
}

If you look closely, you’ll see that we’ve removed the for: indexPath part of the dequeueReusableCell(…) call. Instead, that function now returns an optional. When it can’t dequeue a cell, the function returns nil.

We then jump in ourselves to create the cell, if it’s nil. You see that in the second part of the code. You use a conditional if statement to check if cell is equal to nil, and if that’s true, you create the cell using the UITableViewCell(style:reuseIdentifier:) initializer.

That initializer gets two arguments, the cell style .subtitle, and the identifier we used earlier.

At this point we have a problem, because cell is an optional now! Its type is UITableViewCell?, but the function return type demands that we return an instance with non-optional type UITableViewCell.

Fortunately, this is one of those instances where we can safely use force unwrapping to unwrap the optional value. Because of the way our code is written, it’s impossible for cell to be nil beyond the conditional. You can guarantee that cell is not nil after the if statement.

Make sure to update the function to use force unwrapping for cell. Also, add the following line of code below cell!.textLabel … to set the subtitle of the cell show the phone number of the contact.

cell!.detailTextLabel?.text = contacts[indexPath.row][1]

The entire function now looks like this:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
{
var cell = tableView.dequeueReusableCell(withIdentifier: “cellIdentifier”)

if cell == nil {
cell = UITableViewCell(style: .subtitle, reuseIdentifier: “cellIdentifier”)
}

print(“\(#function) — section = \(indexPath.section), row = \(indexPath.row)”)

cell!.textLabel?.text = contacts[indexPath.row][0]
cell!.detailTextLabel?.text = contacts[indexPath.row][1]

return cell!
}

Finally, make sure to remove the following line from viewDidLoad(). It’ll prevent the table view from initializing cells with the wrong type.

tableView.register(UITableViewCell.self, forCellReuseIdentifier: “cellIdentifier”)

Mighty fine! Run your app with Command + R or the Play button, and check if it works. Do you see names and phone numbers? Good!

Then, for the pièce-de-résistance, let’s add that user interaction function. Now that you’ve learned the intricacies of the table view controller, I think you already know how this next function works.

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
{
if let url = URL(string: “tel://” + contacts[indexPath.row][1])
{
UIApplication.shared.open(url)
}
}

Again, we’re overriding the default implementation of the tableView(_:didSelectRowAt:) function. This function is called when a user taps the cell of a table view, and it belongs to the UITableViewDelegate protocol. Like the other functions, it’s provided the index path of the cell that’s tapped.

In the function body, we’re simply creating a tel:// URL from the phone number. We then tell the app to open that URL, which effectively tells iOS to initiate a call to this number. That code is only there for illustrative purposes. Note that this doesn’t work on iPhone Simulator, and that the numbers in our app are fake. (Still, you’d use this code if you’re making a real Contacts app!)

You can add the following code to the function if you want to check whether it works OK.

print(“\(#function) — Calling: \(contacts[indexPath.row][1])”)

This will print out a debug message when you tap the cell of the table view.

Further Reading

And that’s all there is to it! It’s been quite a trip, but now you know how a table view controller works.

Aasif Khan

Head of SEO at Appy Pie

App Builder

Most Popular Posts