Dylan Shine

OKTableViewLiaison: UITableView Made Simple ๐Ÿ™Œ

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 UITableViewCells, 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 UITableViews 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 UITableViews.

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, UITableViewCells 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.

Screen-Shot-2018-05-17-at-11.51.21-AM

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.

Screen-Shot-2018-05-17-at-11.50.12-AM

Screen-Shot-2018-05-17-at-11.57.13-AM

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 xibs 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.

Screen-Shot-2018-05-17-at-1.18.23-PM

Screen-Shot-2018-05-17-at-1.21.51-PM

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 ๐Ÿ˜œ!

Screen-Shot-2018-05-17-at-4.50.04-PM

Screen-Shot-2018-05-17-at-4.47.52-PM

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.

Screen-Shot-2018-05-17-at-5.50.45-PM

Screen-Shot-2018-05-17-at-5.51.18-PM

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 OKTableViewRows 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)
    }
    
}

Screen-Shot-2018-05-21-at-12.48.05-PM

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!

bear

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 tableViews UITableViewDataSource and UITableViewDelegate. Not only will all the UITableViewHeaderFooterView and UITableViewCells 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.