'macOS SwiftUI Navigation for a Single View

I'm attempting to create a settings view for my macOS SwiftUI status bar app. My implementation so far has been using a NavigationView, and NavigationLink, but this solution produces a half view as the settings view pushes the parent view to the side. Screenshot and code example below.

Navigation Sidebar

enter image description here

struct ContentView: View {
    var body: some View {
        VStack{
            NavigationView{
            NavigationLink(destination: SecondView()){
                Text("Go to next view")
                }}
        }.frame(width: 800, height: 600, alignment: .center)}
}

struct SecondView: View {
    var body: some View {
        VStack{

                Text("This is the second view")

        }.frame(width: 800, height: 600, alignment: .center)
    }
}

The little information I can find suggests that this is unavoidable using SwiftUI on macOS, because the 'full screen' NavigationView on iOS (StackNavigationViewStyle) is not available on macOS.

Is there a simple or even complex way of implementing a transition to a settings view that takes up the whole frame in SwiftUI for macOS? And if not, is it possible to use AppKit to call a View object written in SwiftUI?

Also a Swift newbie - please be gentle.



Solution 1:[1]

Here is a simple demo of possible approach for custom navigation-like solution. Tested with Xcode 11.4 / macOS 10.15.4

demo

Note: background colors are used for better visibility.

struct ContentView: View {
    @State private var show = false
    var body: some View {
        VStack{
            if !show {
                RootView(show: $show)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.blue)
                    .transition(AnyTransition.move(edge: .leading)).animation(.default)
            }
            if show {
                NextView(show: $show)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.green)
                    .transition(AnyTransition.move(edge: .trailing)).animation(.default)
            }
        }
    }
}

struct RootView: View {
    @Binding var show: Bool
    var body: some View {
        VStack{
            Button("Next") { self.show = true }
            Text("This is the first view")
        }
    }
}

struct NextView: View {
    @Binding var show: Bool
    var body: some View {
        VStack{
            Button("Back") { self.show = false }
            Text("This is the second view")
        }
    }
}

backup

Solution 2:[2]

I've expanded upon Asperi's great suggestion and created a generic, reusable StackNavigationView for macOS (or even iOS, if you want). Some highlights:

  • It supports any number of subviews (in any layout).
  • It automatically adds a 'Back' button for each subview (just text for now, but you can swap in an icon if using macOS 11+).

Example view on macOS

Swift v5.2:

struct StackNavigationView<RootContent, SubviewContent>: View where RootContent: View, SubviewContent: View {
    
    @Binding var currentSubviewIndex: Int
    @Binding var showingSubview: Bool
    let subviewByIndex: (Int) -> SubviewContent
    let rootView: () -> RootContent
    
    var body: some View {
        VStack {
            VStack{
                if !showingSubview { // Root view
                    rootView()
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .transition(AnyTransition.move(edge: .leading)).animation(.default)
                }
                if showingSubview { // Correct subview for current index
                    StackNavigationSubview(isVisible: self.$showingSubview) {
                        self.subviewByIndex(self.currentSubviewIndex)
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .transition(AnyTransition.move(edge: .trailing)).animation(.default)
                }
            }
        }
    }
    
    init(currentSubviewIndex: Binding<Int>, showingSubview: Binding<Bool>, @ViewBuilder subviewByIndex: @escaping (Int) -> SubviewContent, @ViewBuilder rootView: @escaping () -> RootContent) {
        self._currentSubviewIndex = currentSubviewIndex
        self._showingSubview = showingSubview
        self.subviewByIndex = subviewByIndex
        self.rootView = rootView
    }
    
    private struct StackNavigationSubview<Content>: View where Content: View {
        
        @Binding var isVisible: Bool
        let contentView: () -> Content
        
        var body: some View {
            VStack {
                HStack { // Back button
                    Button(action: {
                        self.isVisible = false
                    }) {
                        Text("< Back")
                    }.buttonStyle(BorderlessButtonStyle())
                    Spacer()
                }
                .padding(.horizontal).padding(.vertical, 4)
                contentView() // Main view content
            }
        }
    }
}

More info on @ViewBuilder and generics used can be found here.

Here's a basic example of it in use. The parent view tracks current selection and display status (using @State), allowing anything inside its subviews to trigger state changes.

struct ExampleView: View {
    
    @State private var currentSubviewIndex = 0
    @State private var showingSubview = false
    
    var body: some View {
        StackNavigationView(
            currentSubviewIndex: self.$currentSubviewIndex,
            showingSubview: self.$showingSubview,
            subviewByIndex: { index in
                self.subView(forIndex: index)
            }
        ) {
            VStack {
                Button(action: { self.showSubview(withIndex: 0) }) {
                    Text("Show View 1")
                }
                Button(action: { self.showSubview(withIndex: 1) }) {
                    Text("Show View 2")
                }
                Button(action: { self.showSubview(withIndex: 2) }) {
                    Text("Show View 3")
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color.blue)
        }
    }
    
    private func subView(forIndex index: Int) -> AnyView {
        switch index {
        case 0: return AnyView(Text("I'm View One").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.green))
        case 1: return AnyView(Text("I'm View Two").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.yellow))
        case 2: return AnyView(VStack {
            Text("And I'm...")
            Text("View Three")
        }.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.orange))
        default: return AnyView(Text("Inavlid Selection").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.red))
        }
    }
    
    private func showSubview(withIndex index: Int) {
        currentSubviewIndex = index
        showingSubview = true
    }
}

Note: Generics like this require all subviews to be of the same type. If that's not so, you can wrap them in AnyView, like I've done here. The AnyView wrapper isn't required if you're using a consistent type for all subviews (the root view’s type doesn’t need to match).

Solution 3:[3]

Heyo, so a problem I had is that I wanted to have multiple navigationView-layers, I'm not sure if that's also your attempt, but if it is: MacOS DOES NOT inherit the NavigationView. Meaning, you need to provide your DetailView (or SecondView in your case) with it's own NavigationView. So, just embedding like [...], destination: NavigationView { SecondView() }) [...] should do the trick.

But, careful! Doing the same for iOS targets will result in unexpected behaviour. So, if you target both make sure you use #if os(macOS)!

However, when making a settings view, I'd recommend you also look into the Settings Scene provided by Apple.

Solution 4:[4]

Seems this didn't get fixed in Xcode 13.

Tested on Xcode 13 Big Sur, not on Monterrey though...enter image description here

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2
Solution 3 thisIsTheFoxe
Solution 4 Mane Manero