Leveraging UIStackView and result builders to ease our remaining days of UIKit. (Part 1)
Had you been a member of the iOS developers community for the past two years now, you probably couldn’t help but notice that there is a constant debate over which UI framework should or could you use in your next project.
SwiftUI it’s been around for a while now already and as it grows more mature, it is making it harder and harder to opt for UIKit in your next app. As I think is the case with every major shift of paradigm, some people decided to fearlessly jump ahead and develop all their upcoming features (or entire apps) in pure new SwiftUI, which is totally fine. Nevertheless, there are still many of us whom decided we’re not there yet and delayed the adoption of SwiftUI in our code bases. In the meanwhile, what we can do is make use of the best tools in our hands to prepare our code bases for any upcoming features or changes, which might or might not be a good suite for SwiftUI. This post aims to show how UIKit can be used to do just that.
The idea for this came across my mind in an attempt to improve something we build a lot as developers of our iOS apps, which is scrolling screens. With the wide range of screen sizes now, almost every screen we work on is a candidate for scrollable behavior. For a long time now we solved this issue with the help of a base
ScrollViewController implementation. Internally, this component embeds a
UIScrollView which should be populated with views by any of the subclass view controllers.
In order to showcase the power of the solution presented in this post, lets build a simple checkout screen. It has a header view at the top, a table view in the middle and a checkout footer view at the bottom which is implemented this time using a child
I will start by attaching a small code sample showing how we can achieve this design using our current
ScrollViewController. For brevity I will focus solely on the code used to actually build the interface for the rest of this article. Also all constraints written here will make use of SnapKit, also for brevity.
This approach comes with some limitations:
- the scrolling behavior is highly dependent on getting those constraints written perfectly, let one constraint out and suddenly your whole UI collapses and good luck with the debugging work.
2. adding new views in the hierarchy involves at best writing another set of constraints for that view, in case you want to insert it somewhere in the middle, you’d probably end up having to update constraints for two or more views as well, not to mention how it makes it hard to judge how the final user interface will end up looking.
3. it makes it hard to compose child view controllers without having to write a lot of boilerplate code (and additional constraints of course)
I hope that by now I made a good enough argument to motivate ourselves to find a solution for these limitations. As you probably guessed by now, the solution presented here involves
resultBuilders. As you have also probably thought, this solution also comes with a slight similarity to SwiftUI. Seeing some libraries which attempt to “recreate” SwiftUI using UIKit, I myself wondered for a bit whether if I am (accidentely) trying to do the same thing. This solution however does not attempt to do that, however I highly respect the work of those who did publish a functional library which allows writing SwiftUI-like, UIKit components.
Instead, the goal was to find a way in which we use existing UIKit components and compose them in a more clear, predictable, easy to debug fashion, all while supporting all the functionalities provided by the previous
ScrollViewController implementation (and more).
Here’s a short list of checkpoints which I expected this solution to cover:
◦ No more constraints — views should just follow the order they are laid in the stack and allow scrolling in case they exceed the screen’s bounds.
◦ Insert UIViewControllers frictionless in the stack (just like
◦ Views should be easy to swap and move freely up and down the hierarchy
◦ Use a mechanism to offset views vertically on from another, somehow like .
padding() does in SwiftUI, as well as setting constraints on the view’s height (either absolute, maximum or minimum height)
Let’s go ahead and see how we can achieve those one by one:
No more constraints. To achieve this step we make use of one of the most underestimated yet powerful components of UIKit: the
UIStackView. The property which makes it the best candidate for this job is that it automatically changes its height as we add subviews to it, meaning that when embedded in a
UIScrollView, it will force the
contentSize to grow vertically. Also, all subviews embedded within the
UIStackView will automatically take the whole width of it, (thus the whole width of the screen if we set it up properly) which is probably what we would expect from this component most often. With that being said, our new
ScrollViewController component now becomes:
It turns out that’s all the code we need to get us “constraints free” for now. Despite the fact that with this improvement we do much better than before, the only hint we have about the position of our views in the screen is the order in which we call
stack on them when we add them to the screen.This is fine but we can do better — result builders to the rescue 🤟🏻.
Result builders were introduced in Swift 5.4 and are
Getting into detail on this type is beyond the scope of this article but here’s a great article from Antoine van der Lee which goes into more detail on them: https://www.avanderlee.com/swift/result-builders/.
We start by creating our (initial version of) result builder:
Using this new type, we can refactor our
stack function from before so that it takes a closure parameter annotated with @StackBuilder, thus enabling us to use a declarative syntax when calling it.
We’ve come yet another step forward using this small improvement. Our code looks better now and is easier to reason about what to expect from our interface to look like just by glacing at the call site of
Despite this improval, there are still some limitations we face: one is we cannot write conditional statements within our stack function declaration. This matters a lot, because being able to write conditional statements inside our declaration enables reusability of the same custom
UIViewController, for a slightly different use case, just by changing a flag. For example we might have a variation of our cart view controller in which we want to replace the checkout footer with a label which simply displays the number of items in the cart. Also, even if our view controller is visible on the screen, this implementation is not fully correct in that it does not add the
UIViewController as a child view controller in the correct fashion: calling on
didMoveTo APIs in order to ensure the methods corresponding to the life cycle are called at their own.
Ideally, if we made the proper modifications, our declaration should become:
Before we go any further, consider that with the current implementation there is no (easy) way to decide whether if the component returned by the stack builder’s closure is a
UIViewController backed view or a simple
UIView, as all of them are returned as
To fix this we introduce a new
StackedView protocol that can be used as the return type by the result builder instead of
UIView. This protocol comes with some other interesting advatanges as well, which will be highlighted as we go along.
Worth noting is that the type of the variable declared by this protocol is not actually UIView but some new
DecoratedStackedView type. This is because we need some sort of transportation mechanism for relevant information related to the views we stack, such as whether it’s backed by a view controller or not and more. For now, it's only responsible to wrap either a
UIView or a
We will then conform
StackedView protocol in order to allow us to call them directly from the closure and bring all types into a common
[DecoratedStackView] type. This concept is also explained better in Antoine’s article.
Next, let’s refactor the stack builder to use the new return type. In the process, we will add three new functions which enable us to use conditional states inside the body of the builder function:
static func buildOptional(_ component: [StackedView]?) -> [StackedView]
static func buildEither(second component: [StackedView]) -> [StackedView]
static func buildEither(first component: [StackedView]) -> [StackedView]
This last enhancement enables us to refactor our
stack function such that it can call the appropriate API for adding child view controllers, if it's the case. We are now also able to write conditional
if statements in the closure’s body, to return either a view or another, based on whatever condition we have.
Before looking at the new implementation of
stack, let us add one more enhancements to our implementation, as we are very close to achieve the last feature from our initial list as well: use paddings and height constraints.In order to achieve this, we will make use of the
DecoratedStackedView type again, adding two more properties:
height. Following a functional approach, we create two functions inside the
DecoratedStackView to set these.
Because every view we pass in the result builder’s closure is not of
DecoratedStackView type but a
StackedView, these functions cannot be called directly. Instead an extension over
StackedView creates corresponding ‘‘proxy’’ functions for each one of them.
With this last addition, let’s refactor
stack to support the new features.
This a lot of code at once so let’s digest it in order. First it tries to map instances of our
StackedView elements to our specialized
DecoratedStackView type. Where this mapping is successful, it adds them to the stack. Before actually adding a view, we attempt to peform the required setup on it’s view controller, if the returned component is actually part of a child view controller. Afterwards, it applies any modifier (
height) which was passed to the current component. For heights, we use constraints on the view’s height while for paddings,
setCustomSpacing method satisfies our needs well enough.
Putting all the pieces together and our complicated, constraints based setup from before now becomes:
We came a long road undoubtedly from where we started with this, to now having a pretty powerful component which can be leveraged to implement, I think, almost any kind of scrollable screen we will encounter. Going back to the introduction of this article, this component also achieves its higher goal, which is to allow seamless integration with SwiftUI. As we enabled
UIViewControllers to be stacked just like
UIViews, this also makes it perfectly valid to use
UIHostingController (wrapping SwiftUI views)instead in their place. Therefore, if say, in the future we decided to refactor our
cartHeader view to use SwiftUI instead of UIKit, we can do that with incredible small effort.
In part two of this article we will see how we can extend our component even more, enabling us to handle scenarios in which the content in the scroll view does not fill the entire screen.
Until then, I hope you enjoyed this one and please feel free to contact me with any kind of comment, feedback or question about it.
Thanks for reading!
Known limitations: we are still required to observe the
contentSize for the
UITableView in order to update its height constraint to take the height of the
contentSize. This way we prevent itself from adopting a scrolling behavior and thus having two scrolling areas in the screen. This shortcoming is nevertheless common to the previous
ScrollViewController implementation as well. The immediate fix idea could be to implement some kind of
ForEach view (like SwiftUI) which uses the scroll view directly instead of embedding a
UITableView . This however is beyond the scope of the article and deserves its own discussion.
All the code from this article you can find it here: https://github.com/VladIacobIonut/ScrollStackView