Search code examples
swiftuiswiftui-navigationsplitview

NavigationSplitView - having a .sheet in the detail view breaks in compact/iPhone mode


I'm trying to get a NavigationSplitView working where on the iPhone in compact/portrait mode when you go to the detail view there is a '.sheet' that is available only on that view, and when you navigate back it goes away. I'm coming across a few issues and I'm not sure what I'm doing wrong.

1 - preferredCompactColumn seems to be ignored at the start in my minimal reproducible sample, it always starts into the detail view. fixed thanks to link provided by sweeper - set to nil

2 - the sheet initially appears over the sidebar view at first launch despite being attached to the detail view. I get an error message... "swiftui sheet Presenting view controller  from detached view controller is not supported, and may result in incorrect safe area insets and a corrupt root presentation. Make sure  is in the view controller hierarchy before presenting from it. Will become a hard exception in a future release."

Bottom sheet showing in the sideview at launch

Minimal Reproducible Code below.

import SwiftUI

// simple sidebar for testing
struct SideBarView: View {
    
    @State private var selectedInt: Int? = nil
    
    var body: some View {
        List(1...3, id: \.self, selection: $selectedInt) { int in
            NavigationLink("Row \(int)", value: int)
        }
    }
}

// Detail View with a permanent bottom sheet
struct DetailView: View {
    
    @State var sheetVisible: Bool = false
    
    // fill the space
    var body: some View {
        VStack {
            Spacer()
            Text("detail view")
            Spacer()
        }
        .onAppear(perform: {
            sheetVisible = true
        })
        .onDisappear(perform: {
            sheetVisible = false
        })
        // show a bottom sheet that is for this view
        // in this case I have dismiss disable but it seems to break regardless
        .sheet(isPresented: $sheetVisible) {
            Text("Bottom Sheet")
                .presentationDetents([.fraction(0.15), .medium, .large])
                .presentationDragIndicator(.visible)
                .interactiveDismissDisabled()
                .presentationBackgroundInteraction(
                    .enabled(upThrough: .medium)
                )
        }
        
    }
}

struct ContentView: View {
    
    // bug? - this seems to be ignored at the start.  Initial view is the detail
    @State private var preferredColumn = NavigationSplitViewColumn.sidebar
    
    var body: some View {
        NavigationSplitView(
            preferredCompactColumn: $preferredColumn)
        {
            // simple sidebar
            SideBarView()
                .navigationTitle("Sidebar")
        } detail: {
            // bug? - this detail view has a sheet displayed when active
            // but it's causing issues with ehe split view and I'm not sure
            // what I've done wrong with the setup
            DetailView()
                .navigationTitle("Details")
        }
    }
}



Solution

  • Tthe sheet appears even in the sidebar, because apparently the detail view's onAppear is called as soon as the sidebar appears, even when the detail view hasn't appeared. I think this might not be intentional.

    To work around this, I thought of a way where you would need to shift the ownership of selectedInt from SideBarView to ContentView. The idea is to let the detail view know about whether anything is selected in the sidebar.

    struct SideBarView: View {
        
        @Binding var selectedInt: Int?
        
        var body: some View {
            List(1...3, id: \.self, selection: $selectedInt) { int in
                NavigationLink("Row \(int)", value: int)
            }
        }
    }
    
    struct DetailView: View {
        
        @State var sheetVisible: Bool = false
        
        let hasSelected: Bool
        
        var body: some View {
            VStack {
                Spacer()
                Text("detail view")
                Spacer()
            }
            .onChange(of: hasSelected, { oldValue, newValue in
                if !oldValue && newValue {
                    sheetVisible = true
                }
            })
            .sheet(isPresented: $sheetVisible) {
                Text("Bottom Sheet")
                    .presentationDetents([.fraction(0.15), .medium, .large])
                    .presentationDragIndicator(.visible)
                    .interactiveDismissDisabled()
                    .presentationBackgroundInteraction(
                        .enabled(upThrough: .medium)
                    )
            }
            
        }
    }
    
    struct ContentView: View {
        @State private var selectedInt: Int?
        @State private var preferredColumn = NavigationSplitViewColumn.sidebar
        
        var body: some View {
            NavigationSplitView(
                preferredCompactColumn: $preferredColumn)
            {
                SideBarView(selectedInt: $selectedInt)
                    .navigationTitle("Sidebar")
            } detail: {
                DetailView(hasSelected: selectedInt != nil)
                    .navigationTitle("Details")
            }
        }
    }
    

    DetailView now knows whether itself is presented by checking hasSelected. Only when hasSelected changes from false to true (this means the user tapped a row in the sidebar), does it set sheetVisible = true.