The Developer's Guide to User-Interactive Cell Configurations in iOS 14

In an earlier article, I introduced iOS 14’s new content configuration architecture. You have a UIContentConfiguration object and a UIContentView, and they go together. Configuring the content configuration object expresses the data you want represented; the configuration object then generates the content view, which constructs the interface that displays that data. A UITableViewCell or a UICollectionViewCell has a contentConfiguration property; whatever content view the cell’s content configuration creates, the cell makes that its own contentView, automatically.

The idea here, as I explained in the earlier article, is that you have probably been configuring the entire cell, including its interface, in your implementation of the table view data source method cellForRowAt (or the collection view data source method itemForRowAt). This is wrong, and you should stop doing it. It’s no business of the data source to know what the cell’s interface is like. For example, you might be saying this:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
    cell.textLabel?.text = // whatever
    return cell
}

So your data source knows that the cell has a UILabel in it, and it is configuring that label directly. With a configuration, on the other hand, the data source knows only about the data. It hands that data to the cell by way of the content configuration object, and neither knows nor cares what happens after that:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
    var configuration = cell.defaultContentConfiguration()
    configuration.text = // whatever
    cell.contentConfiguration = configuration
    return cell
}

So much for the recap. But what about user-interactive cell content? I didn’t talk about that in the earlier article.

The User Talks Back

To see what I mean, let’s suppose that your cell contains a UISwitch that the user is able to tap and toggle. Obviously, this UISwitch reflects a piece of your table view’s model data. Presumably, you have, somewhere, a model object that corresponds to each row of the table, and this model object contains a Bool. If the Bool is true, the UISwitch is on. If the Bool is false, the UISwitch is off.

Now, what happens when the user toggles the UISwitch in some row of the table? You know the drill; this is a problem you are familiar with, and you’ve already got some sort of solution to it. Typically, what you’re doing is probably something like this. A UISwitch is a UIControl. It has a dispatch table where its control events can be assigned a target and an action. When the user toggles the UISwitch, its Value Changed control event fires. So you’ve arranged for the UISwitch’s Value Changed event to have a target and action. The target object is probably the view controller, acting as the table view’s data source. And the action is some method in that target object. Maybe you configured that in the storyboard using a prototype cell; maybe you configured it in code, right there in your cellForRowAt implementation —

let sw = // the UISwitch
sw.addTarget(self, action: #selector(switchChanged), for: .valueChanged)

The user taps a UISwitch in a certain row, the Value Changed control event fires, your switchChanged method is called, and you respond by updating your model data. That’s a bit tricky, of course, because you want to update the model data corresponding to the correct row of the table. But this is a common problem, so I’m sure you’ve worked out some favorite way of solving it. Personally, what I like to do is this: the UISwitch itself arrives as the parameter into the action method, so I walk up the view hierarchy until I come to the cell, I ask the table view what row this cell corresponds to, and now I can update the model data.

So, I’ve got a utility method for walking up the view hierarchy (actually, it walks up the responder chain, but that embraces the view hierarchy, so it amounts to the same thing):

extension UIResponder {
    func next<T:UIResponder>(ofType: T.Type) -> T? {
        let r = self.next
        if let r = r as? T ?? r?.next(ofType: T.self) {
            return r
        } else {
            return nil
        }
    }
}

And my action method is structured like this:

@objc func switchChanged(_ sender: UISwitch) {
    if let cell = sender.next(ofType: UITableViewCell.self) {
        if let indexPath = self.tableView.indexPath(for: cell) {
            // update the model at that index path
        }
    }
}

Okay, but here’s the problem. If you now change your architecture to use a cell content configuration object, that direct connection between the UISwitch in the cell and the table view’s data source goes away. So now, what do you do? Once you’ve adopted content configurations, how are user-interactive subviews in the cell going to work?

A Test Bed Example

To illustrate the problem more concretely, I’ll make a very silly table with just one section, where each cell consists of nothing but a UISwitch. My data model can, therefore, be a simple array of Bools. To keep the demonstration as general as possible, I’ll write my own custom UIContentConfiguration object. This is a lot of code to start with, but if you look back at the earlier article, you’ll see that it’s almost entirely boilerplate; the only vaguely interesting thing I’m doing is getting the UISwitch into the configuration’s content view:

struct Config: UIContentConfiguration {
    var isOn = false
    // 1
    func makeContentView() -> UIView & UIContentView {
        return MyContentView(configuration:self)
    }
    func updated(for state: UIConfigurationState) -> Config {
        return self
    }
}
class MyContentView : UIView, UIContentView {
    var configuration: UIContentConfiguration {
        didSet {
            config()
        }
    }
    let sw = UISwitch()
    init(configuration: UIContentConfiguration) {
        self.configuration = configuration
        super.init(frame:.zero)
        sw.translatesAutoresizingMaskIntoConstraints = false
        self.addSubview(sw)
        sw.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
        sw.topAnchor.constraint(equalTo: self.topAnchor, constant:10).isActive = true
        sw.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant:-10).isActive = true
        // 2
        config()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    func config() {
        self.sw.isOn = (configuration as? Config)?.isOn ?? false
    }
}
class ViewController: UIViewController, UITableViewDataSource {
    @IBOutlet var tableView : UITableView!
    var list = Array(repeating: false, count: 100) // this is the data
    override func viewDidLoad() {
        super.viewDidLoad()
        self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    }
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.list.count
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        var config = Config()
        config.isOn = list[indexPath.row]
        // 3
        cell.contentConfiguration = config
        return cell
    }
}

That’s a complete working example. Our data consists of 100 Bools; all of them are set initially to false. We run the project, and we see our table view, consisting of 100 rows of UISwitches, all of them set to the Off position. So far, so good.

But here’s the problem. Let’s say we (the user) tap the UISwitch in the first row of the table to set it to the On position. We now scroll the table up and scroll it back down — and kaboom, that UISwitch is back to the Off position. And I’m sure you know why: it’s because we didn’t communicate that change in the table cell interface back into the model data (our list array). That’s the problem we are here to solve.

In the code, I’ve used comments to distinguish three locations where we’re going to need to insert something:

  1. In the content configuration object, we’re going to need another property. Properties are all a configuration object really has, as far as its API is concerned. So somehow, there has to be a property here that allows us to establish a communication conduit between the UISwitch in the cell and the table view’s data source.

  2. In the configuration object’s content view, we’re going to need to respond to that property to configure the actual UISwitch in the interface. The content view is the view; it’s the thing that knows about the UISwitch. The UISwitch can have a target and an action; clearly, this is the place where we’re going to establish that.

  3. In the data source’s cellForRowAt method, we’re going to need to set that property in such a way that we get called back so that we can update the data model when we are called back.

So how are we going to do all that? I can think of numerous ways to solve this problem. Any differences between them will simply reflect the particular type of conduit we decide to use in order to let the UISwitch in the cell communicate all the way back to the data source.

Solving the Problem — Protocol-and-Delegate

For instance, we might use the well-worn path of protocol-and-delegate. In this approach, the content configuration object has a delegate and keeps a reference to it in a property (perhaps called delegate). The content view knows of a message that it is allowed to send to that delegate; it knows this message because we have established what that message is, by way of a protocol to which the delegate must conform. The content view sets up the UISwitch so that its target and action call into the content view, which passes the signal along by sending the protocol message to the delegate.

So here’s my protocol:

protocol SwitchListener : AnyObject {
    func switchChangedTo(_:Bool, sender:UIView)
}

And here’s my property (in the slot marked with comment 1):

weak var delegate : SwitchListener?

And here’s my content view configuration of the UISwitch (in the slot marked with comment 2):

sw.addAction(UIAction { action in
    if let sender = action.sender as? UISwitch {
        (configuration as? Config)?.delegate?.switchChangedTo(sender.isOn, sender:sender)
    }
}, for: .valueChanged)

And here’s how the data source sets up the configuration object (in the slot marked with comment 3):

config.delegate = self

All that remains is for the data source (self) to conform to the protocol; the actual implementation of the switchChangedTo method is astoundingly similar to what I was doing before:

extension ViewController : SwitchListener {
    func switchChangedTo(_ newValue: Bool, sender: UIView) {
        if let cell = sender.next(ofType: UITableViewCell.self) {
            if let ip = self.tableView.indexPath(for: cell) {
                self.list[ip.row] = newValue
            }
        }
    }
}

That works, and there is nothing particularly wrong with it. But I don’t like it. It’s such a lot of work. Things are happening in a lot of different places, and there is something ugly about the way we’re using two distinct signals: the UISwitch sends a signal to the action, which sends a different signal to the delegate. (“The king told the queen, and the queen told the dairymaid…”) I can think of a more compact way to do this, and now I’ll show you what that is.

Solving the Problem — A Property That’s a Function

My second approach starts with the idea that the content configuration object property that the data source will set should be not a delegate object but a function. Instead of handing the configuration object a reference to an object that will implement a function, we hand it the function itself.

That sort of thing is a more modern approach. You can see this by looking at what the Apple folks themselves do when they devise an API these days. For example, look at UICollectionViewDiffableDataSource. How do you tell a collection view diffable data source how to configure a supplementary view? You set its supplementaryViewProvider property — to a function. How do you tell the diffable data source how to respond when the user is reordering cells? You set its reorderingHandlers property to a ReorderingHandlers object, which has three properties, all of which are functions. And so on.

So here we go. Here’s the content configuration object property, whose type is a function (comment 1):

var isOnChanged : ((Bool, UIView) -> Void)?

Here’s the content view, configuring the UISwitch. It sets up the UISwitch’s action so as to call the content configuration object property’s function (comment 2):

sw.addAction(UIAction { action in
    if let sender = action.sender as? UISwitch {
        (configuration as? Config)?.isOnChanged?(sender.isOn, sender)
    }
}, for: .valueChanged)

And here’s the data source, configuring the content configuration object (comment 3):

config.isOnChanged = { [weak self] isOn, v in
    if let cell = v.next(ofType: UITableViewCell.self) {
        if let ip = self?.tableView.indexPath(for: cell) {
            self?.list[ip.row] = isOn
        }
    }
}

That’s all! There’s no protocol. There’s no delegate. And there’s no extra method (switchChangedTo in the previous implementation) because the isOnChanged property is itself a function, and we can just declare that function’s body then and there, inline, when we set the property.

By the way, this is probably obvious to you, but just in case it isn’t, note the use of [weak self] in the anonymous function. That’s crucial. Any time you store a function in a property, as we are doing here, you run the risk of a retain cycle. This would be a retain cycle if we didn’t do something about it; the view controller itself, and all that it owns, would leak.

Conclusion

As I said earlier, those are not the only ways to arrange for communication from an interactive subview of a cell’s content view to the data source. I can think of several other ways! I’m not even saying that the ways I’ve shown are the best ways. I’m just showing the general architecture you’ll need in order to implement a solution. The key thing will always be some property of the content configuration object; the data source sets that property with regard to a particular cell, and the content view uses that property setting to configure the interactive subview.

The nice thing about any approach you choose to adopt is that the purpose of the content configuration object will be fulfilled — namely, separation of responsibilities. The data source will communicate the data to the configuration object and will receive messages when the user does something implying that the model data needs to change. The configuration object’s content view configures the cell’s subviews. And never the twain shall meet!

Finally, I should acknowledge that you might not have any implementation of the data source cellForRowAt (or itemForRowAt) method in the first place; you might be using a diffable data source instead. But the diffable data source has a cell provider function that does, in effect, just what the data source method would do, so the difference in architectures doesn’t change the nature of the problem or the solution.

Check out more in this series:

The Developers Guide to Cell Content Configuration in iOS14
The Developer’s Guide to List Content Views in iOS14

You Might Also Like…

Collection View Outlines in iOS 14

In an earlier article, I introduced collection view lists, new in iOS 14, which allow a collection view, or a section of a collection view, to imitate a table view. Collection view lists are the basis for yet another new iOS 14 feature: outlines. As Lovely as a Tree An outline is a way of …

Collection View Outlines in iOS 14 Read More »

    Sign Up

    Get more articles and exclusive content that takes your iOS skills to the next level.