swiftswiftui-navigationsplitviewnavigationsplitview

navigationSplitViewColumnWidth being ignored. Cannot widen sidebar or detail column


I am trying to use NavigationSplitView to create a two-column structure where right column depends on left column and the left column is a bit larger than the right. Thinking I might want the two columns to stack on a phone. For a simple example I'm using the same view in both columns.

No matter what I try to do I cannot get the left column to be wider than about 25% of an iPad in Landscape (or about 35% in portrait). I need the left column to be more like 60% of the width.

I have tried:

The view is static so I must have .navigationSplitViewStyle(.balanced) and no toolbar.

Am I just using the wrong thing, should I use something instead of NavigationSplitView for a two-column view on an iPad in Landscape?

struct SuggestionContentView : View {
    @State var isPresented:Bool = false
    
    @State var items = [
        "crown",
        "cereal",
        "color",
        "cookie",
        "can",
        "cat",
    ]
    var nullAction: (() -> Void)?
    var body: some View {
        List {
            ForEach(items, id:\.self) { item in
                Button {
                    isPresented = true
                } label: {
                    Text(item)
                }
            }
        }
        .navigationBarBackButtonHidden(true)
    }
}

Here is the layout with three columns and a zero-width sidebar:

struct ClickAndSuggestionLayout: View {
    @State private var columnVisibility =
    NavigationSplitViewVisibility.doubleColumn
    
    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            EmptyView().navigationSplitViewColumnWidth(0)
        } content: {
            SuggestionContentView()
                .toolbar(.hidden, for: .navigationBar)
                .navigationSplitViewColumnWidth( 800)
        } detail: {
            SuggestionContentView()
                .toolbar(.hidden, for: .navigationBar)
                .navigationSplitViewColumnWidth(400)
        }
        .navigationSplitViewStyle(.balanced)
    }
}

Here is the layout with two columns per online examples:

struct RadialAndSuggestionLayout: View {
    @State private var columnVisibility =
    NavigationSplitViewVisibility.doubleColumn
    
    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            SuggestionContentView()
                .toolbar(.hidden, for: .navigationBar)
                .navigationSplitViewColumnWidth( 800)
        } detail: {
            SuggestionContentView()
                .toolbar(.hidden, for: .navigationBar)
                .navigationSplitViewColumnWidth(400)
        }
        .navigationSplitViewStyle(.balanced)
    }
}

In both case I get previews like this:

enter image description here

enter image description here


Solution

  • From the documentation,

    Only some platforms enable resizing columns. If you specify a width that the current presentation environment doesn’t support, SwiftUI may use a different width for your column.

    navigationSplitViewColumnWidth is only just a "suggestion". SwiftUI has every right to not resize the column to the width you want. From my experiments, it seems like navigationSplitViewColumnWidth does not set maximumPrimaryColumnWidth of the underlying UISplitViewController at all, even if you pass a max: argument.

    You will need to use the UISplitViewController APIs to make this work. Specifically, you need to set preferredPrimaryColumnWidth or preferredPrimaryColumnWidthFraction to your desired width, and maximumPrimaryColumnWidth to a big enough value.

    For example, here is how you would do it using SwiftUI-Introspect.

    NavigationSplitView {
        Color.blue
    } detail: {
        Color.yellow
    }
    .introspect(.navigationSplitView, on: .iOS(.v16, .v17)) { splitView in
        splitView.maximumPrimaryColumnWidth = .infinity
        splitView.preferredPrimaryColumnWidthFraction = 2 / 3
    }
    

    Wrapping a UISplitViewController with UIViewControllerRepresentable is a more future-proof solution, though. Here is a simple example:

    struct UIKitSplitView<Primary, Secondary, Selection>: UIViewControllerRepresentable
        where Primary : View, Secondary : View, Selection: Hashable {
        
        typealias UIViewControllerType = UISplitViewController
        
        let primary: Primary
        let secondary: Secondary
        let selection: Selection?
        
        init(selection: Selection?, @ViewBuilder primary: @escaping () -> Primary, @ViewBuilder secondary: @escaping () -> Secondary) {
            self.primary = primary()
            self.secondary = secondary()
            self.selection = selection
        }
        
        func makeUIViewController(context: Context) -> UISplitViewController {
            
            let splitViewController = UISplitViewController(style: .doubleColumn)
            splitViewController.preferredPrimaryColumnWidth = 800
            splitViewController.maximumPrimaryColumnWidth = .infinity
            splitViewController.delegate = context.coordinator
            
            let primaryHostingViewController = UIHostingController(rootView: primary)
            let secondaryHostingViewController = UIHostingController(rootView: secondary)
                    
            splitViewController.setViewController(primaryHostingViewController, for: .primary)
            splitViewController.setViewController(secondaryHostingViewController, for: .secondary)
                    
            return splitViewController
        }
        
        func updateUIViewController(_ uiViewController: UISplitViewController, context: Context) { 
            (uiViewController.viewController(for: .primary) as! UIHostingController<Primary>).rootView = primary
            (uiViewController.viewController(for: .secondary) as! UIHostingController<Secondary>).rootView = secondary
            if let selection {
                uiViewController.show(.secondary)
            }
        }
        
        func makeCoordinator() -> Coordinator {
            Coordinator()
        }
        
        class Coordinator: NSObject, UISplitViewControllerDelegate {
            func splitViewController(_ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column) -> UISplitViewController.Column {
                return .primary
            }
        }
    }
    

    Example Usage:

    struct ContentView: View
    {
        @State var selection: Int?
        
        var body: some View {
            // the "selection" parameter here acts as a trigger for showing the detail view
            // when "selection" is not nil, the detail view is shown
            UIKitSplitView(selection: selection) {
                List([1,2,3,4], id: \.self, selection: $selection) {
                    Text("Row \($0)")
                }
            } secondary: {
                if let selection {
                    Text("Details for \(selection)")
                }
            }
            .ignoresSafeArea()
            
        }
    }