SwiftUI · · 8 min read

A Beginner's Guide to NavigationSplitView in SwiftUI for iOS 16

A Beginner's Guide to NavigationSplitView in SwiftUI for iOS 16

In iOS 16, other than introducing the new NavigationStack, Apple also released a new view container named NavigationSplitView for developers to create two or three column navigation interface. If you want to build UI similar to the stock Mail app, you should check out this split view component.

While NavigationSplitView is more suitable for iPadOS and macOS apps, you can also use it on apps for iPhone. The view component automatically adapts itself for iPhone. Instead of displaying a multi-column interface, it creates a single-column experience.

The new NavigationSplitView comes with various options for you to customize its appearance and operations. You can change the column width and programmatically show/hide the columns.

In this tutorial, we will create a three-column navigation UI using NavigationSplitView.

Let’s get started.

The Basic Usage of NavigationSplitView

The NavigationSplitView supports both two-column and three-column navigation experience. Their implementations are quite similar. To create a two-column navigation UI, you write the code like this:

NavigationSplitView {
  // Menu bar
} detail: {
  // Detail view for each of the menu item
}

For 3-column navigation interface, you add the content parameter in between:

NavigationSplitView {
  // Menu bar
} content: {
  // Sub menu
} detail: {
  // Detail view for each of the sub-menu item
}

We will start with the two-column navigation UI and eventually build the three-column design.

Building the 2-Column Navigation Interface

If you’ve read my previous tutorial on the expandable list view, you may know that I’m a big fan of La Marzocco. In that tutorial, I showed you how to create an expandable list view with inset group style.

Now let’s turn this expandable list into a two level navigation interface like the screenshot shown below:

swiftui-navigationsplitview-two-column

Before we create the split view, let’s begin with the data model. We create a struct to model a menu item:

struct MenuItem: Identifiable, Hashable {
    var id = UUID()
    var name: String
    var image: String
    var subMenuItems: [MenuItem]?
}

To make a nested list, the key here is to include a property that contains an optional array of children (i.e. subMenuItems). Note that the children are of the same type of its parent.

For the top level menu items, we can create an array of MenuItem like this:

let topMenuItems = [ MenuItem(name: "Espresso Machines", image: "linea-mini", subMenuItems: espressoMachineMenuItems),
                        MenuItem(name: "Grinders", image: "swift-mini", subMenuItems: grinderMenuItems),
                        MenuItem(name: "Other Equipments", image: "espresso-ep", subMenuItems: otherMenuItems)
                    ]

For each of the menu item, we specify the array of the sub-menu items. In case if there is no sub-menu item, you can omit the subMenuItems parameter or pass it a nil value. For the sub-menu items, we can define them like this:

// Sub-menu items for Espressco Machines
let espressoMachineMenuItems = [ MenuItem(name: "Leva", image: "leva-x", subMenuItems: [ MenuItem(name: "Leva X", image: "leva-x"), MenuItem(name: "Leva S", image: "leva-s") ]),
                                 MenuItem(name: "Strada", image: "strada-ep", subMenuItems: [ MenuItem(name: "Strada EP", image: "strada-ep"), MenuItem(name: "Strada AV", image: "strada-av"), MenuItem(name: "Strada MP", image: "strada-mp"), MenuItem(name: "Strada EE", image: "strada-ee") ]),
                                 MenuItem(name: "KB90", image: "kb90"),
                                 MenuItem(name: "Linea", image: "linea-pb-x", subMenuItems: [ MenuItem(name: "Linea PB X", image: "linea-pb-x"), MenuItem(name: "Linea PB", image: "linea-pb"), MenuItem(name: "Linea Classic", image: "linea-classic") ]),
                                 MenuItem(name: "GB5", image: "gb5"),
                                 MenuItem(name: "Home", image: "gs3", subMenuItems: [ MenuItem(name: "GS3", image: "gs3"), MenuItem(name: "Linea Mini", image: "linea-mini") ])
                                ]

// Sub-menu items for Grinder
let grinderMenuItems = [ MenuItem(name: "Swift", image: "swift"),
                         MenuItem(name: "Vulcano", image: "vulcano"),
                         MenuItem(name: "Swift Mini", image: "swift-mini"),
                         MenuItem(name: "Lux D", image: "lux-d")
                        ]

// Sub-menu items for other equipment
let otherMenuItems = [ MenuItem(name: "Espresso AV", image: "espresso-av"),
                         MenuItem(name: "Espresso EP", image: "espresso-ep"),
                         MenuItem(name: "Pour Over", image: "pourover"),
                         MenuItem(name: "Steam", image: "steam")
                        ]

To better organize the data model, we create a struct called CoffeeEquipmentModel like this:

struct CoffeeEquipmenModel {
    let mainMenuItems = {
        // Top menu items
        let topMenuItems = [ MenuItem(name: "Espresso Machines", image: "linea-mini", subMenuItems: espressoMachineMenuItems),
                                MenuItem(name: "Grinders", image: "swift-mini", subMenuItems: grinderMenuItems),
                                MenuItem(name: "Other Equipments", image: "espresso-ep", subMenuItems: otherMenuItems)
                            ]

        // Sub-menu items for Espresso Machines
        let espressoMachineMenuItems = [ MenuItem(name: "Leva", image: "leva-x", subMenuItems: [ MenuItem(name: "Leva X", image: "leva-x"), MenuItem(name: "Leva S", image: "leva-s") ]),
                                         MenuItem(name: "Strada", image: "strada-ep", subMenuItems: [ MenuItem(name: "Strada EP", image: "strada-ep"), MenuItem(name: "Strada AV", image: "strada-av"), MenuItem(name: "Strada MP", image: "strada-mp"), MenuItem(name: "Strada EE", image: "strada-ee") ]),
                                         MenuItem(name: "KB90", image: "kb90"),
                                         MenuItem(name: "Linea", image: "linea-pb-x", subMenuItems: [ MenuItem(name: "Linea PB X", image: "linea-pb-x"), MenuItem(name: "Linea PB", image: "linea-pb"), MenuItem(name: "Linea Classic", image: "linea-classic") ]),
                                         MenuItem(name: "GB5", image: "gb5"),
                                         MenuItem(name: "Home", image: "gs3", subMenuItems: [ MenuItem(name: "GS3", image: "gs3"), MenuItem(name: "Linea Mini", image: "linea-mini") ])
                                        ]

        // Sub-menu items for Grinder
        let grinderMenuItems = [ MenuItem(name: "Swift", image: "swift"),
                                 MenuItem(name: "Vulcano", image: "vulcano"),
                                 MenuItem(name: "Swift Mini", image: "swift-mini"),
                                 MenuItem(name: "Lux D", image: "lux-d")
                                ]

        // Sub-menu items for other equipment
        let otherMenuItems = [ MenuItem(name: "Espresso AV", image: "espresso-av"),
                                 MenuItem(name: "Espresso EP", image: "espresso-ep"),
                                 MenuItem(name: "Pour Over", image: "pourover"),
                                 MenuItem(name: "Steam", image: "steam")
                                ]

        return topMenuItems
    }()

    func subMenuItems(for id: MenuItem.ID) -> [MenuItem]? {
        guard let menuItem = mainMenuItems.first(where: { $0.id == id }) else {
            return nil
        }

        return menuItem.subMenuItems
    }

    func menuItem(for categoryID: MenuItem.ID, itemID: MenuItem.ID) -> MenuItem? {

        guard let subMenuItems = subMenuItems(for: categoryID) else {
            return nil
        }

        guard let menuItem = subMenuItems.first(where: { $0.id == itemID }) else {
            return nil
        }

        return menuItem
    }
}

The mainMenuItems array holds the sample menu items. Both subMenuItems and menuItem are helper methods for looking up a specific category or menu item.

Now that we’ve prepared the data model, let’s move onto the implementation of the navigation split view. Create a new file named TwoColumnSplitView.swift using the SwiftUI view template. Update the TwoColumnSplitView struct like this:

struct TwoColumnSplitView: View {

    @State private var selectedCategoryId: MenuItem.ID?

    private var dataModel = CoffeeEquipmenModel()

    var body: some View {
        NavigationSplitView {
            List(dataModel.mainMenuItems, selection: $selectedCategoryId) { item in
                HStack {
                    Image(item.image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 50)
                    Text(item.name)
                        .font(.system(.title3, design: .rounded))
                        .bold()
                }
            }

            .navigationTitle("Coffee")

        } detail: {
            if let selectedCategoryId,
               let categoryItems = dataModel.subMenuItems(for: selectedCategoryId) {

                List(categoryItems) { item in
                    HStack {
                        Image(item.image)
                            .resizable()
                            .scaledToFit()
                            .frame(width: 50, height: 50)
                        Text(item.name)
                            .font(.system(.title3, design: .rounded))
                            .bold()
                    }
                }
                .listStyle(.plain)
                .navigationBarTitleDisplayMode(.inline)

            } else {
                Text("Please select a category")
            }
        }

    }
}

The first closure of NavigationSplitView presents the main menu item. We use a List view to loop through all mainMenuItems in the data model and display each of the menu items using a HStack view.

We also have a state variable named selectedCategoryId, which is used to hold the selected main menu item.

For the detail closure, this is where the submenu item is rendered. If a category is selected, we call the subMenuItems method to get the submenu items for that particular category. We then display the submenu items using List view. Conversely, if no category is selected, we display a text message instructing the user to choose a category.

Once you made the changes, you should see a two-column navigation UI in the Xcode preview.

swiftui-split-view-three-columns

Creating a Three-Column Navigation Interface

Now that we’ve created a two-column navigation interface, let’s further enhance it to provide users with a three-column navigation experience. The extra column is used for displaying the photo of the selected equipment.

swiftui-navigation-split-view-three-column-demo

To convert the two-column navigation interface to three-column, we need to implement an additional parameter (i.e. content) for the NavigationSplitView. Let’s create a new view named ThreeColumnSplitView like this:

struct ThreeColumnSplitView: View {
    @State private var selectedCategoryId: MenuItem.ID?
    @State private var selectedItem: MenuItem?

    private var dataModel = CoffeeEquipmenModel()

    var body: some View {
        NavigationSplitView {

            List(dataModel.mainMenuItems, selection: $selectedCategoryId) { item in
                HStack {
                    Image(item.image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 50)
                    Text(item.name)
                        .font(.system(.title3, design: .rounded))
                        .bold()
                }
            }

            .navigationTitle("Coffee")
        } content: {

            if let selectedCategoryId,
               let subMenuItems = dataModel.subMenuItems(for: selectedCategoryId) {
                List(subMenuItems, selection: $selectedItem) { item in
                    NavigationLink(value: item) {
                        HStack {
                            Image(item.image)
                                .resizable()
                                .scaledToFit()
                                .frame(width: 50, height: 50)
                            Text(item.name)
                                .font(.system(.title3, design: .rounded))
                                .bold()
                        }
                    }
                }
                .listStyle(.plain)
                .navigationBarTitleDisplayMode(.inline)

            } else {
                Text("Please select a menu item")
            }

        } detail: {
            if let selectedItem {
                Image(selectedItem.image)
                    .resizable()
                    .scaledToFit()
            } else {
                Text("Please select an item")
            }
        }

    }
}

Basically, the code in the content closure should be very similar to you. The content parameter is designed for displaying the submenu items. Thus, we use the List view to show the submenu items for the selected category.

When an item is selected in the submenu, the app shows the equipment photo. This is achieved by the code written in the detail closure.

After the code changes, the preview pane should show you a two-column layout.

swiftui-navigation-empty

By default, the first column is hidden. You need to tap the menu button at the top-left corner to reveal it.

To control the visibility of the split view, you can declare a state variable of the type NavigationSplitViewVisibility and set its value to .all:

@State private var columnVisibility = NavigationSplitViewVisibility.all

When instantiating the NavigationSplitView, it has an option parameter named columnVisibility. You can simply pass the binding of columnVisibility to control the visibility of the columns.

swiftui-column-visiblity-split-view

The NavigationSplitViewVisibility.all value tells iPadOS to display all the three columns. There are other options including:

  • .automatic – Use the default leading column visibility for the current device. This is the default setting.
  • .doubleColumn – Show the content column and detail area of a three-column navigation split view.
  • .detailOnly – Hide the leading two columns of a three-column split view. In other words, only the detail area shows.

Customizing the Style of Navigation Split Views

Have you tested the app in iPad Portrait? By default, the detail area takes up the whole screen when the iPad device is in portrait mode. So, when you bring up the main menu and submenu areas, the detail area is hidden behind these two leading columns.

swiftui-portrait-multiple-column

If you don’t like this style, you can change it by attaching the .navigationSplitViewStyle modifier to NavigationSplitView:

NavigationSplitView(columnVisibility: $columnVisibility) {
  .
  .
  .
}
.navigationSplitViewStyle(.balanced)

The default value is set to .automatic. If you set the value to .balanced, it reduces the detail area such that the two leading columns can appear at the same time.

swiftui-splitview-multi-column-balanced

What’s Next

This tutorial gives you an introduction to NavigationSplitView in iOS 16. It’s very easy to create multi-column navigation experience for iPad users. Even if you develop apps for iPhone users, NavigationSplitView can adapt itself to fit the navigation experience on narrower screens. For example, when iPhone 13 Pro Max is in portrait mode, the split view shows a one-column navigation. If you rotate the device sideway, the split view changes to a multi-column layout.

Therefore, take some time to study this split view component and apply it to your app whenever this kind of UI makes sense.

For reference, if you want to learn more about NavigationSplitView, you can check out this WWDC video.

If you enjoy this article and want to dive deeper into SwiftUI, you may check out our Mastering SwiftUI book.

Read next