A while back, I wrote about the frustrating state of navigation bars on iOS and how that frustration grew once we started reviewing and rebuilding our own navigation bars at work.
We’ve since managed to achieve what we set out to do and recently shipped the result to our users. Along the way, we took the opportunity to finally tackle a problem we’d been struggling with for years: consistency.
Our iOS chapter consists of around 45 engineers working across multiple independent teams. Over time, our navigation bars drifted apart. Some screens had a written “Close” button, others used a cross icon for dismissing, some used “Cancel” (which behaved the same as “Close”), and others swapped “Done” and “Save” interchangeably. With over 500 screens across our app and modules, this inconsistency became impossible to ignore. We wanted our screens to feel more predictable and our users to experience more consistent behaviors throughout the app.
If you’re only working with a few other engineers on an app, this might not sound like a big deal. But as your team grows, these inconsistencies can creep in quietly, and by the time you notice, it’s often too late to untangle them easily.
In this article, I’ll share what we did, how we approached it, and why. I haven’t seen this topic covered much elsewhere, so I hope our experience might be useful for your team as well, even if your setup and challenges are quite different from ours.
Mapping It Out #
We started by looking at all our navigation bars and the contexts in which they were used. From there, we began mapping them into semantic groups, not just visually but conceptually. Instead of thinking of a navigation bar as a set of buttons configured ad hoc for each screen, we began to treat them as named configurations with clear intent and meaning.
Even though different screens had different needs, patterns quickly started to emerge. For example, dialogs that allow users to edit a resource (like an editor screen) often include both a “Cancel” or “Discard” button and a “Save” or “Done” button. These recurring patterns helped us define four distinct semantic configurations that now cover all navigation bars across our app.
We also standardized how modal screens are dismissed. We agreed on using a cross button (✕), placed consistently on the leading edge of the navigation bar, a pattern that has become increasingly common in modern iOS apps.
We grouped navigation bars that focus on editing a single item or entity into what we now call a dialog configuration. This layout always includes our standard close button on the left and a save button on the right.
Next, we identified that the first screen inside a tab bar controller usually had a slightly larger title, signaling that it’s the main entry point or “root” of that section. We categorized these as root screens. They use a large title, cannot be dismissed (no close or back button), and form the base of their respective navigation stacks.
Finally, we looked at standard screens that are pushed on top of another (for example, from a root screen). These follow a more compact style with regular-sized titles, a back button aligned to the leading edge, and any custom actions placed on the trailing side. We called these page configurations, representing the typical “another page in the app” scenario.
I’m sure some of this might also apply to screens in your app. Try grouping them and see what patterns emerge. You might be surprised how much complexity hides in plain sight.
A Configuration-Based Approach #
We decided on a configuration-based approach that hides implementation details around how the navigation bar is built. For example, we didn’t want the public API of our design system to expose UIKit components directly. Instead, we use our own abstraction layer. This approach allows us to change the underlying implementation freely without breaking existing usages across SDKs and apps.
Because this lives within our design system, avoiding breaking changes (and not annoying teams too often) is a high priority. The configuration-based model turned out to be a solid choice.
We then created dedicated configurations for each semantic group identified earlier. These represent our standard navigation setups, with limited customization capabilities by design. By constraining what engineers can configure, we effectively encoded our design guidelines into code. Instead of relying on people to “do the right thing,” we enforce supported patterns through the API itself.
We also provided a way to customize the dismiss configuration, which can either use our standard close or back button. Initially, we didn’t allow back buttons in this configuration, but it became clear that certain screens, especially those with tracking or analytics requirements, needed more flexibility.
public class PageHeaderConfiguration: HeaderConfigurationWithTrailingItems {
public var hidesBackButton = false
public var trailingItems: [NavigationHeaderConfigurationBarItem] = []
public var dismissConfiguration: DismissItemConfiguration?
public init(dismissConfiguration: DismissItemConfiguration? = nil) {
self.dismissConfiguration = dismissConfiguration
}
}
public class DismissItemConfiguration: UIAccessibilityIdentification {
public enum DismissStyle: Int {
case close
case back
}
public var style: DismissStyle
public var action: NavigationHeaderActionHandler?
public var isEnabled = true
public init(style: DismissStyle = .close,
action: NavigationHeaderActionHandler? = nil) {
self.style = style
self.action = action
}
}
While we now had a specific style defined, we still needed a way to apply it. Since we support multiple styles, we introduced a unified NavigationHeaderConfiguration type that all concrete configurations convert into. This type is mostly private to the design system and not directly exposed to integrators, keeping the API surface clean while allowing internal flexibility.
public final class NavigationHeaderConfiguration {
enum HeadlineProminence: Hashable {
case primary
case secondary
}
var hidesBackButton = false
var leadingItem: NavigationHeaderConfigurationBarItem?
var headlineProminence: HeadlineProminence = .secondary
var trailingItems = [NavigationHeaderConfigurationBarItem]()
override init() { }
}
extension PageHeaderConfiguration: NavigationHeaderConvertible {
func toConfiguration() -> NavigationHeaderConfiguration {
let config = NavigationHeaderConfiguration()
config.headlineProminence = .secondary
config.trailingItems = trailingItems
config.hidesBackButton = hidesBackButton
config.leadingItem = dismissConfiguration?.toNavigationHeaderConfigurationBarItem()
return config
}
}
This single, unified header configuration can now be used to configure any navigation bar consistently. Even if we were to replace UINavigationBar entirely with a fully custom implementation in the future, this interface would remain stable and mostly backward compatible.
We also defined a NavigationHeaderConfigurationBarItem, which is similar to UIBarButtonItem. It represents a button in the navigation bar, but it is purely a model object. The actual rendering into a button is handled by the navigation bar itself.
In addition, we implemented a NavigationHeaderButtonItemPurpose enum, similar to UIBarButtonItem.SystemItem, but scoped to our design system. These purposes come with predefined icons and built-in localizations that can still be overridden when needed.
public enum NavigationHeaderButtonItemPurpose {
case add, edit, delete, search, share
var icon: UIImage {
// ...
}
var localizedTitle: String {
// ...
}
}
We initially considered using UIBarButtonItem and its SystemItem enum directly. However, these cannot be fully parsed, since accessing the underlying system item of a bar button item is private API. We therefore decided to implement our own version that follows a similar concept but gives us full control over the public interface and its capabilities.
We also decided against allowing arbitrary images within navigation buttons. Instead, each button must have a defined purpose. This purpose-driven approach ensures that if we ever decide to change our icon style in the future, we can replace icons centrally without affecting existing code. Again, the goal was to avoid breaking changes and maintain flexibility for the long term.
While this approach required some adjustment at first, it has worked very well in practice.
The final step was making it easy to apply configurations within our view controllers:
extension NavigationHeaderConfiguration {
public static func page(_ config: PageHeaderConfiguration = .init()) -> NavigationHeaderConfiguration {
config.toConfiguration()
}
// ...
}
extension UIViewController {
public func applyHeaderConfiguration(_ configuration: NavigationHeaderConfiguration) {
// Uses an associated object to store the configuration in any view controller.
headerConfiguration = configuration
}
}
While this could have been made more generic (for example, accepting a NavigationHeaderConvertible), we decided to prioritize readability and ease of use. The result is a declarative, predictable API that’s simple to understand at a glance:
vc.applyHeaderConfiguration(
.page(
.init(
dismissConfiguration: .init(
action: .init(target: self, selector: #selector(didSelectClose))
)
)
)
)
We also added an extension to make the most common dismiss action even easier by providing a static close function that produces a PageHeaderConfiguration and takes a target and selector. This helps reduce boilerplate.
extension PageHeaderConfiguration {
public static func close(target: AnyObject, selector: Selector) -> PageHeaderConfiguration {
.init(dismissConfiguration: .init(style: .close, action: .init(target: target, selector: selector)))
}
}
Overall, I’m quite happy with how this turned out. We’ve received great feedback from our iOS chapter, and we’re confident this will serve as the backbone of our navigation bars for the coming years.
Tip: We use a common
UIViewControllerbase class that applies the page style by default. This made migration straightforward since most screens updated automatically and only those that customized buttons required manual changes.
Conclusion #
Looking back, this project turned out to be about much more than just navigation bars. It was really about bringing semantics and intent into a part of the interface that had quietly become chaotic over the years.
By introducing a small set of semantic configurations, we not only made our navigation bars consistent again but also made them predictable. Engineers no longer have to decide whether a screen should have a “Close” or a “Cancel” button. Designers no longer need to double-check every new screen for alignment with our patterns. And our users now experience more coherent, familiar interactions across the entire app.
The most satisfying part is that all of this now lives inside our design system. Consistency isn’t something we have to remind people about anymore, it happens automatically. Every new screen that uses one of the shared configurations starts off on the right path. And if our design ever changes, we can update it once in the design system and roll it out across the entire app without other engineers having to touch a single line of code.
The same concept also works well in SwiftUI. With a few adjustments, the configuration-based approach can be reused there too. We follow the same pattern internally and simply provide a thin SwiftUI layer on top of this system.
If you’ve been struggling with similar inconsistencies in your own app, I’d highly recommend exploring a semantic approach like this. Start by mapping out the kinds of screens you actually have, identify recurring patterns, and think about which parts of your interface could benefit from standardization. The sooner you encode your conventions in code, the fewer inconsistencies you’ll need to fix later.
What began as a small refactor ended up being one of the most impactful UX improvements we’ve shipped in a long time, simply because it made our product, and our development process, feel more intentional.
What do you think about this approach? Does it make sense to you, and could it work in your own projects? Have you run into similar situations where small inconsistencies slowly turned into a larger problem?
If you want to chat about any of this or share your own experiences, feel free to reach out on Mastodon.