The Craft of SwiftUI APIs: Progressive Disclosure

#swiftui#api-design#ios

The moment you build a reusable component, you’re an API designer, whether you signed up for it or not.

I learned this the hard way building a Form component.

It started small. One screen, a handful of fields, a submit button. Then the use cases started showing up like uninvited guests: conditional fields, custom validation, async submission, nested sections, dynamic rows, a designer who wanted “just one more” layout variant. Every new requirement tugged at the API. Add a parameter here, an enum case there, a configuration object to hold the growing pile of options. Within a few weeks the call site looked like a tax form.

The principle that pulled me out of the hole, the one I kept coming back to, is progressive disclosure. It’s not a SwiftUI concept; it’s a design philosophy. But SwiftUI is the cleanest place I’ve seen it applied at scale, so that’s where we’ll spend this post.

What progressive disclosure actually means

Here’s the one-line version: the complexity of the call site should grow with the complexity of the use case.

The simple thing should be simple to write. The advanced thing should be possible, but you only pay for it when you need it. That’s it. The whole philosophy fits on a sticky note.

Why it matters in practice:

Most APIs fail at this not because their authors are careless, but because we write APIs from the wrong vantage point. We design at the declaration site, the file where the type lives, where every option feels equally important because we’re staring at all of them at once. But code is used at the call site, in some screen file three folders away, often by someone who has never opened the declaration. If you don’t design from there, you’re designing blind.

SwiftUI is interesting because the team clearly designed from the call site. Three strategies show up over and over. Let’s walk through them.

Strategy 1: Start from the common case

Pick the thing 80% of users will write, and make that the API. Build out from there.

Button is the canonical example.

// The common case. This is what you write 90% of the time.
Button("Next Page") {
    currentPage += 1
}

That’s it. A title, an action. No builders, no configurations, no ButtonStyle parameter you have to remember exists.

When you need a little more, say, a custom label, the API steps up:

Button {
    currentPage += 1
} label: {
    Text("Next Page")
}

And when you need arbitrary content:

Button {
    currentPage += 1
} label: {
    HStack {
        Text("Next Page")
        NextPagePreview()
    }
}

Notice what didn’t happen: there’s no Button(title:icon:style:action:) initializer with five optional parameters. There’s no enum of “supported label types.” The simple case stays simple. The complex case is reachable through the same door; you just open it wider.

The trap when you’re designing your own component is the urge to expose every option you can think of, up front, “for flexibility.” Flexibility at the cost of every call site reading the docs is not flexibility. It’s tax.

Strategy 2: Lean on intelligent defaults

Defaults are how you keep the simple case simple without lying about what the API does. The work doesn’t disappear; SwiftUI just does it for you and trusts you’ll override when you need to.

Look at what happens when you write this:

Text("Hello, world!")

Behind that one line, SwiftUI is doing a surprising amount of work on your behalf:

None of that is in the call site. None of it has to be. And if you ever need to override one of those defaults, the API has a modifier waiting for you.

The .toolbar API does the same trick with layout:

.toolbar {
    Button {
        addItem()
    } label: {
        Label("Add", systemImage: "plus")
    }

    Button {
        sort()
    } label: {
        Label("Sort", systemImage: "arrow.up.arrow.down")
    }

    Button {
        openShareSheet()
    } label: {
        Label("Share", systemImage: "square.and.arrow.up")
    }
}

I haven’t told SwiftUI where these buttons go. I don’t need to. The default placement follows platform convention:

This is exactly the layout you’d hand-write if you cared about the platform, which is the point. The default is the right answer for almost everyone, almost all the time.

And if you’re the exception, you reach for ToolbarItemGroup:

.toolbar {
    ToolbarItemGroup(placement: .navigationBarLeading) {
        Button {
            addItem()
        } label: {
            Label("Add", systemImage: "plus")
        }
        // ...
    }
}

Same idea as Button: the simple call site stays simple, the advanced one is one extra wrapper away. Nobody pays for ToolbarItemGroup unless they need it.

When you’re choosing defaults for your own APIs, the test is: if a user types nothing, will the result be reasonable? Not perfect, reasonable. If the answer is no, you don’t have a default. You have a required parameter wearing a costume.

Strategy 3: Compose, don’t enumerate

This is the strategy I underestimated the longest, and the one that saved my Form component.

When you’re staring at “users want N different behaviors,” the reflex is to model the behaviors. Enumerate them. Give each one a name.

Take HStack arrangement. The first design that pops into your head is probably something like:

// Don't do this
enum Arrangement {
    case leading
    case trailing
    case centered
}

Three cases, clean, readable. Until someone asks for evenly-spaced. Then space-between. Then space-before-the-last-element. Then…

// Definitely don't do this
enum Arrangement {
    case leading
    case trailing
    case centered
    case spacedEvenly
    case spaceBetweenElements
    case spaceBeforeLastElement
}

The enum is now a museum of every layout anyone ever asked for. Every new request is an API change. Every new case is a new thing to document, name, test, and remember. You’re not designing an API anymore. You’re maintaining a catalog.

SwiftUI’s answer is to refuse the framing. Arrangement isn’t a thing; it’s a consequence of how you compose what’s already there. So they shipped one tiny view, Spacer, and let you build every arrangement out of it.

struct StackExample: View {
    var body: some View {
        HStack { // leading
            Box().tint(.red)
            Box().tint(.green)
            Box().tint(.blue)
        }
    }
}
struct StackExample: View {
    var body: some View {
        HStack { // centered
            Spacer()
            Box().tint(.red)
            Box().tint(.green)
            Box().tint(.blue)
            Spacer()
        }
    }
}
struct StackExample: View {
    var body: some View {
        HStack { // evenly spaced
            Spacer()
            Box().tint(.red)
            Spacer()
            Box().tint(.green)
            Spacer()
            Box().tint(.blue)
            Spacer()
        }
    }
}
struct StackExample: View {
    var body: some View {
        HStack { // space only between elements
            Box().tint(.red)
            Spacer()
            Box().tint(.green)
            Spacer()
            Box().tint(.blue)
        }
    }
}
struct StackExample: View {
    var body: some View {
        HStack { // space only before the last element
            Box().tint(.red)
            Box().tint(.green)
            Spacer()
            Box().tint(.blue)
        }
    }
}

Every arrangement from the would-be enum, plus every arrangement nobody on the SwiftUI team had to think about. No API change. No new cases. Just one composable piece, used the way you’d use a word in a sentence.

The signal that you should reach for composition is when you catch yourself enumerating. The moment a list of cases starts feeling arbitrary (“why these three? why not four?”), it’s usually because you’re naming the outputs of a system instead of giving people the system itself.

Good APIs feel invisible

The strange thing about progressive disclosure, when it’s done well, is that nobody notices it. You write Text("Hello") and you don’t think wow, what a thoughtfully designed call site. You think about the screen you’re building. The API got out of your way.

That’s the bar. The best APIs aren’t the ones you marvel at; they’re the ones that disappear into the work. You only notice them when they’re missing, when a library forces you to construct a configuration object to draw a label, or a framework hands you fifteen optional parameters and expects you to know which six matter today.

So next time you’re about to add a parameter to one of your own components, try this: open the file where it’ll actually be used. Look at the call site. Ask whether the new option belongs in the 90% case or the 10% one. If it’s the 10%, find a way to let people opt in. Don’t make everyone else carry it.

Your future self, and everyone else on your team, will thank you. Quietly. Which is the whole point.