Creating a Simple Reactive Table View

Introduction

You’d be hard-pressed to build an app these days that doesn’t need at least one table view (or collection view, but that’s for another time). So in this post, I will go over how to create a simple reactive table view using RxSwift and RxCocoa.

If you’re new to Rx, I highly encourage you to head over to RxSwift’s home page and read the Getting Started guide, download the repo, and review the example code in the Rx.playground and RxExample project. These are excellent resources!

Getting started

I’ll start off with a single-view app, in which I’ve installed RxSwift and RxCocoa via CocoaPods, and added a table view with a prototype table view cell.

You can clone the demo project from the GitHub project badge at the top and follow along by checking out the specified branch. You can check out a branch in Xcode by selecting Source Control > CapturingReferenceTypes > Switch Branch… from the menu. Because Interface Builder files may automatically be modified by Xcode simply by selecting them, you may need to select a non-Interface Builder file before switching to a different branch, and then discard (or commit) changes, which you can also do from the Source Control menu item.

01_Install_Rx_and_add_table_view

Nothing special here, just a simple table view and prototype cell, with outlets added to their associated class definitions. Next, I’ll create a data model and an Rx data source, and bind the data source to the table view.

Configuring a table view using Rx

02_Implement_Rx_table_view_1
enum SurveyQuestion: String {

case Age = "How old are you?"
case Hometown = "Where did you grow up?"
case Food = "What is your favorite food?"
case Superhero = "Who is your favorite superhero?"

static let allValues: [SurveyQuestion] = [.Age, .Hometown, .Food, .Superhero]

}

First, I created a simple data model using an enumeration to express a few survey questions.

let dataSource$ = Observable.just(SurveyQuestion.allValues)
let disposeBag = DisposeBag()

override func viewDidLoad() {
super.viewDidLoad()

dataSource$
.bindTo(tableView.rx_itemsWithCellIdentifier("SurveyQuestionCell")) { (row, surveyQuestion: SurveyQuestion, cell: SurveyQuestionCell) in

cell.questionLabel.text = surveyQuestion.rawValue

}.addDisposableTo(disposeBag)
}

Then, in ViewController (after adding imports of RxSwift and RxCocoa) I used that data model to create an observable sequence, assigned to the dataSource$ constant property.

Observables should generally be declared as constants, because, once assigned, elements are added to an observable sequence vs. reassigning the observable itself. Also, I have adopted the naming convention of suffixing observable sequence values with a “$” to make them stand out from “regular” values.

I’ve also created a disposeBag property, assigned to an instance of DisposeBag. DisposeBag provides a thread-safe way to dispose of subscriptions added to it when its owner is about to be deallocated. Any time you are creating a subscription in Rx, you should generally add it to a dispose bag. And if you forget, the compiler will warn you:

02_Implement_Rx_table_view_2

This warning indicates that the result of bindNext(_:curriedArgument:) is unused. bindNext(_:curriedArgument:), as do all subscribing methods in Rx, returns a Disposable instance. I could have assigned this result to a local variable, and then added it to the dispose bag, or even manually disposed of that subscription by calling dispose() on it. However, the typical, and preferred, way to work with subscriptions is to add them to a dispose bag upon creation.

Believe it or not, that’s it!

02_Implement_Rx_table_view_3

For a simple table view, all you have to do is call rx_itemsWithCellIdentifier(_:cellType:) on a suitable data source observable to configure it. rx_itemsWithCellIdentifier(_:cellType:) takes care of the required UITableViewDataSource protocol methods, that is,
 tableView(_:numberOfRowsInSection:) and tableView(_:cellForRowAtIndexPath:). There are more advanced ways to implement Rx table views that also offer additional capabilities, which I’ll cover in future posts.

The eagle-eyed of you may have noticed that I am working with a UIViewController subclass and scene, with a table view added to it, vs. a UITableViewController, which automatically includes the table view and adopts the UITableViewDataSource and UITableViewDelegate protocols. Why? Because rx_itemsWithCellIdentifier(_:cellType:) (part of RxCocoa) takes care of the required protocol methods, in addition to providing additional Rx observables and methods for many of the optional protocol methods. If I’d have used a UITableViewController and scene, even if I disconnected the delegate and dataSource outlets in Interface Builder, the class implementation would’ve required me to conform to the aforementioned UITableViewDataSource required methods, at a minimum.

But wait, there’s more…

  1. Use rx_setDelegate(_:) to essentially jump out of Rx, and then implement the protocol method in the normal manner.
  2. Implement the Rx version yourself (and if it’s good, submit a pull request and share it!).

I’ll go over how to do both of these things.

Creating a forward delegate

class ViewController: UIViewController {

@IBOutlet weak var tableView: UITableView!

let dataSource$ = Observable.just(SurveyQuestion.allValues)
let disposeBag = DisposeBag()

override func viewDidLoad() {
super.viewDidLoad()

dataSource$
.bindTo(tableView.rx_itemsWithCellIdentifier("SurveyQuestionCell")) { (row, surveyQuestion: SurveyQuestion, cell: SurveyQuestionCell) in

cell.questionLabel.text = surveyQuestion.rawValue

}.addDisposableTo(disposeBag)

tableView.rx_setDelegate(self).addDisposableTo(disposeBag)
}

}

extension ViewController: UITableViewDelegate {

func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let label = UILabel()
label.text = "Section \(section)"
label.backgroundColor = UIColor.lightGrayColor()
return label
}

}

There is currently no Rx observable or method for tableView(_:viewForHeaderInSection:). So, as an alternative to implementing one myself, I have called rx_setDelegate(_:) on the table view, to assign self as a forward delegate, and then implemented tableView(_:viewForHeaderInSection:) in an extension on ViewController that adopts UITableViewDelegate. And in the app, now a section header is displayed:

03_Use_forwarding_delegate_1

Creating a custom Rx observable

Ok, so that leaves the second option: implement a custom Rx observable. For this example, suppose that, instead of all the text fields being different widths and appearing jagged, we want them all to be the same width, equal to the space remaining based on the longest label in any of the cells.

Breaking down this feature, I need to:

  1. Determine the intrinsic label width of each cell based its text.
  2. Set the width of the label in each cell to the width of the widest label of any cell.

The table view cell is already set up with the label and text field embedded in a horizontal stack view with distribution set to Fill, meaning that views will be tend to be stretched to fill the available horizontal space (depending also on manually-added constraints and/or content hugging priorities). I’ve set the label’s horizontal content hugging priority to 1000, meaning that it will not be stretched to fill available space. The text field’s horizontal content hugging priority is 250 (the default), meaning that the text field will allow itself to be stretched horizontally to fill the available space (with regards to the label). These settings can be viewed and changed in the Size inspector in Interface Builder. The result is, the text field will stretch to fill the available space:

04_Create_custom_Rx_observable_1

If you’re not familiar with using stack views, check out my course on lynda.com: iOS UI Development with Visual Tools

iOS UI Development with Visual Tools with Scott Gardner - Customize a designable user interface

To implement this feature, I’ll start by creating a width constraint on the label:

class SurveyQuestionCell: UITableViewCell {

@IBOutlet weak var questionLabel: UILabel!
@IBOutlet weak var questionLabelWidthConstraint: NSLayoutConstraint!

var disposeBag = DisposeBag()

}

I’ve also created a disposeBag variable property on the cell; I’ll explain why shortly.

Now I’ll set that constraint’s constant to that of the widest label based on its text in the dataSource$ binding in ViewController.

class ViewController: UIViewController {

@IBOutlet weak var tableView: UITableView!

let dataSource$ = Observable.just(SurveyQuestion.allValues)
let labelWidthConstraintConstant$ = Variable<CGFloat>(0.0)
let disposeBag = DisposeBag()

override func viewDidLoad() {
super.viewDidLoad()

dataSource$
.bindTo(tableView.rx_itemsWithCellIdentifier("SurveyQuestionCell")) { (row, surveyQuestion: SurveyQuestion, cell: SurveyQuestionCell) in

cell.questionLabel.text = surveyQuestion.rawValue

cell.questionLabel.sizeToFit()

self.labelWidthConstraintConstant$.value = max(self.labelWidthConstraintConstant$.value, cell.questionLabel.bounds.size.width)

self.labelWidthConstraintConstant$.asDriver()
.distinctUntilChanged()
.drive(cell.questionLabelWidthConstraint.rx_constant)
.addDisposableTo(cell.disposeBag)

}.addDisposableTo(disposeBag)

tableView.rx_setDelegate(self).addDisposableTo(disposeBag)
}

}

I’ve created a labelWidthConstraintConstant$ Variable with an initial value of 0.0. In the dataSource$ binding, I call sizeToFit() on the questionLabel after assigning its text, to get its intrinsic width, and then I add to labelWidthConstraintConstant$ the max of its current value and the width of the label. Then I use labelWidthConstraintConstant$ to drive the cell’s questionLabelWidthConstraint.rx_constant value (rx_constant is an observable of the constant property of a constraint, aka a bindable sink).

Driver is a special kind of observable sequence that is ideal for use with binding to UI elements. Among other useful features, a Driver will never fail, always deliver events on the main thread, and it will replay the last value when initially subscribed.

drive(_:) creates a subscription within the cell. Remember that table view cells are not deallocated during the lifecycle of the table view. Once allocated, they’re removed from the table view and added to the queue, waiting in turn to be dequeued when the table view needs to display another cell. Because of this, I’ll need to add the subscription to a dispose bag on the cell itself, and then empty that dispose bag after it is removed from the table view. If I didn’t do that, subscriptions would keep being added to the same cell, even as it’s dequeued, used, removed (but not deallocated), dequeued again, etc. And because of this need, I will implement a custom Rx observable to handle UITableViewDelegate’s
 tableView(_:didEndDisplayingCell:forRowAtIndexPath:) callback.

extension UITableView {

var rx_didEndDisplayingCell: ControlEvent<(cell: UITableViewCell, indexPath: NSIndexPath)> {
let source = rx_delegate.observe(#selector(UITableViewDelegate.tableView(_:didEndDisplayingCell:forRowAtIndexPath:)))
.map { ($0[1] as! UITableViewCell, $0[2] as! NSIndexPath) }
return ControlEvent(events: source)
}

}

I’ve implemented rx_didEndDisplayingCell as a computed property on UITableView in an extension. In its getter, I construct an observable source$ of type tuple of UITableViewCell and NSIndexPath, by calling observe on rx_delegate (which is an Rx wrapper around the delegate) and passing the selector for the delegate method I want to listen for. Then I create and return a ControlEvent that wraps that source$ observable in a UI event listener on the main thread.

I can now use this Rx observable to listen for the tableView(_:didEndDisplayingCell:forRowAtIndexPath:) callback and then properly dispose of the subscription, by simply setting the cell’s disposeBag variable property to a new instance of DisposeBag. Doing so will cause dispose() to be called on its contents.

class ViewController: UIViewController {

@IBOutlet weak var tableView: UITableView!

let dataSource$ = Observable.just(SurveyQuestion.allValues)
let labelWidthConstraintConstant$ = Variable<CGFloat>(0.0)
let disposeBag = DisposeBag()

override func viewDidLoad() {
super.viewDidLoad()

dataSource$
.bindTo(tableView.rx_itemsWithCellIdentifier("SurveyQuestionCell")) { (row, surveyQuestion: SurveyQuestion, cell: SurveyQuestionCell) in

cell.questionLabel.text = surveyQuestion.rawValue

cell.questionLabel.sizeToFit()

self.labelWidthConstraintConstant$.value = max(self.labelWidthConstraintConstant$.value, cell.questionLabel.bounds.size.width)

self.labelWidthConstraintConstant$.asDriver()
.distinctUntilChanged()
.drive(cell.questionLabelWidthConstraint.rx_constant)
.addDisposableTo(cell.disposeBag)

}.addDisposableTo(disposeBag)

tableView.rx_setDelegate(self).addDisposableTo(disposeBag)

tableView.rx_didEndDisplayingCell.asObservable()
.map { $0.cell as! SurveyQuestionCell }
.subscribeNext { $0.disposeBag = DisposeBag() }
.addDisposableTo(disposeBag)
}

}

In the app, the table view cells’ labels are all aligned nicely and their text fields are the same width:

04_Create_custom_Rx_observable_2

Wrap up

Creating a simple reactive table view using RxSwift and RxCocoa is quick and easy, and there are options for when your needs go beyond the basics. I’ve only scratched the surface here, but stay tuned! I plan to go into more depth and also cover RxDataSources in future posts.

I hope you enjoyed this post. Nothing says “thanks” like a share. Cheers!


Originally published at as.ync.io.

Show your support

Clapping shows how much you appreciated Scott Gardner’s story.