How to Use NavigationSplitView for Multi-Column Display in iPad Portrait Mode

ยท

7 min read

How to Use NavigationSplitView for Multi-Column Display in iPad Portrait Mode

Hi everyone, I recently resumed work on an app I put on hold for many months. I will give you more details later, but this app should work on multiple Apple platforms, such as iOS, iPadOS, and macOS.

My first task was to create a simple list using NavigationSplitView. NavigationSplitView was introduced in iOS 16 (and macOS 13.0) along with NavigationStackView to improve how SwiftUI manages navigation between screens (which was originally quite poor!).

Both also help you display a list of items on the screen, but the main difference is that NavigationStackView is designed for managing a single stack of views. It's often used for typical hierarchical navigation where you push and pop views onto a stack. If your app only supports iPhone, it's preferred to use NavigationStackView.

If you want to learn more about NavigationStackView, check out this video.

However, if you are planning (like me) an app that will run on iPadOS or macOS, NavigationSplitView is a better option, because is designed for managing split-view navigation in multi-column interfaces:

However, right off the bat, I found an interesting issue while working with NavigationSplitView on iPad. Keep reading to learn more ๐Ÿ‘‡๐Ÿป

What is the problem?

First off, this is the code that display the UI above:

struct ObjectList: View {
    @State private var selectedItem: Int? // 1

    var body: some View {
        NavigationSplitView {
            List(
                0..<10,
                selection: $selectedItem
            ) { item in
                NavigationLink(value: item) { // 3
                    Text("Object " + item.description)
                }
            }
            .navigationTitle("Objects")
        } detail: {
            if let item = selectedItem { // 2
                Text(item.description)
            } else {
                Text("Select an Item") 
            }
        }
    }
}
  1. I created a state property selectedItem to store the currently selected item.

  2. By default, nothing is selected, and a placeholder text is displayed on the right screen (detail). Once I tap on any item from the list, the app displays the text with the selected item number.

  3. For this two-column setup, the system doesn't push to a new detail screen. However, under the hood each item in the list acts as a navigation link, displaying the new screen on the right.

By the way, NavigationSplitView acts as NavigationStackView on iOS (iPhone). User won't notice any difference.

The code above is trivial. So let's see the result in all the multiple OSs:

  • iOS:

  • macOS:

For iOS and macOS, everything looks good, now let's review iPadOS in Landscape and Portrait:

  • iPadOS - Landscape:

  • iPadOS - Portrait:

Have you noticed the issue? By default, iPadOS in Portrait mode doesn't show the sidebar list. I don't know about you, but I expected to see the list by default.

For some apps, this behavior might be good because it optimizes screen space. However, for the app I have in mind, I prefer to be consistent and display the list. Of course, the user can still hiding it using the top-left icon if they want.

After an hour of investigation, I found a way to achieve this. It turns out that NavigationSplitView has a property called NavigationSplitViewVisibility that controls how we display the split screens.

Let's learn how to use it!

Using NavigationSplitViewVisibility to fix the Column Visibility

NavigationSplitView has two configurations:

  • Two-Column Layout (this what I'm using):
/// Creates a two-column navigation split view.
///
/// - Parameters:
///   - sidebar: The view to show in the leading column.
///   - detail: The view to show in the detail area.
public init(
    @ViewBuilder sidebar: () -> Sidebar,
    @ViewBuilder detail: () -> Detail
) where Content == EmptyView
  • And Three-Column Layout (that we will explore in detail later):
/// Creates a three-column navigation split view.
///
/// - Parameters:
///   - sidebar: The view to show in the leading column.
///   - content: The view to show in the middle column.
///   - detail: The view to show in the detail area.
public init(
    @ViewBuilder sidebar: () -> Sidebar,
    @ViewBuilder content: () -> Content,
    @ViewBuilder detail: () -> Detail
)

In addition, both initializers have a version that includes a parameter called columnVisibility:

/// Creates a two-column navigation split view that enables programmatic
/// control of the sidebar's visibility.
///
/// - Parameters:
///   - columnVisibility: A ``Binding`` to state that controls the
///     visibility of the leading column.
///   - sidebar: The view to show in the leading column.
///   - detail: The view to show in the detail area.
public init(
    columnVisibility: Binding<NavigationSplitViewVisibility>,
    @ViewBuilder sidebar: () -> Sidebar,
    @ViewBuilder detail: () -> Detail
) where Content == EmptyView

columnVisibility is a Binding value of type NavigationSplitViewVisibility, which is a struct (not an enum) holding the following static values (that act as enum's cases):

@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
struct NavigationSplitViewVisibility {
    static var detailOnly: NavigationSplitViewVisibility { get }
    static var doubleColumn: NavigationSplitViewVisibility { get }
    static var all: NavigationSplitViewVisibility { get }
    static var automatic: NavigationSplitViewVisibility { get }
}

One of my first videos explain Binding in detail. Check out this link if you want to learn more.

  • detailOnly: It will hide the sidebars and content column leaving only detail view visible (regardless of the orientation).

  • doubleColumn: For Three-Column configuration, it will show Content and detail view only. For Two-Column configuration, Both sideBar and detail are visible.

    • For Two-Column configuration, doubleColumn is equivalent to all.
  • all: Show all the columns of a three-column navigation split view.

  • automatic: This is the default behavior. It will return any of the three values above depending on the orientation and column configuration.

Internally, my NavigationSplitView is using automatic. Since I want to display both sidebar and the detail content regardless of the orientation, I will use all configuration.

Since this is a binding value, let's create a @State property to store the column visibility, and pass it to NavigationSplitView initializer:

struct ObjectList: View {
    @State private var selectedItem: Int?
    @State private var columnVisibility = NavigationSplitViewVisibility.all

    var body: some View {
        NavigationSplitView(
            columnVisibility: $columnVisibility
        ) {
            List(
                0..<10,
                selection: $selectedItem
            ) { item in
                ...
            }
            .navigationTitle("Objects")
        } detail: {
            ...
        }
    }
}

Now we are finally showing the sidebar by default, however, we got another issue:

The sidebar is overlaying the default screen adding a shadow effect, but what I need is that both look side-by-side.

Second, if you touch the detail screen, the app hides the sidebar. We want to keep it visible unless the user presses the top-left button.

To fix that, we will apply a modifier on NavigationSplitView called .navigationSplitViewStyle. This modifier accepts the following values of type NavigationSplitViewStyle:

  • automatic: The default behavior. It will resolve the appearance based on the current context.

  • balanced: A navigation split style that reduces the size of the detail content to make room when showing the leading column or columns.

  • prominentDetail: A navigation split style that attempts to maintain the size of the detail content when hiding or showing the leading columns.

For my case, I will use balanced:

struct ObjectList: View {
    @State private var selectedItem: Int?
    @State private var columnVisibility = NavigationSplitViewVisibility.all

    var body: some View {
        NavigationSplitView(
            columnVisibility: $columnVisibility
        ) {
            List(
                0..<10,
                selection: $selectedItem
            ) { item in
                ...
            }
            .navigationTitle("Objects")
        } detail: {
            ...
        }
        .navigationSplitViewStyle(.balanced) // <-------
    }
}

And now see the result! This is what I was looking for:

Wrap Up

In this article, we explored NavigationSplitView to build apps that work on multiple Apple OSs, such as iOS, iPadOS, or macOS.

We reviewed how NavigationSplitViewVisibility can help you manage the way a multicolumn app is displayed on the screen, and how the navigationSplitViewStyle modifier can adjust the layout to make more room for detail or balance the size between the detail and the sidebar.

There will be more to explore in my journey of making multiplatform apps. I will share more insights in upcoming articles.

If you have questions about NavigationSplitViewVisibility or any other Swift topic, please let me know in the comments below.

That's it for me, remember, my name is Pitt, and this, this is swiftandtips.com! Thanks for reading and have a great day! ๐Ÿ‘‹๐Ÿป

References

https://developer.apple.com/documentation/swiftui/navigationsplitview

https://developer.apple.com/documentation/swiftui/navigationsplitviewvisibility

https://developer.apple.com/documentation/swiftui/navigationsplitviewstyle

ย