SwiftUI - How to change/access #Published var value via toggle from View? - swift

I'm making a simple password generation app. The idea was simple.
There are 2 toggles in the View that are bound to ObservableObject class two #Published bool vars. The class should return a different complexity of generated password to a new View dependently on published vars true/false status after clicking generate button.
Toggles indeed change published var status to true/false (when I print it on toggle) and the destination view does show the password for false/false combination but for some reason, after clicking generate, they always stay false unless I manually change their value to true. Can toggles change permanently the value of #Published var values somehow?
I can't seem to find a suitable workaround. Any solutions how to make this work?
MainView
import SwiftUI
struct MainView: View {
#ObservedObject var manager = PasswordManager()
var body: some View {
NavigationView() {
VStack {
ZStack {
Toggle(isOn: $manager.includeNumbers) {
Text("Include numbers")
.italic()
}
}
ZStack {
Toggle(isOn: $manager.includeCharacters) {
Text("Include special characters")
.italic()
}
}
NavigationLink(destination: PasswordView(), label: {
Text("Generate")
})
}
.padding(80)
}
}
PasswordManager
import Foundation
class PasswordManager: ObservableObject {
#Published var includeNumbers = false
#Published var includeCharacters = false
let letters = ["A", "B", "C", "D", "E"]
let numbers = ["1", "2", "3", "4", "5"]
let specialCharacters = ["!", "#", "#", "$", "%"]
var password: String = ""
func generatePassword() -> String {
password = ""
if includeNumbers == false && includeCharacters == false {
for _ in 1...5 {
password += letters.randomElement()!
}
}
else if includeNumbers && includeCharacters {
for _ in 1...3 {
password += letters.randomElement()!
password += numbers.randomElement()!
password += specialCharacters.randomElement()!
}
}
return password
}
}
View that shows password
import SwiftUI
struct PasswordView: View {
#ObservedObject var manager = PasswordManager()
var body: some View {
Text(manager.generatePassword())
}
}

The problem is caused by the fact that your PasswordView creates its own PasswordManager. Instead, you need to inject it from the parent view.
You should never initialise an #ObservedObject inside the View itself, since whenever the #ObservedObject's objectWillChange emits a value, it will reload the view and hence create a new object. You either need to inject the #ObservedObject or declare it as #StateObject if you are targeting iOS 14.
PasswordView needs to have PasswordManager injected from MainView, since they need to use the same instance to have shared state. In MainView, you can use #StateObject if targeting iOS 14, otherwise you should inject PasswordManager even there.
import SwiftUI
struct PasswordView: View {
#ObservedObject private var manager: PasswordManager
init(manager: PasswordManager) {
self.manager = manager
}
var body: some View {
Text(manager.generatePassword())
}
}
struct MainView: View {
#StateObject private var manager = PasswordManager()
var body: some View {
NavigationView() {
VStack {
ZStack {
Toggle(isOn: $manager.includeNumbers) {
Text("Include numbers")
.italic()
}
}
ZStack {
Toggle(isOn: $manager.includeCharacters) {
Text("Include special characters")
.italic()
}
}
NavigationLink(destination: PasswordView(manager: manager), label: {
Text("Generate")
})
}
.padding(80)
}
}
}

Related

SwiftUI How to have class viewable from all views

I am fairly new to SwiftUI and I am trying to build an app where you can favorite items in a list. It works in the ContentView but I would like to have the option to favorite and unfavorite an item in its DetailView.
I know that vm is not in the scope but how do I fix it?
Here is some of the code in the views. The file is long so I am just showing the relevant code
struct ContentView: View {
#StateObject private var vm = ViewModel()
//NavigationView with a List {
//This is the code I call for showing the icon. The index is the item in the list
Image(systemName: vm.contains(index) ? "heart.fill" : "heart")
.onTapGesture{
vm.toggleFav(item: index)
}
}
struct DetailView: View {
Hstack{
Image(systemName: vm.contains(entry) ? "heart.fill" : "heart") //Error is "Cannot find 'vm' in scope"
}
}
Here is the code that that vm is referring to
import Foundation
import SwiftUI
extension ContentView {
final class ViewModel: ObservableObject{
#Published var items = [Biase]()
#Published var showingFavs = false
#Published var savedItems: Set<Int> = [1, 7]
// Filter saved items
var filteredItems: [Biase] {
if showingFavs {
return items.filter { savedItems.contains($0.id) }
}
return items
}
private var BiasStruct: BiasData = BiasData.allBias
private var db = Database()
init() {
self.savedItems = db.load()
self.items = BiasStruct.biases
}
func sortFavs(){
withAnimation() {
showingFavs.toggle()
}
}
func contains(_ item: Biase) -> Bool {
savedItems.contains(item.id)
}
// Toggle saved items
func toggleFav(item: Biase) {
if contains(item) {
savedItems.remove(item.id)
} else {
savedItems.insert(item.id)
}
db.save(items: savedItems)
}
}
}
This is the list view...
enter image description here
Detail view...
enter image description here
I tried adding this code under the List(){} in the ContentView .environmentObject(vm)
And adding this under the DetailView #EnvironmentObject var vm = ViewModel() but it said it couldn't find ViewModel.
To put the view model inside the ContentView struct is wrong. Delete the enclosing extension.
If the view model is supposed to be accessed from everywhere it must be on the top level.
In the #main struct create the instance of the view model and inject it into the environment
#main
struct MyGreatApp: App {
#StateObject var viewModel = ViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(viewModel)
}
}
}
And in any struct you want to use it add
#EnvironmentObject var vm : ViewModel
without parentheses.

I'm trying to implement a view stack in swiftui and my #State objects are being reset for reasons that are unclear to me

I'm new to swiftui and doing an experiment with pushing and popping views with a stack. When I pop a view off the stack, the #State variable of the prior view has been reset and I don't understand why.
This demo code was tested on macos.
import SwiftUI
typealias Push = (AnyView) -> ()
typealias Pop = () -> ()
struct PushKey: EnvironmentKey {
static let defaultValue: Push = { _ in }
}
struct PopKey: EnvironmentKey {
static let defaultValue: Pop = {() in }
}
extension EnvironmentValues {
var push: Push {
get { self[PushKey.self] }
set { self[PushKey.self] = newValue }
}
var pop: Pop {
get { self[PopKey.self] }
set { self[PopKey.self] = newValue }
}
}
struct ContentView: View {
#State private var stack: [AnyView]
var body: some View {
currentView()
.environment(\.push, push)
.environment(\.pop, pop)
.frame(width: 600.0, height: 400.0)
}
public init() {
_stack = State(initialValue: [AnyView(AAA())])
}
private func currentView() -> AnyView {
if stack.count == 0 {
return AnyView(Text("stack empty"))
}
return stack.last!
}
public func push(_ content: AnyView) {
stack.append(content)
}
public func pop() {
stack.removeLast()
}
}
struct AAA : View {
#State private var data = "default text"
#Environment(\.push) var push
var body: some View {
VStack {
TextEditor(text: $data)
Button("Push") {
self.push(AnyView(BBB()))
}
}
}
}
struct BBB : View {
#Environment(\.pop) var pop
var body: some View {
VStack {
Button("Pop") {
self.pop()
}
}
}
}
If I type some text into the editor then hit Push, then Pop out of that view, I was expecting the text editor to maintain my changes but it reverts to the default text.
What am I missing?
Edit:
I guess this is really a question of how are NavigationView and NavigationLink implemented. This simple code does the what I'm trying to do:
import SwiftUI
struct MyView: View {
#State var text = "default text"
var body: some View {
VStack {
TextEditor(text: $text)
NavigationLink(destination: MyView()) {
Text("Push")
}
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
MyView()
}
}
}
run that on iOS so you get a nav stack. edit the text, then push. Edit again if you want, then go back and see state is retained.
My code is trying to do the same thing in principle.
I'll share this attempt maybe it will help you create your version of this.
This all started with an attempt to create something like NavigationView and NavigationLink but being able to back track to a random View in the stack
I have a protocol where an object returns a View. Usually it is an enum. The view() references a View with a switch that provides the correct child View. The ContentView/MainView works almost like a storyboard and just presents whatever is designated in the current or path variables.
//To make the View options generic
protocol ViewOptionsProtocol: Equatable {
associatedtype V = View
#ViewBuilder func view() -> V
}
This is the basic navigation router that keep track of the main view and the NavigationLink/path. Which looks similar to what you want to do.
//A generic Navigation Router
class ViewNavigationRouter<T: ViewOptionsProtocol>: ObservableObject{
//MARK: Variables
var home: T
//Keep track of your current screen
#Published private (set) var current: T
//Keep track of the path
#Published private (set) var path: [T] = []
//MARK: init
init(home: T, current: T){
self.home = home
self.current = current
}
//MARK: Functions
//Control how you get to the screen
///Navigates to the nextScreen adding to the path/cookie crumb
func push(nextScreen: T){
//This is a basic setup just going forward
path.append(nextScreen)
}
///Goes back one step in the path/cookie crumb
func pop(){
//Use the stored path to go back
_ = path.popLast()
}
///clears the path/cookie crumb and goes to the home screen
func goHome(){
path.removeAll()
current = home
}
///Clears the path/cookie crumb array
///sets the current View to the desired screen
func show(nextScreen: T){
goHome()
current = nextScreen
}
///Searches in the path/cookie crumb for the desired View in the latest position
///Removes the later Views
///sets the nextScreen
func dismissTo(nextScreen: T){
while !path.isEmpty && path.last != nextScreen{
pop()
}
if path.isEmpty{
show(nextScreen: nextScreen)
}
}
}
It isn't an #Environment but it can easily be an #EnvrionmentObject and all the views have to be in the enum so the views are not completely unknown but it is the only way I have been able to circumvent AnyView and keep views in an #ViewBuilder.
I use something like this as the main portion in the main view body
router.path.last?.view() ?? router.current.view()
Here is a simple implementation of your sample
import SwiftUI
class MyViewModel: ViewNavigationRouter<MyViewModel.ViewOptions> {
//In some view router concepts the data that is /preserved/shared among the views is preserved in the router itself.
#Published var preservedData: String = "preserved"
init(){
super.init(home: .aaa ,current: .aaa)
}
enum ViewOptions: String, ViewOptionsProtocol, CaseIterable{
case aaa
case bbb
#ViewBuilder func view() -> some View{
ViewOptionsView(option: self)
}
}
struct ViewOptionsView: View{
let option: ViewOptions
var body: some View{
switch option {
case .aaa:
AAA()
case .bbb:
BBB()
}
}
}
}
struct MyView: View {
#StateObject var router: MyViewModel = .init()
var body: some View {
NavigationView{
ScrollView {
router.path.last?.view() ?? router.current.view()
}
.toolbar(content: {
//Custom back button
ToolbarItem(placement: .navigationBarLeading, content: {
if !router.path.isEmpty {
Button(action: {
router.pop()
}, label: {
HStack(alignment: .center, spacing: 2, content: {
Image(systemName: "chevron.backward")
if router.path.count >= 2{
Text(router.path[router.path.count - 2].rawValue)
}else{
Text(router.current.rawValue)
}
})
})
}
})
})
.navigationTitle(router.path.last?.rawValue ?? router.current.rawValue)
}.environmentObject(router)
}
}
struct MyView_Previews: PreviewProvider {
static var previews: some View {
MyView()
}
}
struct AAA : View {
//This will reset because the view is cosmetic. the data needs to be preserved somehow via either persistence or in the router for sharing with other views.
#State private var data = "default text"
#EnvironmentObject var vm: MyViewModel
var body: some View {
VStack {
TextEditor(text: $data)
TextEditor(text: $vm.preservedData)
Button("Push") {
vm.push(nextScreen: .bbb)
}
}
}
}
struct BBB : View {
#EnvironmentObject var vm: MyViewModel
var body: some View {
VStack {
Button("Pop") {
vm.pop()
}
}
}
}

SwiftUI TextField resets value and ignores binding

Using a TextField on a mac app, when I hit 'return' it resets to its original value, even if the underlying binding value is changed.
import SwiftUI
class ViewModel {
let defaultS = "Default String"
var s = ""
var sBinding: Binding<String> {
.init(get: {
print("Getting binding \(self.s)")
return self.s.count > 0 ? self.s : self.defaultS
}, set: {
print("Setting binding")
self.s = $0
})
}
}
struct ContentView: View {
#State private var vm = ViewModel()
var body: some View {
TextField("S:", text: vm.sBinding)
.padding()
}
}
Why is this? Shouldn't it 'get' the binding value and use that? (i.e. shouldn't I see my print statement "Getting binding" in the console after I hit 'return' on the textfield?).
Here you go!
class ViewModel: ObservableObject {
#Published var s = "Default String"
}
struct ContentView: View {
#StateObject private var vm = ViewModel()
var body: some View {
TextField("S:", text: $vm.s)
.padding()
}
}
For use in multiple views, in every view where you'd like to use the model add:
#EnvironmentObject private var vm: ViewModel
But don't forget to inject the model to the main view:
ContentView().environmentObject(ViewModel())

How to correctly handle Picker in Update Views (SwiftUI)

I'm quite new to SwiftUI and I'm wondering how I should use a picker in an update view correctly.
At the moment I have a form and load the data in with .onAppear(). That works fine but when I try to pick something and go back to the update view the .onAppear() gets called again and I loose the picked value.
In the code it looks like this:
import SwiftUI
struct MaterialUpdateView: View {
// Bindings
#State var material: Material
// Form Values
#State var selectedUnit = ""
var body: some View {
VStack(){
List() {
Section(header: Text("MATERIAL")){
// Picker for the Unit
Picker(selection: $selectedUnit, label: Text("Einheit")) {
ForEach(API().units) { unit in
Text("\(unit.name)").tag(unit.name)
}
}
}
}
.listStyle(GroupedListStyle())
}
.onAppear(){
prepareToUpdate()
}
}
func prepareToUpdate() {
self.selectedUnit = self.material.unit
}
}
Does anyone has experience with that problem or am I doing something terribly wrong?
You need to create a custom binding which we will implement in another subview. This subview will be initialised with the binding vars selectedUnit and material
First, make your MaterialUpdateView:
struct MaterialUpdateView: View {
// Bindings
#State var material : Material
// Form Values
#State var selectedUnit = ""
var body: some View {
NavigationView {
VStack(){
List() {
Section(header: Text("MATERIAL")) {
MaterialPickerView(selectedUnit: $selectedUnit, material: $material)
}
.listStyle(GroupedListStyle())
}
.onAppear(){
prepareToUpdate()
}
}
}
}
func prepareToUpdate() {
self.selectedUnit = self.material.unit
}
}
Then, below, add your MaterialPickerView, as shown:
Disclaimer: You need to be able to access your API() from here, so move it or add it in this view. As I have seen that you are re-instanciating it everytime, maybe it is better that you store its instance with let api = API() and then refer to it with api, and even pass it to this view as such!
struct MaterialPickerView: View {
#Binding var selectedUnit: String
#Binding var material : Material
#State var idx: Int = 0
var body: some View {
let binding = Binding<Int>(
get: { self.idx },
set: {
self.idx = $0
self.selectedUnit = API().units[self.idx].name
self.material.unit = self.selectedUnit
})
return Picker(selection: binding, label: Text("Einheit")) {
ForEach(API().units.indices) { i in
Text(API().units[i].name).tag(API().units[i].name)
}
}
}
}
That should do,let me know if it works!

Deriving binding from existing SwiftUI #States

I've been playing around with SwiftUI and Combine and feel like there is probably a way to get a hold of the existing #State properties in a view and create a new one.
For example, I have a password creation View which holds a password and a passwordConfirm field for the user. I want to take those two #State properties and derive a new #State that I can use in my view that asserts if the input is valid. So for simplicity: not empty and equal.
The Apple docs say there is a publisher on a binding, though I can't appear to get ahold of it.
This is some non-functioning pseudo code:
import SwiftUI
import Combine
struct CreatePasswordView : View {
#State var password = ""
#State var confirmation = ""
lazy var valid = {
return self.$password.publisher()
.combineLatest(self.$confirmation)
.map { $0 != "" && $0 == $1 }
}
var body: some View {
SecureField($password, placeholder: Text("password"))
SecureField($confirmation, placeholder: Text("confirm password"))
NavigationButton(destination: NextView()) { Text("Done") }
.disabled(!valid)
}
}
Anyone found. the appropriate way of going about this / if it's possible?
UPDATE Beta 2:
As of beta 2 publisher is available so the first half of this code now works. The second half of using the resulting publisher within the View I've still not figured out (disabled(!valid)).
import SwiftUI
import Combine
struct CreatePasswordView : View {
#State var password = ""
#State var confirmation = ""
lazy var valid = {
Publishers.CombineLatest(
password.publisher(),
confirmation.publisher(),
transform: { String($0) != "" && $0 == $1 }
)
}()
var body: some View {
SecureField($password, placeholder: Text("password"))
SecureField($confirmation, placeholder: Text("confirm password"))
NavigationButton(destination: NextView()) { Text("Done") }
.disabled(!valid)
}
}
Thanks.
I wouldn't be playing with #State/#Published as Combine is in beta at the moment, but here's a simple workaround for what you're trying to achieve.
I'd implement a view model to hold password, password confirmation, and whether it's valid or not
class ViewModel: NSObject, BindableObject {
var didChange = PassthroughSubject<Void, Never>()
var password: String = "" {
didSet {
didChange.send(())
}
}
var passwordConfirmation: String = "" {
didSet {
didChange.send(())
}
}
var isPasswordValid: Bool {
return password == passwordConfirmation && password != ""
}
}
In this way, the view is recomputed anytime the password or the confirmation changes.
Then I would make a #ObjectBinding to the view model.
struct CreatePasswordView : View {
#ObjectBinding var viewModel: ViewModel
var body: some View {
NavigationView {
VStack {
SecureField($viewModel.password,
placeholder: Text("password"))
SecureField($viewModel.passwordConfirmation,
placeholder: Text("confirm password"))
NavigationButton(destination: EmptyView()) { Text("Done") }
.disabled(!viewModel.isPasswordValid)
}
}
}
}
I had to put the views in a NavigationView, because NavigationButton doesn't seem to enable itself if it isn't in one of them.
What you need here is a computed property. As the name suggests, its value is re-computed every time the property is accessed. If you use #State variables to calculate the property, SwiftUI will automatically re-compute the body of the View whenever valid is changed:
struct CreatePasswordView: View {
#State var password = ""
#State var confirmation = ""
private var valid: Bool {
password != "" && password == confirmation
}
var body: some View {
SecureField($password, placeholder: Text("password"))
SecureField($confirmation, placeholder: Text("confirm password"))
NavigationLink(destination: NextView()) { Text("Done") }
.disabled(!valid)
}
}