Note: We released this framework on GitHub
UITableView
is the defacto tool to help us create rows of cells when developing our iOS apps. The typical configuration consists of having a NSObject
(s) conform to the UITableViewDataSource
and UITableViewDelegate
protocols, implementing the boilerplate required methods, and then registering the necessary UITableViewHeaderFooterView
and UITableViewCell
types with the UITableView
.
That configuration will suffice the majority of the time, especially when your UITableView
only consists of a single cell type, but what happens if we want to handle a more complex configuration? What if our UITableView
is to consist of 10+ cell types, custom section header/footer views with various actions and heights? The logic to determine how to handle each case within the implementation of datasource/delegate methods would quickly get out of hand.
Within the OkCupid app, weโre faced with these kinds of situations constantly. Here is an example of a typical implementation one might find of UITableViewDataSource
's func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
:
class AdvertisementCell: UITableViewCell {}
class MatchCell: UITableViewCell {}
class QuestionCell: UITableViewCell {}
protocol Model {}
struct Advertisement: Model {}
struct Match: Model {}
struct Question: Model {}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let model = models[indexPath.item]
if let advertisement = model as? Advertisement {
// Dequeue, configure and return AdvertisementCell
} else if let match = model as? Match {
// Dequeue, configure and return MatchCell
} else if let question = model as? Question {
// Dequeue, configure and return QuestionCell
}
}
As we continue to add more Model types to be displayed by their corresponding UITableViewCell
s, our code grows linearly. Each case has its own separate dequeuing and configuration logic. This sort of pattern can make our code extremely difficult to reason about and harder to maintain the more Model/Cell types we introduce.
Implementing UITableViewDataSource/UITableViewDelegate by simply defining our logic within the datasource/delegate methods is in my opinion inadequate for multi cell/model type configurations. With this in mind, I set out to create an abstraction that would help me implement UITableView
s in a much simpler way.
Introducing OKTableViewLiaison
! OKTableViewLiaison
is a framework that abstracts away all the traditional UITableViewDataSource
and UITableViewDelegate
boilerplate, leaving you with a clean, modular, and scalable API for you to build your UITableView
s.
The framework revolves around interacting with a single object, the OKTableViewLiaison
, which under the hood conforms to the UITableViewDataSource
and UITableViewDelegate
protocols, and handles the inserting, deleting, and moving of Rows and Sections within your UITableView
.
To illustrate the power of OKTableViewLiaison
, I'm going to step by step show you how to build an Instagram/Reddit like content feed, simply using a UITableView
, UITableViewHeaderFooterView
, UITableViewCell
s and the OKTableViewLiaison
framework.
Let's start by creating a new Single View App
project using Swift
in Xcode
. The easiest way to incorporate OKTableViewLiaison
into your project is via Cocoapods
.
pod 'OKTableViewLiaison'
Carthage
support is coming soon.
Our feed is going to display a collection of Posts, with each consisting of a header denoting who created it, a piece of content in the form of an image, a group of action buttons and text stating a caption for the image, the total amount of comments and likes the post received, and a timestamp of when the post was created.
We can model our Post by creating the following Structs:
struct User {
let username: String
let avatar: UIImage
}
struct Post {
let user: User
let content: UIImage
let numberOfLikes: UInt
let caption: String
let numberOfComments: UInt
let timePosted: TimeInterval
}
To ensure maximum reusability and flexibility of our UI, lets represent a Post as a UITableView
Section, where each component is either a UITableHeaderFooterView
or UITableViewCell
. This way instead of building a Post as one giant UITableViewCell
, we can reorder, remove and add components as product requirements change.
Our Post Section is going to have a custom header view, PostTableViewSectionHeaderView, that is simply going to contain a UIImageView and UILabel to display the User avatar and username.
final class PostTableViewSectionHeaderView: UITableViewHeaderFooterView {
@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
imageView.layer.masksToBounds = true
imageView.contentMode = .scaleAspectFit
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.layer.cornerRadius = imageView.frame.height / 2
}
}
OKTableViewLiaison
represents supplementary header/footer views using the class OKTableViewSectionComponent<View: UITableViewHeaderFooterView, Model>
type.
OKTableViewSectionComponent
utilizes two generic types:
View: UITableViewHeaderFooterView
is the type of UITableViewHeaderFooterView
used for the view.
Model
is a type used to help configure the view.
Now that we have the custom section header, lets create our component as a subclass of OKTableViewSectionComponent
:
final class PostTableViewSectionHeaderViewComponent: OKTableViewSectionComponent<PostTableViewSectionHeaderView, User> {
public init(user: User) {
super.init(user, registrationType: PostTableViewSectionHeaderViewComponent.defaultNibRegistrationType)
set(height: .height, 70)
set(command: .configuration) { view, user, section in
view.imageView.image = user.avatar
view.imageView.layer.borderColor = UIColor.gray.cgColor
view.imageView.layer.borderWidth = 1
view.titleLabel.text = user.username
view.titleLabel.font = .systemFont(ofSize: 13, weight: .medium)
view.titleLabel.textColor = .black
}
}
}
We first declare PostTableViewSectionHeaderViewComponent as a subclass of OKTableViewSectionComponent<PostTableViewSectionHeaderView, User>
.
We then proceed to define our initializer which takes a User instance as a parameter. Within our initializer, we make a call to super.init
where we additionally pass in a OKTableViewRegistrationType
.
OKTableViewRegistrationType
is an enumeration that denotes how the OKTableViewLiaison
should register a UITableViewCell
or UITableViewHeaderFooterView
type with a UITableView
. Since we created the PostTableViewSectionHeaderView
using a xib
, and that xib
s name is the same as its related class, we can simply pass the .defaultNibRegistrationType
as the OKTableViewRegistrationType
.
Lastly, we set the height of our header and define its .configuration
closure. OKTableViewSectionComponentCommand
represent lifecycle events of our header/footer views. The command closure we define passes through an instance of our View
, the Model
object, and the index of the section within the UITableView
. The .configuration
closure will be invoked every time the UITableView dequeues the sections supplementary view.
Now that the PostTableViewSectionHeaderViewComponent
has been defined, lets get started building the Rows for our Post. To create a Row using OKTableViewLiaison
, we use the following type:
class OKTableViewRow<Cell: UITableViewCell, Model>
Similar to OKTableViewSectionComponent
, OKTableViewRow
utilizes two generic types:
Cell: UITableViewCell
is the type of UITableViewCell used for the row.
Model
is a type used to help configure the cell for the row.
The first row presented in the section is for the content image. Here we're going to create a and UITableViewCell that contains a single UIImageView pinned to its edges.
final class ImageTableViewCell: UITableViewCell {
@IBOutlet weak var contentImageView: UIImageView!
}
final class ImageTableViewRow: OKTableViewRow<ImageTableViewCell, (UIImage, CGFloat)> {
init(image: UIImage, width: CGFloat) {
super.init((image, width), registrationType: ImageTableViewRow.defaultNibRegistrationType)
set(height: .height) { model -> CGFloat in
let (image, width) = model
let ratio = image.size.width / image.size.height
return width / ratio
}
set(command: .configuration) { cell, model, indexPath in
cell.contentImageView.image = model.0
cell.contentImageView.contentMode = .scaleAspectFill
}
}
}
The class declaration for our custom OKTableViewRow
is quite similar to our OKTableViewSectionComponent
.
We define an initializer that takes two parameters, a UIImage and a CGFloat representing the width of the UITableView
(more on the width in a second).
Instead of passing a static value to determine the height of our cell, we compute the height using a closure. The goal here is to maintain the correct aspect ratio for each content image. By passing in the width of our UITableView, we can derive the correct height.
Lastly, we configure our cell by setting the .configuration
closure. The closure passes through an instance of Cell
, its Model
object, and the IndexPath
of the row.
There are over 15 different OKTableViewRowCommand
events you can set closures for including didSelect
, delete
, insert
, move
and reload
.
Next we will implement a dummy action buttons row to show like, comment, share and bookmark functionality using emojis ๐!
final class ActionButtonsTableViewCell: UITableViewCell {
@IBOutlet weak var likeButton: UIButton!
@IBOutlet weak var commentButton: UIButton!
@IBOutlet weak var messageButton: UIButton!
@IBOutlet weak var bookmarkButton: UIButton!
}
final class ActionButtonsTableViewRow: OKTableViewRow<ActionButtonsTableViewCell, Void> {
init() {
super.init((), registrationType: ActionButtonsTableViewRow.defaultNibRegistrationType)
set(height: .height, 30)
set(command: .configuration) { cell, _, _ in
cell.likeButton.setTitle("โค๏ธ", for: .normal)
cell.commentButton.setTitle("๐ฌ", for: .normal)
cell.messageButton.setTitle("๐ฎ", for: .normal)
cell.bookmarkButton.setTitle("๐", for: .normal)
cell.selectionStyle = .none
}
}
}
The last portion of our Post is the text. We can break down the text into four different Rows; Likes, Caption, Comments and Time. Other than the text color, font, and the actual text, the only real difference between each row are their heights. Previously we set the height of each row using a static value or computing it with a closure. In this case, we need the height to be dynamic based on the size of its text. No problem! By default, OKTableViewRow will return a height of UITableViewAutomaticDimension
if one is not specified, allowing us to setup our UITableViewCell
to be self-sizing. We can also additionally set the rows estimated height using OKTableViewHeightType.estimatedHeight
if need be.
final class TextTableViewCell: UITableViewCell {
@IBOutlet weak var contentTextLabel: UILabel!
override func prepareForReuse() {
super.prepareForReuse()
contentTextLabel.text = nil
contentTextLabel.textColor = .black
contentTextLabel.attributedText = nil
}
}
final class TextTableViewRow: OKTableViewRow<TextTableViewCell, String> {
init(text: String) {
super.init(text,
registrationType: TextTableViewRow.defaultNibRegistrationType)
}
}
extension TimeInterval {
var timeText: String {
let seconds = Int(floor(self))
switch seconds {
case 0...60:
let text = "\(seconds) SECONDS AGO"
return truncatePlural(for: text, time: seconds)
case 60...3599:
let minutes = seconds / 60
let text = "\(minutes) MINUTES AGO"
return truncatePlural(for: text, time: minutes)
case 3600...86399:
let hours = seconds / 3600
let text = "\(hours) HOURS AGO"
return truncatePlural(for: text, time: hours)
default:
let days = seconds / 86400
let text = "\(days) DAYS AGO"
return truncatePlural(for: text, time: days)
}
}
private func truncatePlural(for text: String, time: Int) -> String {
if time == 1 {
return text.replacingOccurrences(of: "S", with: "")
}
return text
}
}
enum TextTableViewRowFactory {
static func likesRow(numberOfLikes: UInt) -> TextTableViewRow {
let row = TextTableViewRow(text: "\(numberOfLikes) likes")
row.set(command: .configuration) { cell, string, _ in
cell.contentTextLabel.font = .systemFont(ofSize: 13, weight: .medium)
cell.contentTextLabel.text = string
cell.selectionStyle = .none
}
return row
}
static func captionRow(user: String, caption: String) -> TextTableViewRow {
let row = TextTableViewRow(text: caption)
row.set(command: .configuration) { cell, caption, _ in
cell.contentTextLabel.numberOfLines = 0
cell.selectionStyle = .none
let mediumAttributes: [NSAttributedStringKey: Any] = [
.font: UIFont.systemFont(ofSize: 13, weight: .medium),
.foregroundColor: UIColor.black
]
let regularAttributes: [NSAttributedStringKey: Any] = [
.font: UIFont.systemFont(ofSize: 13),
.foregroundColor: UIColor.black
]
let attributedString = NSMutableAttributedString(string: user, attributes: mediumAttributes)
attributedString.append(NSMutableAttributedString(string: " \(caption)", attributes: regularAttributes))
cell.contentTextLabel.attributedText = attributedString
}
return row
}
static func commentRow(commentCount: UInt) -> TextTableViewRow {
let row = TextTableViewRow(text: "View all \(commentCount) comments")
row.set(command: .configuration) { cell, string, _ in
cell.contentTextLabel.font = .systemFont(ofSize: 13)
cell.contentTextLabel.text = string
cell.contentTextLabel.textColor = .gray
cell.selectionStyle = .none
}
return row
}
static func timeRow(numberOfSeconds: TimeInterval) -> TextTableViewRow {
let row = TextTableViewRow(text: numberOfSeconds.timeText)
row.set(command: .configuration) { cell, string, _ in
cell.contentTextLabel.font = .systemFont(ofSize: 10)
cell.contentTextLabel.text = string
cell.contentTextLabel.textColor = .gray
cell.selectionStyle = .none
}
return row
}
}
To accompany our TextTableViewRow
, we create a TextTableViewRowFactory
that defines four separate static functions to create each of our text rows. If we're taking into consideration resetting the cells properties on reuse, the TextTableViewCell
will be sufficient to display all the different text configurations.
Putting it all together:
Up to this point we've created the Models, a header OKTableViewSectionComponent
, and all the different OKTableViewRow
s we need to create our Post Section. To create a Section, we must use the following type:
class OKTableViewSection
OKTableViewSection
can be initialized with a [OKAnyTableViewRow]
and/or a OKTableViewSectionComponentDisplayOption
.
OKTableViewSectionComponentDisplayOption
is an enumeration that denotes which supplementary views a OKTableViewSection
should display. For our Post section, we only want to display a header using our previously defined PostTableViewSectionHeaderViewComponent
.
final class PostTableViewSection: OKTableViewSection {
init(user: User, rows: [OKAnyTableViewRow] = []) {
super.init(rows: rows, componentDisplayOption: .header(component: PostTableViewSectionHeaderViewComponent(user: user)))
}
}
Now that we defined our PostTableViewSection
, lets create a factory to more easily create out Post Sections!
enum PostTableViewSectionFactory {
static func section(for post: Post, width: CGFloat) -> PostTableViewSection {
let rows: [OKAnyTableViewRow] = [ImageTableViewRow(image: post.content,
width: width),
ActionButtonsTableViewRow(),
TextTableViewRowFactory.likesRow(numberOfLikes: post.numberOfLikes),
TextTableViewRowFactory.captionRow(user: post.user.username, caption: post.caption),
TextTableViewRowFactory.commentRow(commentCount: post.numberOfComments),
TextTableViewRowFactory.timeRow(numberOfSeconds: post.timePosted)]
return PostTableViewSection(user: post.user, rows: rows)
}
}
Here is the image of the bear you can use to create your Post. Feel free to make your Post about anything you'd like!
final class ContentFeedViewController: UIViewController {
@IBOutlet private weak var tableView: UITableView!
private let liaison = OKTableViewLiaison()
private func section(for post: Post) -> PostTableViewSection {
return PostTableViewSectionFactory.section(for: post, width: tableView.frame.width)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let post = Post(user: User(username: "dylan", avatar: #imageLiteral(resourceName: "dylan")),
content: #imageLiteral(resourceName: "bear"),
numberOfLikes: 64,
caption: "Bears are carnivoran mammals of the family Ursidae. They are classified as caniforms, or doglike carnivorans. Although only eight species of bears are extant, they are widespread, appearing in a wide variety of habitats throughout the Northern Hemisphere and partially in the Southern Hemisphere.",
numberOfComments: 25,
timePosted: 1644)
let bearPostSection = section(for: post)
liaison.liaise(tableView: tableView)
liaison.append(section: bearPostSection)
}
}
Voila! If you build and run your project, you'll see a UITableView with a single Post section displaying our Bear post. The bread and butter of the OKTableViewLiaison
lies within the liaise
function. By liaising a UITableView
with the OKTableViewLiaison
, the liaison
takes control as both the tableView
s UITableViewDataSource and UITableViewDelegate. Not only will all the UITableViewHeaderFooterView
and UITableViewCell
s be registered with the UITableView, you can now start manipulating the UITableView using the various OKTableViewLiaison functions, in this example we're simply appending a new section to our UITableView
.
Feel free to add more Posts to your UITableView
, or maybe introduce a completely new Section/Row type to display different types of content. The flexibility that OKTableViewLiaison
provides makes building your various UIs with UITableView
a breeze!
OKTableViewLiaison also comes equip to handle all of your pagination needs with a simple API with OKTableViewLiaisonPaginationDelegate
. For an in-depth version of this tutorial, check out the Example project found on GitHub, along with the source code.
If you found this article at all interesting/useful, please give the framework a star on GitHub.