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.
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.
Are these just SwiftUI
bugs or is there a different way to create a sidebar with a Binding
?
In Swift, you just need to specify the method name as a string literal to construct a selector. Lastly, we add a gesture recognizer. Not only you can use the menu button to bring out the sidebar menu, the user can swipe the content area to activate the sidebar as well. Cool! Let’s compile and run the app in the simulator.
Normally, the navigation menu is hidden behind the front view. The menu can then be triggered by tapping a list button in the navigation bar. Once the menu is expanded and becomes visible, users can close it by using the list button or simply swiping left on the content area. You can build the sidebar menu from the ground up.
Not only you can use the menu button to bring out the sidebar menu, the user can swipe the content area to activate the sidebar as well. Cool! Let’s compile and run the app in the simulator. Tap the menu button and the sidebar menu should appear. You can hide the sidebar menu by tapping the menu button again.
To use SWRevealViewController for building a sidebar menu, you create a container view controller, which is actually an empty view controller, to hold both the menu view controller and a set of content view controllers. I have already created the menu view controller for you. It is just a static table view with three menu items.
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))
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:
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With