Picker Inside Menu Doesn't Update First Time - swiftui

I've put a picker inside a menu so I can apply custom text and images in the future. It seems that, on a new app launch, the menu only gets updated on a second tap though. On the first tap the picker check-mark gets an update, however neither the ViewModel nor the menu change until a second tap. Any ideas on how to have all the components work every time?
class ViewModel: ObservableObject {
#Published var soundSelection: String = "Off"
#Published var soundSelectionPickerValue: Int = 0
func updateSoundSelection(to: Int) {
var updatedSoundSelection: String
switch to {
case 0:
updatedSoundSelection = "off"
case 1:
updatedSoundSelection = "chime"
case 2:
updatedSoundSelection = "alert"
case 3:
updatedSoundSelection = "peaceful"
default:
return
}
soundSelection = updatedSoundSelection
}
}
struct ContentView: View {
#StateObject var viewModel: ViewModel
var body: some View {
VStack {
Menu {
Picker("", selection: $viewModel.soundSelectionPickerValue) {
Text("off").tag(0)
Text("chime").tag(1)
Text("alert").tag(2)
Text("peaceful").tag(3)
}
.onChange(of: viewModel.soundSelectionPickerValue) { tag in
viewModel.updateSoundSelection(to: tag) }
} label: {
VStack {
Text(viewModel.soundSelection)
}
}
}
}
}

Related

How to get NavigationStack to retain NavigationPath state when switching views?

I modified an app to use the new NavigationSplitView and NavigationStack, but I can't figure out how to have the NavigationPath retain the state when it's not the active view.
Below is some sample code. I run it on an iPad in landscape mode, or on a Mac (Designed for iPad). It starts with View1 selected and displays View1 in the details. I then tap on SubView and it pushes to SubView. If I then tap on View2, View2 is displayed in the details. If I tap on View1 again, View1 is back at it's root, and is no longer pushed to the SubView. How can I fix this so that when I go back to View1 it is still pushed to SubView?
import SwiftUI
struct ViewType: Identifiable, Hashable {
let id: String
}
private var viewTypes = [
ViewType(id: "View1"),
ViewType(id: "View2"),
]
struct ContentView: View {
#StateObject private var navigationModel = NavigationModel()
#State var selection: Set<String> = [viewTypes[0].id]
var body: some View {
NavigationSplitView {
List(viewTypes, selection: $selection) { viewType in
Text("\(viewType.id)")
}
} detail: {
switch selection.first ?? "Unknown" {
case "View1":
View1()
case "View2":
View2()
default:
Text("Unknown")
}
}
.navigationTitle(selection.first ?? "Unknown")
.environmentObject(navigationModel)
}
}
struct View1: View {
#EnvironmentObject var navigationModel: NavigationModel
var body: some View {
NavigationStack(path: $navigationModel.path) {
Text("View1")
NavigationLink("SubView", value: "SubView")
.navigationDestination(for: String.self) { name in
Text(name)
.onAppear {
print((navigationModel.path.count))
}
}
}
}
}
struct View2: View {
var body: some View {
NavigationStack() {
Text("View2")
}
}
}
final class NavigationModel: ObservableObject {
#Published var path = NavigationPath() {
didSet {
print("path.count: \(path.count)")
}
}
}

How to run `task` or `onAppear` on NavigationSplitView detail for every selection change?

I'm trying to use NavigationSplitView with a DetailView that has a task or onAppear in it, but it seems it only runs once.
enum MenuItem: String, Hashable, Identifiable, CaseIterable {
case menu1
case menu2
case menu3
case menu4
case menu5
var id: String { rawValue }
}
struct ContentView: View {
#State var selection: MenuItem?
var body: some View {
NavigationSplitView {
List(MenuItem.allCases, selection: $selection) { item in
NavigationLink(value: item) {
Text(item.rawValue)
}
}
} detail: {
if let selection {
DetailView(menuItem: selection)
} else {
Text("Default")
}
}
}
}
struct DetailView: View {
let menuItem: MenuItem
#State var name = "Name"
var body: some View {
VStack {
Text(menuItem.id)
Text(name)
}
.task {
// This should be an async setup code
// but for the sake of simplicity
// I just made it like this
name = menuItem.id
}
}
}
Initial application load
Initial menu selection
2nd to 5th menu selection
I know I can use onChange(of: selection) as a workaround, and then have my setup code there. But is there any other way to make task or onAppear work inside my DetailView?
Basing the View's id on the selection is not a good idea. It will force an entire body rebuild every time the selection changes, which will result in sluggish performance as the view hierarchy grows.
Instead, you can use the alternate form of task(id:priority:_:) to initiate the task when the selection value changes, like so:
struct ContentView: View {
#State var selection: MenuItem?
var body: some View {
NavigationSplitView {
…
} detail: {
…
}
.task(id: selection, priority: .userInitiated) { sel in
print("selection changed:", sel)
}
}
}
It is SwiftUI optimization, it recreates only dependent parts.
A possible solution is to make entire body dependent on menu item, so it will be recreated completely and calls task again, like
struct DetailView: View {
let menuItem: MenuItem
#State var name = "Name"
var body: some View {
VStack {
Text(menuItem.id)
Text(name)
}
.task {
// This should be an async setup code
// but for the sake of simplicity
// I just made it like this
name = menuItem.id
}
.id(menuItem.id) // << here !!
}
}

SwiftUI 4: NavigationSplitView behaves strangely?

In SwiftUI 4, there is now a NavigationSplitView. I played around with it and detected some strange behaviour.
Consider the following code: When the content function returns the plain Text, then there is the expected behaviour - tapping a menu item changes the detail view to the related text.
However, when commenting out the first four cases, and commenting in the next four, then a tap on "Edit Profile" does not change the detail view display. (Using #ViewBuilder does not change this behaviour.)
Any ideas out there about the reasons for that? From my point of view, this may just be a simple bug, but perhaps there are things to be considered that are not documented yet?!
struct MainScreen: View {
#State private var menuItems = MenuItem.menuItems
#State private var menuItemSelection: MenuItem?
var body: some View {
NavigationSplitView {
List(menuItems, selection: $menuItemSelection) { course in
Text(course.name).tag(course)
}
.navigationTitle("HappyFreelancer")
} detail: {
content(menuItemSelection)
}
.navigationSplitViewStyle(.balanced)
}
func content(_ selection: MenuItem?) -> some View {
switch selection {
case .editProfile:
return Text("Edit Profile")
case .evaluateProfile:
return Text("Evaluate Profile")
case .setupApplication:
return Text("Setup Application")
case .none:
return Text("none")
// case .editProfile:
// return AnyView(EditProfileScreen())
//
// case .evaluateProfile:
// return AnyView(Text("Evaluate Profile"))
//
// case .setupApplication:
// return AnyView(Text("Setup Application"))
//
// case .none:
// return AnyView(Text("none"))
}
}
}
struct MainScreen_Previews: PreviewProvider {
static var previews: some View {
MainScreen()
}
}
enum MenuItem: Int, Identifiable, Hashable, CaseIterable {
var id: Int { rawValue }
case editProfile
case evaluateProfile
case setupApplication
var name: String {
switch self {
case .editProfile: return "Edit Profile"
case .evaluateProfile: return "Evaluate Profile"
case .setupApplication: return "Setup Application"
}
}
}
extension MenuItem {
static var menuItems: [MenuItem] {
MenuItem.allCases
}
}
struct EditProfileScreen: View {
var body: some View {
Text("Edit Profile")
}
}
After playing around a bit in order to force SwiftUI to redraw the details view, I succeeded in this workaround:
Wrap the NavigationSplitView into a GeometryReader.
Apply an .id(id) modifier to the GeometryReader (e.g., as #State private var id: Int = 0)
In this case, any menu item selection leads to a redraw as expected.
However, Apple should fix the bug, which it is obviously.
I've found that wrapping the Sidebar list within its own view will fix this issue:
struct MainView: View {
#State var selection: SidebarItem? = .none
var body: some View {
NavigationSplitView {
Sidebar(selection: $selection)
} content: {
content(for: selection)
} detail: {
Text("Detail")
}
}
#ViewBuilder
func content(for item: SidebarItem?) -> some View {
switch item {
case .none:
Text("Select an Item in the Sidebar")
case .a:
Text("A")
case .b:
Text("B")
}
}
}

Using switch/enums in SwiftUI Views

As of Xcode 11.4, SwiftUI doesn't allow switch statements in Function builder blocks like VStack {}, failing with a generic error like Generic parameter 'Content' could not be inferred. How can the switch statement be used in SwiftUI to create different Views depending on an enum value?
switch in SwiftUI view builders is supported since Xcode 12:
enum Status {
case loggedIn, loggedOut, expired
}
struct SwiftUISwitchView: View {
#State var userStatus: Status = .loggedIn
var body: some View {
VStack {
switch self.userStatus {
case .loggedIn:
Text("Welcome!")
case .loggedOut:
Image(systemName: "person.fill")
case .expired:
Text("Session expired")
}
}
}
}
You can use enum with #ViewBuilder as follow ...
Declear enum
enum Destination: CaseIterable, Identifiable {
case restaurants
case profile
var id: String { return title }
var title: String {
switch self {
case .restaurants: return "Restaurants"
case .profile: return "Profile"
}
}
}
Now in the View file
struct ContentView: View {
#State private var selectedDestination: Destination? = .restaurants
var body: some View {
NavigationView {
view(for: selectedDestination)
}
}
#ViewBuilder
func view(for destination: Destination?) -> some View {
switch destination {
case .some(.restaurants):
CategoriesView()
case .some(.profile):
ProfileView()
default:
EmptyView()
}
}
}
If you want to use the same case with the NavigationLink ... You can use it as follow
struct ContentView: View {
#State private var selectedDestination: Destination? = .restaurants
var body: some View {
NavigationView {
List(Destination.allCases,
selection: $selectedDestination) { item in
NavigationLink(destination: view(for: selectedDestination),
tag: item,
selection: $selectedDestination) {
Text(item.title).tag(item)
}
}
}
}
#ViewBuilder
func view(for destination: Destination?) -> some View {
switch destination {
case .some(.restaurants):
CategoriesView()
case .some(.profile):
ProfileView()
default:
EmptyView()
}
}
}

How to programmatically trigger NavigationLink?

I have a NavigationView with a bunch of NavigationLinks in it. When the user hits the "Add" button, I want to create a new item in the view and automatically send them to it. Can I do that?
SwiftUI triggers View changes (the body) when its source of truth changes, so you just need to change the Binding<Bool> value to trigger a view update. Store the bindings for each link, and change its value when you click a button.
Let's say you have three different views you want to push. Then you would have three different bindings to store. You can store these bindings as three separate #State properties, or using a #StateObject view model as follows:
class RootViewModel: ObservableObject {
#Published var isLinkActive:[Int: Bool] = [:]
}
struct RootView: View {
#StateObject var viewModel = RootViewModel()
...
func binding(index: Int) -> Binding<Bool> {
return .init(get: { () -> Bool in
return self.viewModel.isLinkActive[index, default: false]
}) { (value) in
self.viewModel.isLinkActive[index] = value
}
}
...
func destination(index: Int) -> some View {
switch index {
case 1:
return ContentViewOne()
case 2:
return ContentViewTwo()
case 3:
return ContentViewThree()
default:
return EmptyView()
}
}
var body: some View {
return
NavigationView {
VStack {
ForEach(1..<4) { index in
NavigationLink(destination: self.destination(index: index), isActive: self.binding(index: index)) {
Text("Link to Content View \(index)")
}
}
Button("Add") {
self.contentViewModel.isLinkActive[2] = true // Change this index depending on which view you want to push.
}
}
}
}
}
So the button here simulates the second NavigationLink tap. I used StateObject to stop redrawing the whole RootView in the middle of a push.