SplitView Navigation: An issue with state restoration


In SwiftUI when we use DoubleColumnNavigationViewStyle the framework defaults to display the detail view when first presenting your application. If the user is in a medium size class (vertical iPad or 50% split screen horizontal) the Sidebar is not rendered. And the user is required to swipe from the leading edge (or tap the back button) to show the sidebar.


This becomes annoying if you want to support state restoration since without the sidebar being rendered the navigation is not triggered and the user sees the nothing selected view every time the app is relaunched rather than the restored state, and as soon as they tap the back button the state is restored creating a jarring experience.


A simple app will typically contain a ContentView with the List of navigation links and a NothingSelectedView to be shown when there is no active navigation.


struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                ...
            }.listStyle(SidebarListStyle())
            NothingSelectedView()
        }.navigationViewStyle(
            DoubleColumnNavigationViewStyle()
        )
    }
}


The goal is to find a way to trigger the list's body to be called so that any existing state restored navigation is detected and triggered.


Triggering the sidebar to slide over


By using UIViewRepresentable we can reach into the UIKit apis and find the parent UISplitViewController that SwiftUI is using and show(.primary) when the NothingSelectedView appears on screen.


Firstly let us make a View to do this task.


struct UIKitShowSidebar: UIViewRepresentable {
    let onScreen: Bool

    func makeUIView(context: Context) -> some UIView {
        let uiView = UIView()
        
        if self.onScreen {
            DispatchQueue.main.async { [weak uiView] in
                uiView?.next(
                    of: UISplitViewController.self
                )?.show(.primary)
            }
        }
        
        return uiView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        DispatchQueue.main.async { [weak uiView] in
            uiView?.next(
                of: UISplitViewController.self
            )?.show(.primary)
        }
    }
}


This view calls a method on UIResponder that returns the next item in the responder change that can be cast to the requested type. We need to define this method:


extension UIResponder {
    func next<T>(of type: T.Type) -> T? {
        guard let nextValue = self.next else {
            return nil
        }
        guard let result = nextValue as? T else {
            return nextValue.next(of: type.self)
        }
        return result
    }
}

Adding the UIKitShowSidebar to the NothingSelectedView


struct NothingSelectedView: View {
    
    #if canImport(UIKit)
    @State
    var onScreen: Bool = false
    #endif
    
    var body: some View {
        Label(
            "Nothing Selected",
            systemImage: "exclamationmark.triangle"
        )
        
        #if canImport(UIKit)
        UIKitShowSidebar(onScreen: onScreen).frame(
            width: 0,
            height: 0
        ).onAppear {
            onScreen = true
        }.onDisappear {
            onScreen = false
        }
        #endif
    }
}


This triggers the SideBar to slide-over, rendering the navigation links in the SideBar body and resulting in state restoration.