Create a SwiftUI Sidebar - ios

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?

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:
In portrait the sidebar only shows on the second tap of the Sidebar nav button.
In portrait if you show the sidebar and select the same item that is already showing, the sidebar does not dismiss.

Related

Bug with NavigationLink and #Binding properties causing unintended Interactions in the app

I've encountered a bug in SwiftUI that could cause unintended interaction with the app without the user's knowledge.
Description
The problem seems to be related to using #Binding properties on the View structs when used in conjunction with NavigationStack and NavigationLink. If you use NavigationView with NavigationLink to display a DetailView that accepts a $Binding parameter, and that parameter is used in some sort of condition in the DetailView, it will result in unexpected behavior.
To clearly show the problem, I'm using a DetailView where the "Blue" or "Red" view is shown depending on the #Binding property. Each of those views has a .onTapGesture() modifier that prints some text when tapped. The problem is that if the Red view is shown, it detects and triggers the action on the Blue view, which could lead to unintended changes in many apps without the user's knowledge.
Replication of the problem
You can easily copy and paste this code into your own file to replicate the bug. To see the unexpected behavior, run the code below and follow these steps on the simulator:
Tap on the DetailView in the NavigationLink.
Tap the blue color area and the console will print "Blue Tapped".
Tap the "RED BUTTON" to switch to the other view.
Tap the red color area and the console will print "Red Tapped".
Now try to tap a blank space below the red area (where the blue area was previously located). The console will print "BLUE tapped" - this is the problem, it seems that the blue view is still active there.
I tested this behavior on: XCode 14.1, iPhone 13 Pro 16.1 iOS Simulator, and on a real iPhone with iOS 16. The result was always the same.
import SwiftUI
struct ContentView: View {
var body: some View {
NavView()
}
}
struct NavView: View {
#State private var colourShowed: Int = 1
var body: some View {
// If the DetailView() was shown directly, (without the NavigationLink and NavigationStack) there would be no such a bug.
// DetailView(colourShowed: $colourShowed)
// The bug is obvious when using the NavigationStack() with the NavigationLink()
NavigationStack {
Form {
NavigationLink(destination: { DetailView(colourShowed: $colourShowed) },
label: { Text("Detail View") })
}
}
}
}
struct DetailView: View {
// It seems like the problem is related to this #Binding property when used in conjunction
// with the NavigationLink in "NavView" View above.
#Binding var colourShowed: Int
var body: some View {
ScrollView {
VStack(spacing: 20){
HStack {
Button("BLUE BUTTON", action: {colourShowed = 1})
Spacer()
Button("RED BUTTON", action: {colourShowed = 2})
}
if colourShowed == 1 {
Color.blue
.frame(height: 500)
// the onTapeGesture() is stillActive here even when the "colourShowed" property is set to '2' so this
// view should therefore be deinitialized.
.onTapGesture {
print("BLUE tapped")
}
// The onAppear() doesn't execute when switching from the Red view to the Blue view.
// It seems like the "Blue" View does not deinitialize itself after being previously shown.
.onAppear(perform: {print("Blue appeared")})
}
else {
Color.red
.frame(height: 100)
.onTapGesture {
print("RED tapped")
}
.onAppear(perform: {print("Red appeared")})
}
}
}
}
}
Is there any solution to prevent this?
This is a common problem encountered by those new to Swift and value semantics, you can fix it by using something called a "capture list" like this:
NavigationLink(destination: { [colourShowed] in
It occurred because DetailView wasn't re-init with the new value of colourShowed when it changed. Nothing in body was using it so SwiftUI's dependency tracking didn't think body had to be recomputed. But since you rely on DetailView being init with a new value you have to add it to the capture list to force body to be recomputed and init a new DetailView.
Here are other questions about the same problem with .sheet and .task.

What is wrong with the iOS Keyboard toolbar in SwiftUI

I am aware I asked a similar question before, but it seems like I have not understood the core concept of how to present a custom toolbar above a keyboard.
I successfully solved my problem on how to present one with a search field (SwiftUI 2.0: Custom keyboard elements).
Now I want to present a keyboard when a textfield within a detail view of a list is clicked, but again the keyboard toolbar does not show. Does anyone have an idea why?
VStack {
Text("Weight:")
TextField("0", text: $weight)
.keyboardType(.decimalPad)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
HStack {
Button(action: {
print("Set bodyweight")
},
label: {Text("Bodyweight")
})
Picker("", selection: $weightType) {
ForEach(weightSuffix.allCases, id: \.self) {
Text($0.rawValue)
}
}
.pickerStyle(.segmented)
}
}
}
}
.border(Color.red)
[EDIT]
After AsperiĀ“s comment I Created a small git: https://gist.github.com/joni8a/bc021ef597cb6efa1ab0ca277d602478
Now it gets even weirder, if I attach the toolbar modifier to the list element I get the intended behavior, showing 1 button above the toolbar
If I append the toolbar modifier to the textfield inside the detail view I get the following result:
I think this is a weird behavior. It seems like I have not understood a core concept of SwiftUI. On the other hand if I can't attach the viewmodifer to the textfield itself, it is hard to uncouple the detail view from the list view ...

Force one NavigationLink to the detail view in SwiftUI

I have a master-(primary)-detail setup in my app, where most of the list items in the top-level NavigationView go to another list of submenu items, with .isDetailLink(false), and then those submenu items then navigate to the detail view. This is all working as intended.
I'm trying to also put in a NavigationLink in the top level list to got to my settings page, which I want to force into the detail view. There isn't a submenu for this link, so I tried to navigate to it directly and force .isDetailView(true). However, this causes the view to open in what would be the primary window above, but hidden at first. The detail window only has a back button, which then makes the intended view pop out from the menu column.
Is there a way to force only the settings menu item to open in the detail view, essentially skipping one navigation level?
Thanks!
Here's the simplified version of what I'm currently trying:
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: SubMenu1()) {Text("MenuItem1")}
.isDetailLink(false)
NavigationLink(destination: SubMenu2()) {Text("MenuItem2")}
.isDetailLink(false)
NavigationLink(destination: SubMenu3()) {Text("MenuItem3")})
.isDetailLink(false)
NavigationLink(destination: SettingsView()) {Text("Settings")}
.isDetailLink(true)
}
}
}
}
struct SubMenu: View {
var body: some View {
List {
ForEach(menuItems, id: \.self) { item in
NavigationLink(destination: DetailView(item)) {
Text(item)
}
}
}
}
}
UPDATE
I've taken some screenshots to illustrate the issue:
The top level navigation view:
With one of the sub-menus selected, and .isDetailView(false). This is working properly:
Settings does not have a sub-menu like the others, so I want it to open directly on the right hand side. Instead, it opens like this, and is only revealed when the back button is pressed:
Hey, I have seen your screenshots. I Think you are using NavigationView 2 times there in Settings. Please remove any one of them, It should work fine!

How do I reset a selection tag when pushing a NavigationLink programmatically?

I want to push a view programmatically instead of relying on the interface that NavigationLink provides (e.g. I want to use a button with no chevron). The correct way is to use NavigationLink with tag and selection, and an EmptyView.
When I attempt to use the following code to push a view, it works to push the view the first time:
struct PushExample: View {
#State private var pushedView: Int? = nil
var body: some View {
NavigationView {
VStack {
Form {
Button(action: { self.pushedView = 1 }) { Text("Push view") }
}
NavigationLink(destination: Text("Detail view"), tag: 1, selection: $pushedView) { EmptyView() }
}
}
}
}
However, if I tap the back button on the view, and try hitting the button again, it no longer pushes the view. This is because the value pushedView is being set to 1 again, but it is already at 1. Nothing is resetting it back to nil upon pop of the Detail view.
How do I get subsequent taps of the button to push the view again?
TL;DR: There is no need to reset the state variable, as SwiftUI will automatically handle it for you. If it's not, it may be a bug with the simulator.
This was a simulator bug on Xcode 11.3!
The way to check if it's a simulator bug is to run an even simpler example:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink("Push", destination: Text("Detail"))
}
}
}
On the Xcode 11.3 iPhone 11 Pro Max, this would only work the first time you tap the link.
This worked fine on both a 13.2 and a 13.3 device.
Therefore, when running into odd SwiftUI issues, test on device rather than the simulator.
Update: Restarting the computer didn't fix it either. Thus while SwiftUI is still new, may be better off to use a real device for testing rather than the simulator.

What's the equivalent of the UISplitViewController in SwiftUI

I need to implement an UI which close to the default Mail app in iPad and iPhone.
The App has two sections, typically, the master view will be displayed on the left side and detail view will be displayed in the right side in iPad.
In the phone, the master view will be displayed on whole screen, the detail view can be pushed as second screen.
How to implement it in the new SwiftUI
There is not really a SplitView in SwiftUI, but what you describe is automatically accomplished when you use the following code:
import SwiftUI
struct MyView: View {
var body: some View {
NavigationView {
// The first View inside the NavigationView is the Master
List(1 ... 5, id: \.self) { x in
NavigationLink(destination: SecondView(detail: x)) {
Text("Master\nYou can display a list for example")
}
}
.navigationBarTitle("Master")
// The second View is the Detail
Text("Detail placeholder\nHere you could display something when nothing from the list is selected")
.navigationBarTitle("Detail")
}
}
}
struct SecondView: View {
var detail: Int
var body: some View {
Text("Now you are seeing the real detail View! This is detail \(detail)")
}
}
This is also why the .navigationBarTitle() modifier should be applied on the view inside the NavigationView, instead of on the NavigationView itself.
Bonus: if you don't like the splitView navigationViewStyle, you can apply the .navigationViewStyle(StackNavigationViewStyle()) modifier on the NavigationView.
Edit: I discovered that the NavigationLink has an isDetailLink(Bool) modifier. The default value appears to be true, because by default the "destination" is presented in the detail view (on the right). But when you set this modifier to false, the destination is presented as a master (on the left).

Resources