Weekly Swift articles, podcasts and tips by John Sundell.

Slot-based UI development in Swift

Published on 16 Dec 2018

One of the most challenging decisions that all programmers have to make on an ongoing basis is when to generalize a solution versus just keeping it tied to a specific use case. It’s equally easy — and potentially damaging for a project — to either fall into the trap of over-engineering or under-engineering a solution.

UI development tends to be particularly tricky in this regard — should we build a library of completely generic components that could be adopted to any use case, or should we create highly specialized views that are tailor-made for each scenario?

This week — let’s take a look at a way of building UIs that might allow us to strike a nice balance between those two extremes — using a slot-based approach.

Backed into a purpose-built corner

If there’s one thing that almost all projects have in common, it’s that they tend to change over time. People change their minds, products evolve, and companies pivot. However, very often our UI code is not really prepared to handle such changes, even if they’re relatively minor — which can make us feel like we’ve painted ourselves into a bit of a corner.

Let’s take a look at an example, in which we’re building a UITableViewCell subclass for a recipe app. Currently, whenever we’re displaying a recipe in a list, we also want to include a list of related ones — which we accomplish by adding a RelatedRecipesView at the bottom of our cell, like this:

class RecipeCell: UITableViewCell {
    let relatedRecipesView = RelatedRecipesView()

    override init(style: UITableViewCell.CellStyle,
                  reuseIdentifier: String?) {
        super.init(style: style,
                   reuseIdentifier: reuseIdentifier)

        addRelatedRecipesView()
    }

    private func addRelatedRecipesView() {
        contentView.addSubview(relatedRecipesView)

        // Make the related recipes view stretch the width of
        // the cell, place it at the bottom, and use its own
        // intrinsic content size for its height.
        relatedRecipesView.layout {
            $0.leading == contentView.leadingAnchor
            $0.trailing == contentView.trailingAnchor
            $0.bottom == contentView.bottomAnchor
        }
    }
}

Above we’re borrowing the Auto Layout DSL from “Building DSLs in Swift” to make the code setting up constraints a bit easier to read.

The above cell implementation works really well as long as our requirements stay the same, but let’s say that one day we want to run an A/B test to see if adding a social feature to our app will improve our metrics — by replacing each recipe cell’s RelatedRecipesView with a SocialView instead.

Since our RecipeCell is currently hard-wired to always display a list of related recipes at the bottom, we’re going to need to make some changes. There’s a few different approaches we can take here. One is to add another property for a potential SocialView, and to make both that and our current RelatedRecipesView property optional:

class RecipeCell: UITableViewCell {
    var relatedRecipesView: RelatedRecipesView?
    var socialView: SocialView?
}

That’s not ideal, since by doing so we’re both introducing ambiguity and making our cell more complex by increasing the number of states it can be in. Also, since this new social feature is — at this time — just an A/B test, we really should try to keep the awareness of it across our code base to a minimum, so leaking details of it to one of our core UI components might not be a very good idea.

Opening up a slot

Instead, let’s see how we could solve the above dilemma by making our RecipeCell slot-based. When building a view in a slot-based way, we open up the possibility for certain parts of that view to be filled by other views.

A great example of this approach actually comes built into UITableViewCell — its accessoryView slot, which enables us to place any view we want at the trailing edge of a cell, without requiring the cell to be aware of any of its details.

Following that same design, we could add a bottomView slot to our RecipeCell, which would enable the code configuring an instance of it to place any view it wants at the bottom edge. We then observe that bottomView property, and add the necessary constraints to any view assigned to our new slot, like this:

class RecipeCell: UITableViewCell {
    var bottomView: UIView? {
        // The compiler automatically generates a variable called
        // 'oldValue' in didSet property observations.
        didSet { bottomViewDidChange(from: oldValue) }
    }

    private func bottomViewDidChange(from oldView: UIView?) {
        // === and !== can be used to check if two objects are
        // the same instance, rather than the same value.
        guard bottomView !== oldView else {
            return
        }

        oldView?.removeFromSuperview()

        guard let newView = bottomView else {
            return
        }

        contentView.addSubview(newView)

        newView.layout {
            $0.leading == contentView.leadingAnchor
            $0.trailing == contentView.trailingAnchor
            $0.bottom == contentView.bottomAnchor
        }
    }
}

While the above does require a bit more code than the purpose-built approach from before — it gives us a lot more freedom to evolve our UI as we wish, without having to make our core components aware of any specific features. It’s also a great way to prevent our views from becoming model aware, and to establish a stronger separation of concerns.

With the above slot in place, we can now easily configure an instance of RecipeCell to either display a list of related recipes, or to adapt according to our new social feature — for example in our UITableViewDataSource implementation:

func tableView(_ tableView: UITableView,
               cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(
        withIdentifier: reuseIdentifier,
        for: indexPath
    ) as! RecipeCell

    if featureFlags.enableSocialRecommendations {
        cell.bottomView = socialView(for: cell, at: indexPath)
    } else {
        cell.bottomView = relatedRecipesView(for: cell, at: indexPath)
    }

    return cell
}

For more information about working with A/B testing and feature flags — check out “Feature flags in Swift”.

The good news is that now that we’ve made this change, there’s really no reason for RecipeCell to actually be called that anymore — since, by moving to a slot-based approach, we’ve managed to remove all of its awareness of recipes and any other models. We could now simply call it SlotTableViewCell or something similar, as we might add additional slots to it in the future.

Retaining type safety

While adding slots instead of using concrete subview properties is a great way to make our UI more modular and flexible, it can also end up hurting the type safety of our code. Since we’re now dealing with UIView? slots, any code that relies on the concrete type of the view assigned to a slot — such as our socialView() and relatedRecipesView() methods above — needs to do type casting in order to work.

But what if we could get the best of both worlds — by implementing our slots in a way that actually did retain type safety?

Let’s take a look at another example. Here we’re building a HeaderView, which currently has two assignable slots — one for a view displayed at the top and one displayed at the bottom of the header. Just like before, we observe each slot property and apply the right layout to any assigned view — like this:

class HeaderView: UIView {
    var topView: UIView? {
        didSet { topViewDidChange(from: oldValue) }
    }

    var bottomView: UIView? {
        didSet { bottomViewDidChange(from: oldValue) }
    }
}

Now, to retain the type safety of each of those two slots, let’s turn to the power of generics — and add two generic types to our HeaderView — one for its Top view, and one for its Bottom one. By constraining each type to being a subclass of UIView, we can make this change without having to modify any of our internal layout code:

class HeaderView<Top: UIView, Bottom: UIView>: UIView {
    var topView: Top? {
        didSet { topViewDidChange(from: oldValue) }
    }

    var bottomView: Bottom? {
        didSet { bottomViewDidChange(from: oldValue) }
    }
}

The beauty of the above approach, is that we can now create any kind of specialized header view, without having to do any subclassing or introducing any new types — simply by specifying what top and bottom views that we’re going to use for each one:

let movieHeaderView = HeaderView<PosterView, UILabel>()
let userHeaderView = HeaderView<UIImageView, UIButton>()
let settingHeaderView = HeaderView<UILabel, UISwitch>()

However, there’s still one thing we haven’t addressed with the above implementation — and that’s discoverability. One big benefit of having concrete, model-specific view classes, is that finding them is usually super easy.

For example, if we want to find the header we should use when displaying a movie, we can simply search for “MovieHeaderView” in Xcode and chances are high we’ll quickly find the right one. When all we have is a generic HeaderView, things can get trickier, and we might end up with inconsistencies throughout or code base.

But the good news is that we can actually combine both of these approaches — by using type aliases to form ”pseudo types” for each concrete use case:

typealias MovieHeaderView = HeaderView<PosterView, UILabel>
typealias UserHeaderView = HeaderView<UIImageView, UIButton>
typealias SettingHeaderView = HeaderView<UILabel, UISwitch>

By doing the above, we can still use clear, concrete naming — while still gaining all of the benefits from using a flexible, slot-based design. For example, here’s how a MovieViewController can now refer to its header view simply as MovieHeaderView, which is really just a specialized version of of our slot-based HeaderView under the hood:

class MovieViewController: UIViewController {
    private lazy var headerView = MovieHeaderView()
}

Pretty cool 👍 If we wanted to, we could also have required HeaderView to be initialized with its top and bottom views, making it possible to treat them as non-optional — but that really depends on whether we always want to require both a top and bottom view or if we want to retain the flexibility of using either or neither.

Conclusion

Deciding exactly what level of flexibility to bake into our code will most likely continue to be difficult. Since it’s impossible to see into the future — making our code too generic might cause us to optimize for use cases that will never exist, while making too many hard assumptions might make it slow and error-prone to adapt our code base for new features.

While using slots is not a silver bullet, it can end up giving us a nice balance between flexibility and simplicity — especially when combined with generics and type aliases, which let us still use our slot-based views in much the same way as we’d do when building hard-wired ones. The challenge then becomes to decide when to add a new slot versus when to add a new view entirely.

What do you think? Do you currently build your views using a slot-based approach, or is it something you’ll try out? Let me know — along with any questions, comments or feedback you might have — on Twitter @johnsundell.

Thanks for reading! 🚀