How to set a custom environment key in SwiftUI? - swiftui

Creating a custom environment key works, but if I wish to set the value in a view, XCODE doesn't allow it. But the predefined environment values can be set. What am I doing wrong?
struct ResetDefault: EnvironmentKey {
static var defaultValue: Bool = false
}
extension EnvironmentValues {
var resetDefault: Bool {
get { self[ResetDefault.self] }
set { self[ResetDefault.self] = newValue }
}
}
struct ResetView: View {
#Environment(\.resetDefault) var reset
var body: some View {
Text("Reset").onAppear() {
reset = true. // Cannot assign to property: 'reset' is a get-only property
}
}
}

The Environment is used to pass values in parent > child direction, so value is set for usage. If you want to change internal of environment value then you need to wrap it somehow, possible variants are binding or reference type holder.
Here is an example of usage based on binding (similar to how .editMode and .presentationMode work)
struct TestResetEnv: View {
#State private var isActive = false
#State private var reset = false
var body: some View {
VStack {
Text("Current: \(reset ? "true" : "false")")
Button("Go") { self.isActive.toggle() }
if isActive {
ResetView()
}
}.environment(\.resetDefault, $reset) // set for children as env!!
}
}
struct ResetDefault: EnvironmentKey {
static var defaultValue: Binding<Bool> = .constant(false)
}
extension EnvironmentValues {
var resetDefault: Binding<Bool> {
get { self[ResetDefault.self] }
set { self[ResetDefault.self] = newValue }
}
}
struct ResetView: View {
#Environment(\.resetDefault) var reset
var body: some View {
Text("Reset").onAppear() {
self.reset.wrappedValue.toggle() // << change wrapped !!
}
}
}

Related

Binding Constant Alternative, but Mutable

I have a view like this:
struct View1: View {
#Binding var myVariable: Bool
init() {
_myVariable = Binding.constant(true) // It works but myVariable is immutable, so I can't edit myVariable
}
init(myVariable: Binding<Bool>) {
_myVariable = myVariable
}
var body: some View {
Button("Change") {
myVariable.toggle()
}
}
}
struct View2: View {
var body: some View {
View1()
}
}
struct View3: View {
#State var myVariable = false
var body: some View {
View1(myVariable: $myVariable)
}
}
And I want to make this: If there is a parameter provided, set this to myVariable like second init in View1. Else, set the first value of myVariable like in first init.
I tried to use Binding.constant(value) but it is immutable. And I can't edit the variable. So, I need a mutable Binding initializer like Binding.constant(value). But I can't find it.
How can I solve this problem?
To avoid over-complicating View1, you can create an intermediate view with that name, and then have a private 'internal' view which has the actual implementation.
Code:
private struct View1Internal: View {
#Binding var myVariable: Bool
var body: some View {
Button("Change") {
myVariable.toggle()
}
}
}
struct View1: View {
private enum Kind {
case state
case binding(Binding<Bool>)
}
#State private var state = true
private let kind: Kind
init() {
kind = .state
}
init(myVariable: Binding<Bool>) {
kind = .binding(myVariable)
}
var body: some View {
switch kind {
case .state: View1Internal(myVariable: $state)
case .binding(let binding): View1Internal(myVariable: binding)
}
}
}

Get a binding from an environment value in SwiftUI

I have an environment value as follows:
#Environment(\.isPresented) var isPresented: Bool
This environment value is defined as such:
private struct IsPresented: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var isPresented: Bool {
get { self[IsPresented.self] }
set { self[IsPresented.self] = newValue }
}
}
extension View {
func isPresented(_ isPresented: Bool) -> some View {
environment(\.isPresented, isPresented)
}
}
I want to read this environment value in one of my views to decide whether or not to show a view as a full screen. However, this line of code doesn't work:
.fullScreenCover(isPresented: self.$isPresented) {
// It says there's no such member in my view.
Thus, my question is, how can I convert my environment value to a binding, since .fullScreenCover expects a binding?
If you want it to be a writable value, I think your Environment value should be Binding<Bool> instead of just Bool. This is how the system's presentationMode works, for example.
private struct IsPresented: EnvironmentKey {
static let defaultValue: Binding<Bool> = .constant(false)
}
extension EnvironmentValues {
var isPresented: Binding<Bool> {
get { self[IsPresented.self] }
set { self[IsPresented.self] = newValue }
}
}
extension View {
func isPresented(_ isPresented: Binding<Bool>) -> some View {
environment(\.isPresented, isPresented)
}
}
struct ContentView : View {
#State private var isPresented = false
var body: some View {
ChildView().environment(\.isPresented, $isPresented)
}
}
struct ChildView : View {
#Environment(\.isPresented) var isPresented: Binding<Bool>
var body: some View {
Button("Test") {
isPresented.wrappedValue = true
}
.fullScreenCover(isPresented: isPresented) {
Text("Sheet")
}
}
}
If you really want #Environment(\.isPresented) var isPresented: Bool as Bool, then you can create Binding in place, like
.fullScreenCover(isPresented: Binding(
get: { isPresented },
set: { isPresented = $0 }
)) {

Global Updates With UserDefaults In SwiftUI

I've been using #AppStorage and UserDefaults for updates in SwiftUI. If I make a change to the vie that has the #AppStorage wrapper all works well. I'm confused with how to make this work globally.
I'm using a struct that has computed properties and formatters associated. The idea is to check user defaults and convert items to lbs or kg. The issue is that the views using the computed properties do not update when UserDefaults is updated. Is there a way to create a global change that would update weightFormatted in SecondaryView below?
// Weight Struct
struct Weight {
var weight: Double
var weightFormatted: String {
return weightDecimalLbsOrKgFormatted2(weight)
}
// Formatting Method
func weightDecimalLbsOrKgFormatted2(_ lbs: Double) -> String {
if (!UserDefaults.standard.bool(forKey: "weightInKilograms")) {
let weightString = decimalFormatterDecimal2(lbs)
return weightString + "lbs"
} else {
let kg = toKg(lbs)
let weightString = decimalFormatterDecimal2(kg)
return weightString + "kg"
}
}
// Where weightInKilograms Is Set
struct AccountView: View {
#AppStorage("weightInKilograms") var weightInKilograms = false
let weight = Weight(weight: 9.0))
var body: some View {
VStack {
Text(weight.weightFormatted)
Toggle(isOn: $weightInKilograms) {
Text("Kilograms")
}
}
}
}
// Secondary View Not Updating
struct SecondaryView: View {
let weight = Weight(weight: 9.0))
var body: some View {
Text(weight.weightFormatted)
}
}
Your problem is that weight isn't wrapped by any state.
In your AccountView, give weight a #State wrapper:
struct AccountView: View {
#AppStorage("weightInKilograms") var weightInKilograms = false
#State var weight = Weight(weight: 9.0))
var body: some View {
//...
}
}
In SecondaryView, ensure that weight is wrapped with #Binding:
struct SecondaryView: View {
#Binding var weight: Weight
var body: some View {
// ...
}
}
Then, pass weight as a Binding<Weight> variable to SecondaryView within your first View:
SecondaryView(weight: $weight)
Is there a way to create a global change that would update weightFormatted in SecondaryView below?
If you're looking to make a global change, you should consider setting up a global EnvironmentObject:
class MyGlobalClass: ObservableObject {
// Published variables will update view states when changed.
#Published var weightInKilograms: Bool
{ get {
// Get UserDefaults here
} set {
// Set UserDefaults here
}}
#Published var weight: Weight
}
If you pass an instance of MyGlobalClass as an EnvironmentObject to your main view, then to your secondary view, any changes made to properties in the global instance will update the views' state via the #Published wrapper:
let global = MyGlobalClass()
/* ... */
// In your app's lifecycle, or where AccountView is instantiated
AccountView().environmentObject(global)
struct AccountView: View {
#EnvironmentObject var global: MyGlobalClass
var body: some View {
// ...
Text(global.weight.weightFormatted)
// ...
SecondaryView().environmentObject(global)
}
}

SwiftUI: #Environment not receiving provided value down the view hierarchy

I am following the example of this project to create my iOS app (thanks Alexey!), but can't get the #Environment variable to receive the value that is being passed down the UI hierarchy. The top level view receives the correct value, but the downstream view receives the default value.
EDIT: After tying to replicate Asperi's code, I found that this behavior happens only when the downstream view is invoked via a NavigationLink. Updated the code below:
EDIT2: The problem was with where the environment method was being invoked. Invoking it on the NavigationView instead of the MainView solved the problem. Code updated below:
Custom Environment key - DIContainer
struct DIContainer: EnvironmentKey {
let interactor: Interactor
init(interactor: Interactor) {
self.interactor = interactor
}
static var defaultValue: Self { Self.default }
private static let `default` = Self(interactor: .stub)
}
extension EnvironmentValues {
var injected: DIContainer {
get { self[DIContainer.self] }
set { self[DIContainer.self] = newValue }
}
}
App struct
private let container: DIContainer
init() {
container = DIContainer(interactor: RealInteractor())
}
var body: some Scene {
WindowGroup {
NavigationView {
MainView()
}
.environment(\.injected, container)
}
Main View
struct MainView: View {
#Environment(\.injected) private var injected: DIContainer
// `injected` has the `RealInteractor`, as expected
var body: some View {
VStack {
Text("Main: \(injected.foo())") \\ << Prints REAL
NavigationLink(destination: SearchView()) {
Text("Search")
}
}
}
}
Search View
struct SearchView: View {
#Environment(\.injected) private var injected: DIContainer
// `injected` has the `StubInteractor`, why?
var body: some View {
Text("Search: \(injected.foo())")
}
}
I am able to solve this problem by modifying the MainView like so:
var body: some View {
SearchView()
.environment(\.injected, container)
}
But isn't avoiding doing this repeatedly the purpose of #Environment?
Any guidance/pointers appreciated.
I've tryied to replicate all parts and to make them compiled... and the result just works as expected - environment is passed down the view hierarchy, so you might miss something in your real code.
Here is complete module, tested with Xcode 12.4 / iOS 14.4
class Interactor { // << replicated !!
static let stub = Interactor()
func foo() -> String { "stub" }
}
class RealInteractor: Interactor { // << replicated !!
override func foo() -> String { "real" }
}
struct ContentView: View { // << replicated !!
private let container: DIContainer
init() {
container = DIContainer(interactor: RealInteractor())
}
var body: some View {
NavigationView {
MainView()
}
.environment(\.injected, container) // << to affect any links !!
}
}
// no changes in env parts
struct DIContainer: EnvironmentKey {
let interactor: Interactor
init(interactor: Interactor) {
self.interactor = interactor
}
static var defaultValue: Self { Self.default }
private static let `default` = Self(interactor: .stub)
}
extension EnvironmentValues {
var injected: DIContainer {
get { self[DIContainer.self] }
set { self[DIContainer.self] = newValue }
}
}
struct MainView: View {
#Environment(\.injected) private var injected: DIContainer
// `injected` has the `RealInteractor`, as expected
var body: some View {
SearchView()
}
}
// just tested here
struct SearchView: View {
#Environment(\.injected) private var injected: DIContainer
var body: some View {
Text("Result: \(injected.interactor.foo())") // << works fine !!
}
}

How to force List to redraw by another View's toggle Button?

I fetched JSON data from Google Sheet and populate into a List using ForEach. I used struct HeaderView located in another View and place a Button to serve as a toggle. However, the List will not redraw when I press the toggle button even I use #State ascd variable.
Below is some of my code, is there anything I miss?
struct HeaderView: View {
// #State var asc: Bool = true
var holding: String = "ζŒε€‰"
var earning: String = "賺蝕"
// #State var tog_value: Bool = ContentView().ascd
var body: some View {
HStack {
Button(action: {
ContentView().ascd.toggle()
}
) {
Text("Button")
}
Text(holding)
Text(earning)
}
}
}
struct ContentView: View {
#ObservedObject var viewModel = ContentViewModel()
#ObservedObject var viewModelTotal = ContentViewModelTotal()
#State var ascd: Bool = false
var totalss = ContentViewModelTotal.fetchDatasTotal
var body: some View {
List {
Section(header: HeaderView()) {
ForEach(viewModel.rows, id: \.stockname) { rows in
// Text(user.stock_name)
ListRow(name: rows.stockname, code: rows.stockcode, cur_price: rows.currentprice, mkt_value: rows.marketvalue, amnt: rows.amount, avg_cost: rows.averagecost, pft: rows.profit, pft_pcnt: rows.profitpercent)
}
}
.onAppear {
self.viewModel.fetchDatas()
self.ascd.toggle()
if self.ascd {
self.viewModel.rows.sort { $0.stockname < $1.stockname }
} else {
self.viewModel.rows.sort { $0.stockname > $1.stockname }
}
}
}
}
}
For changing another View's variable you can use a #Binding variable:
struct HeaderView: View {
...
#Binding var ascd: Bool
var body: some View {
HStack {
Button(action: {
self.ascd.toggle()
}) {
Text("Button")
}
Text(holding)
Text(earning)
}
}
}
I'd recommend moving sorting logic to your ViewModel.
class ContentViewModel: ObservableObject {
#Published var ascd: Bool = false {
didSet {
if ascd {
rows.sort { $0.hashValue < $1.hashValue }
} else {
rows.sort { $0.hashValue > $1.hashValue }
}
}
}
...
}
If it's in the .onAppear in the ContentView it will be executed only when your View is shown on the screen.
And you will have to initialise your HeaderView with your ViewModel's ascd variable:
HeaderView(ascd: $viewModel.ascd)