SwiftUI: Update parent view on change of UserDefaults in child view - ios

I am making a game with a minimal color scheme. I store the colors that I use as computed static var's in an enum so that I can call them in any view. I am trying to make a secondary color scheme (colorblind). My code looks like this:
enum GameColors {
static var exampleColor: Color {
!UserDefaults.standard.bool(forKey: GamePrefs.colorBlindMode) ? Color.green : Color(red: 0 / 255, green: 213 / 255, blue: 255 / 255)
}
}
enum GamePrefs {
static let colorBlindMode: String = "colorBlindMode"
}
My settings menu is called in my main menu view like so:
struct MainMenuView: View {
#State var settingsClicked: Bool = false
var body: some View {
VStack {
Button {
settingsClicked.toggle()
} label: {
Text("Settings")
.foregroundColor(GameColors.exampleColor)
}
if settingsClicked {
SettingsView()
}
}
}
}
struct SettingsView: View {
#AppStorage(GamePrefs.colorBlindMode) var colorBlindMode = false
var body: some View {
Toggle(isOn: $colorBlindMode) {
Text("Color Blind Mode: \(colorBlindMode ? "On" : "Off")")
.foregroundColor(GameColors.exampleColor)
}
}
}
When I toggle colorBlindMode, only SettingsView's colors are updated, the color of the main menu does not change unless I interact with it. How do I get both to update?
I've tried binding the #AppStorage property wrapper to no success.

You could try this approach, using #AppStorage and .onReceive in MainMenuView,
to ensure GameColors.exampleColor is updated/received in the MainMenuView while
clicking in the SettingsView.
struct MainMenuView: View {
#AppStorage(GamePrefs.colorBlindMode) var colorBlindMode = false // <-- here
#State var settingsClicked: Bool = false
var body: some View {
VStack {
Button {
settingsClicked.toggle()
} label: {
Text("Settings").foregroundColor(GameColors.exampleColor)
}
if settingsClicked {
SettingsView()
}
}
.onReceive(Just(colorBlindMode)) { _ in // <-- here
}
}
}
struct SettingsView: View {
#AppStorage(GamePrefs.colorBlindMode) var colorBlindMode = false
var body: some View {
Toggle(isOn: $colorBlindMode) {
Text("Color Blind Mode: \(colorBlindMode ? "On" : "Off")")
.foregroundColor(GameColors.exampleColor) // <-- here for testing
}
.toggleStyle(SwitchToggleStyle(tint: GameColors.exampleColor))
}
}

The reason your color does not change is because of your MainMenuView is not refreshing when you press the toggle. Only your SettingsView is refreshed. So you would need something to notify your MainMenuView that something has changed.
To do so you can add another AppStorage to your MainMenuView and force your view to refresh by creating a dependency on this property.
struct MainMenuView: View {
#State var settingsClicked: Bool = false
// add the same AppStorage as in SettingsView
#AppStorage(GamePrefs.colorBlindMode) var colorBlindMode = false
var body: some View {
VStack {
Button {
settingsClicked.toggle()
} label: {
let _ = print("text")
Text("Settings")
//this will force the view to update
.foregroundColor(colorBlindMode ? GameColors.exampleColor : GameColors.exampleColor)
}
if settingsClicked {
SettingsView()
}
}
}
}

After reading a bit more about what causes SwiftUI to reload a view, and a whole lot of testing different methods, I believe that the simplest way to solve this problem is to introduce a new #State variable which you must use somewhere in the parent view:
struct MainMenuView: View {
#State var settingsClicked: Bool = false
#State var reloadView: Bool = false // <-- here
var body: some View {
VStack {
Button {
settingsClicked.toggle()
} label: {
Text("Settings")
.foregroundColor(GameColors.exampleColor)
}
if settingsClicked {
SettingsView(reloadParentView: $reloadView)
}
if reloadView {} // <-- here
}
}
}
struct SettingsView: View {
#Binding var reloadParentView: Bool // <-- here
#AppStorage(GamePrefs.colorBlindMode) var colorBlindMode = false
var body: some View {
Toggle(isOn: $colorBlindMode) {
Text("Color Blind Mode: \(colorBlindMode ? "On" : "Off")")
.foregroundColor(GameColors.exampleColor)
}
.onChange(of: colorBlindMode) { _ in
reloadParentView.toggle() // <-- here
}
}
}
Passing this state from the parent view to the child view allows the child to force reloads on the parent at will.

Related

How to notify view that the variable state has been updated from a extracted subview in SwiftUI

I have a view that contain users UsersContentView in this view there is a button which is extracted as a subview: RequestSearchButton(), and under the button there is a Text view which display the result if the user did request to search or no, and it is also extracted as a subview ResultSearchQuery().
struct UsersContentView: View {
var body: some View {
ZStack {
VStack {
RequestSearchButton()
ResultSearchQuery(didUserRequestSearchOrNo: .constant("YES"))
}
}
}
}
struct RequestSearchButton: View {
var body: some View {
Button(action: {
}) {
Text("User requested search")
}
}
}
struct ResultSearchQuery: View {
#Binding var didUserRequestSearchOrNo: String
var body: some View {
Text("Did user request search: \(didUserRequestSearchOrNo)")
}
}
How can I update the #Binding var didUserRequestSearchOrNo: String inside the ResultSearchQuery() When the button RequestSearchButton() is clicked. Its so confusing!
You need to track the State of a variable (which is indicating if a search is active or not) in your parent view, or your ViewModel if you want to extract the Variables. Then you can refer to this variable in enclosed child views like the Search Button or Search Query Results.
In this case a would prefer a Boolean value for the tracking because it's easy to handle and clear in meaning.
struct UsersContentView: View {
#State var requestedSearch = false
var body: some View {
ZStack {
VStack {
RequestSearchButton(requestedSearch: $requestedSearch)
ResultSearchQuery(requestedSearch: $requestedSearch)
}
}
}
}
struct RequestSearchButton: View {
#Binding var requestedSearch: Bool
var body: some View {
Button(action: {
requestedSearch.toggle()
}) {
Text("User requested search")
}
}
}
struct ResultSearchQuery: View {
#Binding var requestedSearch: Bool
var body: some View {
Text("Did user request search: \(requestedSearch.description)")
}
}
Actually I couldn't understand why you used two struct which are connected to eachother, you can do it in one struct and Control with a state var
struct ContentView: View {
var body: some View {
VStack {
RequestSearchButton()
}
}
}
struct RequestSearchButton: View {
#State private var clicked : Bool = false
var body: some View {
Button(action: {
clicked = true
}) {
Text("User requested search")
}
Text("Did user request search: \(clicked == true ? "YES" : "NO")")
}
}
if this is not what you are looking for, could you make a detailed explain.

How to update ParentView after updating SubView #ObservedObject SwiftUI

This is a simple example for my case.
I have a #ObservedObject viewModel (Object1), pass a property (Object2) to another view (View2) . Change value in View 2, and when i go back to View 1, i wish the value is updated too. What is the best solution?
In this Example, when i press the blue number, i wish black number is also updated.
Actually I don't know why do the black number is updated after pressing button "Show".
I would really appreciate if you could help me. Thanks.
import SwiftUI
import Combine
struct ContentView: View {
#ObservedObject var object1: Object1 = Object1(ob: Object2(n: 0))
#State var isShow = false
var body: some View {
NavigationView {
VStack {
Text("\(object1.object2.n)")
//NavigationLink(destination: View2(object2: object1.object2)) {
// Text("Go to view 2")
//}
View2(object2: object1.object2)
Button {
isShow = true
} label: {
Text("Show")
}.alert(isPresented: $isShow, content: {
Alert(title: Text("\(object1.object2.n)"))
})
}
}
}
}
struct View2: View {
#ObservedObject var object2: Object2
var body: some View {
Button {
object2.n += 1
} label: {
Text("\(object2.n)")
}
}
}
class Object1: ObservableObject {
#Published var object2: Object2
init(ob: Object2) {
self.object2 = ob
}
}
class Object2: ObservableObject {
#Published var n: Int = 0
init(n: Int) {
self.n = n
}
}
Here is possible solution:
var body: some View {
NavigationView {
VStack {
Text("\(object1.object2.n)")
.onChange(of: object1.object2.n) { _ in
object1.objectWillChange.send()
}
// .. other code
Alternate is to move every object2 dependent part into separated subview observed object2 explicitly.

How to make NavigationLink work if it is not visible, SwiftUI?

When using NavigationLink on the bottom of a view after ForEach it won't work if it is not visible.
I have a list of Buttons. If a button is pressed, it sets a Bool to true. This bool value now shows a NavigationLink which immediately activates because the passed binding is set to true.
However, the link won't work if the array is too long because it will be out of sight once one of the first buttons is pressed.
This is my Code:
import SwiftUI
struct TestLinkView: View {
#State private var linkIsActive = false
var body: some View {
NavigationView {
VStack {
Button(action: {
linkIsActive = true
}) {
Text("Press")
}
NavigationLink(destination: ListView(linkIsActive: $linkIsActive), isActive: $linkIsActive) {
Text("Navigation Link")
}
}
}
}
}
struct ListView: View {
var nameArray = ["Name1","Name2","Name3","Name4","Name5","Name6","Name7","Name8","Name9","Name10","Name11","Name12","Name13","Name14","Name15","Name16","Name17","Name18","Name19","Name20" ]
#State private var showLink: Bool = false
#State private var selectedName: String = ""
#Binding var linkIsActive: Bool
var body: some View {
Form {
ForEach(nameArray, id: \.self) { name in
Button(action: {
selectedName = name
showLink = true
}) {
Text(name)
}
}
if showLink {
NavigationLink(destination: NameView(selectedName: selectedName), isActive: $linkIsActive) {
EmptyView()
}
}
}
.navigationBarTitle("ListView")
}
}
struct NameView: View {
var selectedName: String
var body: some View {
Text(selectedName)
.navigationBarTitle("NameView")
}
}
What would work is to pass the NavigationLink with the if-condition inside the button label. However if I do that, the animation won't work anymore.
You don't need it in Form, which is like a List don't create views far outside of visible area. In your case the solution is to just move link into background of Form (because it does not depend on form internals).
The following tested as worked with Xcode 12 / iOS 14.
Form {
ForEach(nameArray, id: \.self) { name in
Button(action: {
selectedName = name
showLink = true
}) {
Text(name)
}
}
}
.background(Group{
if showLink {
NavigationLink(destination: NameView(selectedName: selectedName), isActive: $linkIsActive) {
EmptyView()
}
}
})

Value of Selected Option From a SwiftUI Picker does not Update the View

I have the following in a SwiftUI app. Basically I have some settings (Settings class) that I would like to use throughout the app. I have a Settings view that shows a picker to select the value of one of the settings. And other views of the app would only use the current set value of the settings. The following setup works in the sense that in ContentView I see the correct value of firstLevel setting. But the problem is that in SettingsView, I think since selectedFirstLevel is not a #State, its correct value is not shown on the picker I navigate to select either even or odd (oddly, the first time it's correct). This selection is carried correctly to ContentView, but it's not shown correctly on SettingsView. How can I fix this issue?
Settings.swift
import Foundation
class Settings: ObservableObject {
static let shared: Settings = Settings()
#Published var firstLevel: FirstLevel = .even
}
enum FirstLevel: String, CaseIterable, Identifiable {
case even
case odd
var id: String { self.rawValue }
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#State private var showSettings: Bool = false
#ObservedObject var settings = Settings.shared
var body: some View {
VStack {
SettingsButton(showSettings: $showSettings, settings: settings)
Text(settings.firstLevel.id)
.padding()
}
}
}
struct SettingsButton: View {
#Binding var showSettings: Bool
var settings: Settings
var firstLevel: Binding<FirstLevel> {
return Binding<FirstLevel>(
get: {
return self.settings.firstLevel
}) { newFirstLevel in
self.settings.firstLevel = newFirstLevel
}
}
var body: some View {
Button(action: { self.showSettings = true }) {
Image(systemName: "gear").imageScale(.large)
}
.sheet(isPresented: $showSettings) {
SettingsView(selectedFirstLevel: self.firstLevel)
}
}
}
SettingsView.swift
import SwiftUI
struct SettingsView: View {
#Binding var selectedFirstLevel: FirstLevel
var body: some View {
NavigationView {
Form {
Picker("First Level", selection: $selectedFirstLevel) {
ForEach(FirstLevel.allCases) { level in
Text(level.rawValue).tag(level)
}
}
}
.navigationBarTitle("Settings", displayMode: .inline)
}
}
}
It looks overcomplicated, moreover Binding is unreliable as communication between different view hierarchies (which is sheet in your case).
Here is simplified and worked variant. Tested with Xcode 12 / iOS 14.
struct ContentView: View {
#ObservedObject var settings = FLevelSettings.shared
var body: some View {
VStack {
SettingsButton(settings: settings)
Text(settings.firstLevel.id)
.padding()
}
}
}
struct SettingsButton: View {
#State private var showSettings: Bool = false
var settings: FLevelSettings
var body: some View {
Button(action: { self.showSettings = true }) {
Image(systemName: "gear").imageScale(.large)
}
.sheet(isPresented: $showSettings) {
FLevelSettingsView(settings: self.settings)
}
}
}
struct FLevelSettingsView: View {
#ObservedObject var settings: FLevelSettings
var body: some View {
NavigationView {
Form {
Picker("First Level", selection: $settings.firstLevel) {
ForEach(FirstLevel.allCases) { level in
Text(level.rawValue).tag(level)
}
}
}
.navigationBarTitle("Settings", displayMode: .inline)
}
}
}
Note: it can be even more simplified, if you want, due to presence of FLevelSettings.shared, so you can use it inside FLevelSettingsView directly. Just in case.

SwiftUI: Using #Binding to dismiss a modal view not working

I'm passing a #State var down a few views, using #Binding on the child views and when I ultimately set the variable to back to false, sometimes my view doesn't dismiss.
It seems like I can run articleDisplayed.toggle() but if I run an additional function above or below, it won't work.
Any idea what's going on here?
Here's my code:
struct HomeView: View {
#EnvironmentObject var state: AppState
#State var articleDisplayed = false
// MARK: - Body
var body: some View {
NavigationView {
ZStack {
List {
ForEach(state.cards, id: \.id) { card in
Button(action: {
self.articleDisplayed = true // I set it to true here
self.state.activeCard = card
}) {
HomeCell(
card: card,
publicationColor: self.state.publication.brandColor
)
}.sheet(isPresented: self.$articleDisplayed) {
SafariQuickTopicView(articleDisplayed: self.$articleDisplayed)
.environmentObject(self.state)
.environment(\.colorScheme, .light)
}
}
}
}
}
}
}
Then in my SafariQuickTopicView:
struct SafariQuickTopicView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#EnvironmentObject var state: AppState
#Binding var articleDisplayed: Bool
var body: some View {
NavigationView {
ZStack(alignment: .bottom) {
// doesn't matter what's in here
}
.navigationBarItems(trailing: passButton)
}
}
private var passButton: some View {
Button(action: self.state.pass {
DispatchQueue.main.async {
// self.state.removeActiveCardFromState()
self.articleDisplayed.toggle() // this will work but adding a second function in here prevents it from working, above or below the toggle.
}
}
}) {
Text("Pass")
}
}
Finally, in my AppState:
func pass(completion: () -> Void) { // need completion?
guard let activeCard = activeCard else { return }
if let index = cards.firstIndex(where: { $0.id == activeCard.id }) {
activeCard.add(comment: "pass")
rejectCurrentCard() // Does an async operation with an external API but we don't care about the result
addRemovedActiveCardToUserDefaults()
completion()
}
}
Move .sheet out of List, it must be one per view hierarchy, so like
List {
ForEach(state.cards, id: \.id) { card in
Button(action: {
self.articleDisplayed = true // I set it to true here
self.state.activeCard = card
}) {
HomeCell(
card: card,
publicationColor: self.state.publication.brandColor
)
}
}
}
.sheet(isPresented: self.$articleDisplayed) {
SafariQuickTopicView(articleDisplayed: self.$articleDisplayed)
.environmentObject(self.state)
.environment(\.colorScheme, .light)
}

Resources