The craft of SwiftUI API design: Progressive disclosure
Explore progressive disclosure — one of SwiftUI's core principles — and learn how it influences the design of our APIs. We'll show you how we use progressive disclosure, discuss how it can support quick iteration and exploration, and help you take advantage of it in your own code.
Sam Lazarus: Hi my name is Sam, and I'm an engineer on the SwiftUI team. When designing SwiftUI, we've always strived to make decisions based on clearly defined principles, and today, we're going to highlight one of them: progressive disclosure. On the SwiftUI team, we spend a lot of time thinking about and building new API, but what you might not have realized is that the moment you build a reusable component or abstraction, you, too, are an API designer. In this talk, we wanted to peel back the curtains on our design process and share what we've learned about progressive disclosure, so the next time you're building a reusable component, or abstraction, you have a new tool in your tool belt.
Let's start by talking about what progressive disclosure actually means. As it turns out, it's not unique to the design of APIs! In fact, you can see it in action in one of the most common macOS UIs: the save dialog.
When you're first shown a save dialog, there's a default location already populated for you. Additionally, the dialog shows you a drop-down with some common locations, so locations you're likely to select are easy to reach. And finally, if you need to browse the filesystem to find the right path, you can expand the dialog to reveal a more complex but more powerful UI. There are different layers of complexity here that can be revealed when needed. This is the same experience that we want to provide with our APIs. The code equivalent to providing a nice UI experience is making your APIs feel great to use.
As developers, we're used to looking at our code from the perspective of where we write it: the declaration site. But to make code feel great to use, we have to look at it from a different perspective: where the code actually gets used, or what we refer to as the call site.
Progressive disclosure, then, is designing APIs so that the complexity of the call site grows with the complexity of the use case.
An ideal API is both simple and approachable but also be able to accommodate powerful use cases.
This has real benefits for developers. First, it minimizes the time to the the first build and run, letting you make use of your API quickly. It also lowers the learning curve of your code, preventing APIs from getting bogged down by concepts that aren't relevant in all use cases.
Finally, it creates a tight feedback loop. With APIs that embrace progressive disclosure, you can add pieces bit by bit, seeing what you've created at each step.
All of these things together make building apps a cycle of quick refinements rather than a single, large, up-front investment.
So progressive disclosure is a useful guiding light, but how can we design specific API so they embrace that principle? On the SwiftUI team, we start by considering common use cases. In order to progressively disclose functionality, we need to identify what the simple cases should be.
We also strive to provide intelligent defaults, so those common cases can specify only what they need to. Next, we aim to optimize the call site, ensuring every character of your call site has a purpose. And finally, we design our APIs so they compose pieces rather than enumerating possibilities.
Let's dive right in and take a look at some examples from SwiftUI, starting with how we consider common use cases. One place where SwiftUI does this particularly well is with labels.
When you create a button, for example, we require that you provide a label for the button. Most of the time, that label will just be some text, describing the purpose of the button, and SwiftUI provides you a concise way to spell that. But if you want to customize the button further, SwiftUI provides another overload. which takes an arbitrary view as a label.
This allows you to build complex functionality out of this simple control. But because this API carefully considers its common use cases, 99% percent of the time, you only need the simple version.
This label pattern shows up everywhere in SwiftUI. And when I say everywhere, I really mean it.
So considering common use cases is something we do across the entire framework. Next, let's look at providing intelligent defaults. In order to streamline our common use cases, we have to provide intelligent defaults for all the things we don't specify explicitly. And there's no better example of this than one of the most commonly used APIs in all of SwiftUI: Text.
Text is such a great example of intelligent defaults that you've probably written code like this hundreds of times without thinking about everything you don't have to specify.
Just with this code, SwiftUI will localize your text by looking up the localized string in your app bundle with the environment locale. It will automatically adapt to the current color scheme, supporting dark mode right out of the box. And it will automatically scale the text up or down depending on the current accessibility dynamic type size.
We've talked about these behaviors before, but text is doing even more behind the scenes than that.
When you put two texts next to each other into a stack, for example, the space between the texts is automatically adjusted to the correct line spacing for text in the current context.
All that behavior can be manually specified, but SwiftUI's intelligent defaults mean that when they aren't relevant to your use case, they don't appear at the call site.
Text is an example of an API where the simplest case is extremely minimal, but intelligent defaults apply to all sorts of call sites. Take toolbar, for example. Here, we have a toolbar with a bunch of buttons. Without having to explicitly specify their position, the toolbar buttons are placed according to platform convention. On macOS, they'll appear in the leading edge of the toolbar, but on iOS, they'll appear in the navigation bar, starting from the trailing edge. And finally, on watchOS, only the first item appears, pinned under the navigation bar. This works great for the large majority of cases, but if you do need more control, we provide additional API to explicitly specify the placement of items. Again, the customization is there if you need it, but intelligent defaults handle the majority of cases.
Considering common use cases and providing intelligent defaults create some really great experiences, but if using those APIs feels clunky, or unrefined, it can ruin the whole effect. That brings us to our last strategy: optimize the call site. And for that, let's look at another API: Table.
Multi-column tables are a very feature-rich control. There's lots to configure and lots of functionality. But the large majority of tables are much simpler and don't need all those features. We want table to be capable of this more complex behavior, and in its most verbose format, it has that. It has support for sorting, multiple columns with rich cell content, sectioned rows, and much, much more.
But we also want to provide a great experience in the more common cases, so let's take a look at the fully specified code for this simpler table and see how we can optimize its call site. First, let's break down this example. Table starts by specifying how it generates the data for each row.
Here, I'm iterating over each book we're currently reading and creating a table row for each of those books. Next, it specifies how to populate the columns from the data for each row. Here, I create a Title column and an Author column.
It also takes a binding to the sort order to allow table to change the sort when users click on the table column headers.
Finally, I've added some code which re-sorts the table's data whenever the sort order changes. That's a lot of information, so let's take a look at how to optimize this call site to really embrace progressive disclosure.
One common use case that stands out right away has to do with rows. Most of the time, the rows field will look just like it does in this example: a ForEach over a collection, providing a table row for each item.
The developer doesn't need to loop through all of this themselves, so SwiftUI provides a convenience that handles this under the hood. By passing the collection directly to table, the ForEach behavior can be provided behind the scenes, drastically simplifying our call site, but this can still be simplified further. What are other common use cases? Well, most of the time, when one of the values I want to show in a table is a string, I'll just use a text to display it in the column.
We optimize the call site for this case too.
Whenever the value keypath points to a string, we allow the view associated with the TableColumn to be omitted.
That's another significant simplification, but there's still more to optimize! There's information in the call site which not all tables need to concern themselves with: the sort order. The simplest use case for table doesn't care about sorting at all! So we provide a version of table which doesn't concern itself with sorting either. And this brings us to our final iteration. Much simpler! Every character of this call site serves a clear purpose, and we got here by asking ourselves two key questions at every step: "What are the most common use cases that we should build conveniences for?" and "What is the essential information that should always be required?" These guiding questions are great for helping you optimize your call sites, but they need to be applied carefully. If you don't think through their implications for your API, they can lead you astray. That brings us to our final strategy: Compose, don't enumerate. And to illustrate this, let's talk about the design of a part of SwiftUI's layout system: stacks, in particular, HStack. First, let's think about what the essential information is for an HStack. Well, it needs to know what content should be in the stack and how that content should be arranged within the stack. We already have view builders to specify the content of the HStack, so let's focus on the arrangement. Going back to the guiding questions we highlighted, what are the most common use cases when arranging elements in an Hstack? Well, sometimes I want to show a stack like this one that shows the boxes one after another, starting from the leading edge.
Another common case is wanting to center the elements. And finally, I might want to align the elements against the trailing edge.
VStack already has an API with similar cases to this, alignment, so it might seem tempting to create a similar enum for the arrangement of elements within the stack. This supports all the cases we mentioned! By specifying the arrangement of an HStack, I could select a leading, trailing, or centered arrangement, depending on which I wanted. But now what if I want to space the elements out evenly or put spacing only between the elements or put space only before the last element? This is getting really messy! But more importantly, it's unsustainable. I have to add an enum case for every behavior we want, and we might not think of all the useful cases! When you find yourself enumerating common cases rather than providing conveniences for them, try breaking your API apart into composable pieces that can build a solution: Compose, don't enumerate.
In the case of stacks, SwiftUI provides Spacer and lets you compose it with the elements of your stack to build all of the spacing schemes we enumerated, and many, many more, which is how we arrived at the API we have today.
Designing the best experience for progressive disclosure here wasn't just about minimizing the call site, but also involved careful thought about how that call site should scale to handle all its cases: in this case, through composition.
When writing code yourself, it can be incredibly helpful to apply the same kind of careful consideration for the components you create. And to recap, that starts by considering common use cases. By applying progressive disclosure, the code you write will save you time in the most common use cases. Intelligent defaults will mean you won't have to think about the details in those common cases. Working to optimize the call sites you build will allow you to iterate quickly.
And finally, utilizing composition will let you build APIs that are flexible enough to accommodate all their use cases.
And because you are an API designer, you can apply these lessons to the code you write every day, whether it's being designed for someone else, or just for you to use.