14

I want to build a very simple iOS 14 sidebar using SwiftUI. The setup is quite simple, I have three views HomeView, LibraryView and SettingsView and an enum representing each screen.

enum Screen: Hashable {
   case home, library, settings
}

My end-goal is to automatically switch between a tab view and a sidebar depending on the size class but some things don't quite work as expected.

The global state is owned by the MainNavigationView, which is also the root view for my WindowGroup.

struct MainNavigationView: View {
    @State var screen: Screen? = .home
   
    var body: some View {
        NavigationView {
            SidebarView(state: $screen)
        }
        .navigationViewStyle(DoubleColumnNavigationViewStyle())
    }
}

The SidebarView is a simple List containing three NavigationLink, one for each Screen.

struct SidebarView: View {
    @Binding var state: Screen?
    var body: some View {
        List {
            NavigationLink(
                destination: HomeView(),
                tag: Screen.home,
                selection: $state,
                label: {
                    Label("Home", systemImage: "house" )
                })
            NavigationLink(
                destination: LibraryView(),
                tag: Screen.library,
                selection: $state,
                label: {
                    Label("Library", systemImage: "book")
                })
            NavigationLink(
                destination: SettingsView(),
                tag: Screen.settings,
                selection: $state,
                label: {
                    Label("Settings", systemImage: "gearshape")
                })
        }
        .listStyle(SidebarListStyle())
        .navigationTitle("Sidebar")
    
    }
}

I use the NavigationLink(destination:tag:selection:label) initializer so that the selected screen is set in my MainNavigationView so I can reuse that for my TabView later.

However, a lot of things don't quite work as expected.

First, when launching the app in a portrait-mode iPad (I used the iPad Pro 11-inch simulator), no screen is selected when launching the app. Only after I click Back in the navigation bar, the initial screen shows and my home view gets shown.

First bug: HomeView is only shown after the Back button was tapped

The second weird thing is that the binding seems to be set to nil whenever the sidebar gets hidden. In landscape mode the view works as expected, however when toggling the sidebar to hide and then shown again, the selection gets lost. The content view stays correct, but the sidebar selection is lost.

Toggling the sidebar resets the state

Are these just SwiftUI bugs or is there a different way to create a sidebar with a Binding?

jlsiewert
  • 3,494
  • 18
  • 41
  • 1
    Seems to be the same issue as described [here](https://stackoverflow.com/questions/62760985/swiftui-sidebar-doesnt-remember-state) – jlsiewert Jul 06 '20 at 17:34

1 Answers1

9

You need to include a default secondary view within the NavigationView { }, usually it would be a placeholder but you could use the HomeScreen, e.g.

struct MainNavigationView: View {
    @State var screen: Screen? = .home
   
    var body: some View {
        NavigationView {
            SidebarView(state: $screen)
            HomeScreen()
        }
        .navigationViewStyle(DoubleColumnNavigationViewStyle())
    }
}

Regarding the cell not re-selecting - as of iOS 14.2 there is no list selection binding (when not in editing mode) so selection is lost. Although the List API has a $selection param, it is only supported on macOS at the moment. You can see that info the header:

/// On iOS and tvOS, you must explicitly put the list into edit mode for
/// the selection to apply.

It's a bit convoluted but it means that selection binding that we need for a sidebar is only for macOS, on iOS it is only for multi-select (i.e. checkmarks) in edit mode. The reason could be since UITableView's selection is event driven, maybe it wasn't possible to translate into SwiftUI's state driven nature. If you've ever tried to do state restoration with a view already pushed on a nav controller and try to show the cell unhighlight animation when popping back and that table view wasn't loaded and cell was never highlighted in the first place you'll know what I mean. It was a nightmare to load the table synchronously, make the selected cell be drawn and then start the unhighlight animation. I expect that Apple will be reimplementing List, Sidebar and NavigationView in pure SwiftUI to overcome these issues so for now we just have to live with it.

Once this has been fixed it will be as simple as List(selection:$screen) { } like how it would work on macOS. As a workaround on iOS you could highlight the icon or text in your own way instead, e.g. try using bold text:

    NavigationLink(
        destination: HomeView(),
        tag: Screen.home,
        selection: $state,
        label: {
            Label("Home", systemImage: "house" )
        })
        .font(Font.headline.weight(state == Screen.home ? .bold : .regular))

enter image description here

It doesn't look very nice when in compact because after popping the main view, the bold is removed when the row is un-highlighted. There might be a way to disable using bold in that case.

There are 2 other bugs you should be aware of:

  1. In portrait the sidebar only shows on the second tap of the Sidebar nav button.
  2. In portrait if you show the sidebar and select the same item that is already showing, the sidebar does not dismiss.
malhal
  • 26,330
  • 7
  • 115
  • 133
  • 1
    Thanks for the detailed answer, even though it just looks like a bug. I'll post a update here after I tried some of these ideas out. – jlsiewert Oct 28 '20 at 07:00