SwiftUI Edit Struct from List - list

I am attempting to have a list that when a cell it tapped it changes the hasBeenSeen Bool value within the State object itself.
struct State: Identifiable {
var id = UUID()
let name: String
var hasBeenSeen: Bool = false
}
struct ContentView: View {
let states: [State] = [
State(name: "Oregon", hasBeenSeen: true),
State(name: "California", hasBeenSeen: true),
State(name: "Massachussets", hasBeenSeen: false),
State(name: "Washington", hasBeenSeen: true),
State(name: "Georgia", hasBeenSeen: false)
]
var body: some View {
NavigationView {
List {
ForEach(states, id: \.id) { state in
StateCell(state: state)
}
}.navigationBarTitle(Text("States"))
}
}
}
struct StateCell: View {
var state: State
var body: some View {
HStack {
Text(state.name)
Spacer()
if state.hasBeenSeen {
Image(systemName: "eye.fill")
}
}.onTapGesture {
// state.hasBeenSeen.toggle()
}
}
}
My original thought is that I need to make hasBeenSeen to a #State var but that doesn't seem to work. How can I make this Bool val editable from a list?

Views in SwiftUI are immutable - they are just structures - so you can't change their properties. That's why SwiftUI has a concept of a #State property wrapper. When you change "state" property, SwiftUI actually updates the state value, not the view's property value (which is, again, immutable).
So, you need to set #State on the states property within your view. (You'd also need to change the name, since identifier State is already taken by the State property wrapper - so I just changed it to StateEntity)
#State var states: [StateEntity] = [
StateEntity(name: "Oregon", hasBeenSeen: true),
// ... etc
]
That's not enough, though, since when you pass an element of the states array (a StateEntity value) to a child view, you're just passing a copy.
For that, you'd need a binding. A binding allows child views to modify state properties of parent views, without owning the data. So, the child view's property should use the #Binding property wrapper:
struct StateCell: View {
#Binding var state: StateEntity
// ...
}
SwiftUI made it easy to get the binding of state property by using the projected value, which in this case is $states.
However, you need to pass a binding not to the entire array, but to a specific element of that array. That, unfortunately (and rather annoyingly), is a bit trickier. You need to get the index of the element, and given the index, access the binding like so: $state[index].
One way is to do a ForEach over indices of states:
var body: some View {
NavigationView {
List {
ForEach(states.indices) { index in
StateCell(state: self.$states[index])
}
}.navigationBarTitle(Text("States"))
}
}

Related

Bug in SwiftUI? iOS 15. List refresh action is executed on an old instance of View -- how to work around?

I'm using the refreshable modifier on List
https://developer.apple.com/documentation/SwiftUI/View/refreshable(action:)
The List is contained in a view (TestChildView) that has a parameter. When the parameter changes, TestChildView is reinstantiated with the new value. The list has a refreshable action. However, when pulling down to refresh the list, the refresh action is run against the original view instance, so it doesn't see the current value of the parameter.
To reproduce with the following code: If you click the increment button a few times, you can see the updated value propagating to the list item labels. However, if you pull down the list to refresh, it prints the original value of the parameter.
I assume this is happening because of how refreshable works .. it sets the refresh environment value, and I guess it doesn't get updated as new instances of the view are created.
It seems like a bug, but I'm looking for a way to work around -- how can the refreshable action see the current variable/state values?
import SwiftUI
struct TestParentView: View {
#State var myVar = 0
var body: some View {
VStack {
Text("increment")
.onTapGesture {
myVar += 1
}
TestChildView(myVar: myVar)
}
}
}
struct TestChildView: View {
let myVar: Int
struct Item: Identifiable {
var id: String {
return val
}
let val: String
}
var list: [Item] {
return [Item(val: "a \(myVar)"), Item(val: "b \(myVar)"), Item(val: "c \(myVar)")]
}
var body: some View {
VStack {
List(list) { elem in
Text(elem.val)
}.refreshable {
print("myVar: \(myVar)")
}
}
}
}
The value of myVar in TestChildView is not updated because it has to be a #Binding. Otherwise, a new view is recreated.
If you pass the value #State var myVar from TestParentView to a #Binding var myVar to TestChildView, you will have the value being updated and the view kept alive the time of the parent view.
You will then notice that the printed value in your console is the refreshed one of the TestChildView.
Here is the updated code (See comments on the updated part).
import SwiftUI
struct TestParentView: View {
#State var myVar = 0
var body: some View {
VStack {
Text("increment")
.onTapGesture { myVar += 1 }
TestChildView(myVar: $myVar) // Add `$` to pass the updated value.
}
}
}
struct TestChildView: View {
#Binding var myVar: Int // Create the value to be `#Binding`.
struct Item: Identifiable {
var id: String { return val }
let val: String
}
var list: [Item] {
return [Item(val: "a \(myVar)"), Item(val: "b \(myVar)"), Item(val: "c \(myVar)")]
}
var body: some View {
VStack {
List(list) { elem in
Text(elem.val)
}
.refreshable { print("myVar: \(myVar)") }
}
}
}
Roland's answer is correct. Use a binding so that the correct myVar value is used.
As to why: .refreshable, along with other stateful modifiers like .task, .onAppear, .onReceive, etc, operate on a different phase in the SwiftUI View lifecycle. You are correct in assuming that the closure passed to refreshable is stored in the environment and doesn't get updated as the views are recreated. This is intentional. It would make little sense to recreate this closure whenever the view is updated, because updating the view is kind of its intended goal.
You can think of .refreshable (and the other modifiers mentioned above) as similar to the #State and #StateObject property wrappers, in that they are persisted across view layout updates. A #Binding property can also be considered stateful because it is a two-way 'binding' to a state variable from a parent view.
In fact generally speaking, the closures you pass to .refreshable or .task should only read and write to stateful properties (such as viewModels) for this exact reason.

SwiftUI clean up ContentView

I'm trying to simplify the ContentView within a project and I'm struggling to understand how to move #State based logic into its own file and have ContentView adapt to any changes. Currently I have dynamic views that display themselves based on #Binding actions which I'm passing the $binding down the view hierarchy to have buttons toggle the bool values.
Here's my current attempt. I'm not sure how in SwiftUI to change the view state of SheetPresenter from a nested view without passing the $binding all the way down the view stack. Ideally I'd like it to look like ContentView.overlay(sheetPresenter($isOpen, $present).
Also, I'm learning SwiftUI so if this isn't the best approach please provide guidance.
class SheetPresenter: ObservableObject {
#Published var present: Present = .none
#State var isOpen: Bool = false
enum Present {
case none, login, register
}
#ViewBuilder
func makeView(with presenter: Present) -> some View {
switch presenter {
case .none:
EmptyView()
case .login:
BottomSheetView(isOpen: $isOpen, maxHeight: UIConfig.Utils.screenHeight * 0.75) {
LoginScreen()
}
case .register:
BottomSheetView(isOpen: $isOpen, maxHeight: UIConfig.Utils.screenHeight * 0.75) {
RegisterScreen()
}
}
}
}
if you don't want to pass $binding all the way down the view you can create a StateObject variable in the top view and pass it with .environmentObject(). and access it from any view with EnvironmentObject
struct testApp: App {
#StateObject var s1: sViewModel = sViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(s1)
}
}
}
You are correct this is not the best approach, however it is a common mistake. In SwiftUI we actually use #State for transient data owned by the view. This means using a value type like a struct, not classes. This is explained at 4:18 in Data Essentials in SwiftUI from WWDC 2020.
EditorConfig can maintain invariants on its properties and be tested
independently. And because EditorConfig is a value type, any change to
a property of EditorConfig, like its progress, is visible as a change
to EditorConfig itself.
struct EditorConfig {
var isEditorPresented = false
var note = ""
var progress: Double = 0
mutating func present(initialProgress: Double) {
progress = initialProgress
note = ""
isEditorPresented = true
}
}
struct BookView: View {
#State private var editorConfig = EditorConfig()
func presentEditor() { editorConfig.present(…) }
var body: some View {
…
Button(action: presentEditor) { … }
…
}
}
Then you just use $editorConfig.isEditorPresented as the boolean binding in .sheet or .overlay.
Worth also taking a look at sheet(item:onDismiss:content:) which makes it much simpler to show an item because no boolean is required it uses an optional #State which you can set to nil to dismiss.

SwiftUI #State variables aren't updated

I basically have the same code as in this question. The problem I have is that when the tapGesture event happens, the sheet shows (the sheet code is called) but debug shows that showUserEditor is false (in that case, how is the sheet showing...?) and that selectedUserId is still nil (and therefore crashes on unwrapping it...)
The view:
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
#State private var selectedUserId : NSManagedObjectID? = nil
#State private var showUserEditor = false
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUserId = user.objectID
self.showUserEditor = true
}
}
}
}.sheet(isPresented: $showUserEditor) {
UserEditorView(userId: self.selectedUserId!)
}
}
}
If you want, I can publish the editor and the row but they seem irrelevant to the question as the magic should happen in the view.
So, I still haven't figured out WHY the code posted in the question didn't work, with a pointer from #loremipsum I got a working code by using another .sheet() method, one that takes an optional Item and not a boolean flag. The code now looks like this and works, but still if anyone can explain why the posted code didn't work I'd appreciate it.
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
#State private var selectedUser : User? = nil
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUser = user
}
}.onDelete(perform: deleteItems)
}
}.sheet(item: $selectedUser, onDismiss: nil) { user in
UserEditorView(user: user)
}
}
}
struct == immutable and SwiftUI decides when the struct gets init and reloaded
Working with code that depends on SwiftUI updating non-wrapped variables at a very specific time is not recommended. You have no control over this process.
To make your first setup work you need to use SwiftUI wrappers for the variables
.sheet(isPresented: $showUserEditor) {
//struct == immutable SwiftUI wrappers load the entire struct when there are changes
//With your original setup this variable gets created/set when the body is loaded so the orginal value of nil is what is seen in the next View
UserEditorView1(userId: $selectedUserId)
}
struct UserEditorView1: View {
//This is what you orginal View likely looks like it won't work because of the struct being immutable and SwiftUI controlling when the struct is reloaded
//let userId: NSManagedObjectID? <---- Depends on specific reload steps
//To make it work you would use a SwiftUI wrapper so the variable gets updated when SwiftUI descides to update it which is invisible to the user
#Binding var userId: NSManagedObjectID?
//This setup though now requres you to go fetch the object somehow and put it into the View so you can edit it.
//It is unnecessary though because SwiftUI provides the .sheet init with item where the item that is set gets passed directly vs waiting for the SwiftUi update no optionals
var body: some View {
Text(userId?.description ?? "nil userId")
}
}
Your answer code doesn't work because your parameter is optional and Binding does not like optionals
struct UserEditorView2: View {
//This is the setup that you posted in the Answer code and it doesn't work becaue of the ? Bindings do not like nil. You have to create wrappers to compensate for this
//But unecessary because all CoreData objects are ObservableObjects so you dont need Binding here the Binding is built-in the object for editing the variables
#Binding var user: User?
var body: some View {
TextField("nickname", text: $user.nickname)
}
}
Now for working code with an easily editable CoreData Object
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
//Your list view would use the CoreData object to trigger a sheet when the new value is available. When nil there will not be a sheet available for showing
#State private var selectedUser : User? = nil
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUser = user
}
}
}
}.sheet(item: $selectedUser, onDismiss: nil) { user in //This gives you a non-optional user so you don't have to compensate for nil in the next View
UserEditorView3(user: user)
}
}
}
Then the View in the sheet would look like this
struct UserEditorView3: View {
//I mentioned the ObservedObject in my comment
#ObservedObject var user: User
var body: some View {
//If your nickname is a String? you have to compensate for that optional but it is much simpler to do it from here
TextField("nickname", text: $user.nickname.bound)
}
}
//This comes from another very popular SO question (couldn't find it to quote it) that I could not find and is necessary when CoreData does not let you define a variable as non-optional and you want to use Binding for editing
extension Optional where Wrapped == String {
var _bound: String? {
get {
return self
}
set {
self = newValue
}
}
public var bound: String {
get {
//This just give you an empty String when the variable is nil
return _bound ?? ""
}
set {
_bound = newValue.isEmpty ? nil : newValue
}
}
}

onChange not getting called when #State var is modified

I'm working on a validation routine for a form, but when the validation results come in, the onChange is not being triggered.
So I have a form that has some fields, and some nested items that have some more fields (the number of items may vary). Think of a form for creating teams where you get to add people.
When the form is submitted, it sends a message to each item to validate itself, and the results of the validation of each item are stored in an array of booleans. Once all the booleans of the array are true, the form is submitted.
Every time a change occurs in the array of results, it should change a flag that would check if all items are true, and if they are, submits the form. But whenever I change the flag, the onChange I have for it never gets called:
final class AddEditProjectViewModel: ObservableObject {
#Published var array = ["1", "2", "3", "hello"]
// In reality this array would be a collection of objects with many properties
}
struct AddEditItemView: View {
#State var text : String
#Binding var doValidation: Bool // flag to perform the item validation
#Binding var isValid : Bool // result of validating all fields in this item
init(text: String, isValid: Binding<Bool>, doValidation: Binding<Bool>) {
self._text = State(initialValue: text)
self._isValid = isValid
self._doValidation = doValidation
}
func validateAll() {
// here would be some validation logic for all form fields,
//but I'm simulating the result to all items passed validation
// Validation needs to happen here because there are error message
//fields within the item view that get turned on or off
isValid = true
}
var body: some View {
Text(text)
.onChange(of: doValidation, perform: { value in
validateAll() // when the flag changes, perform the validation
})
}
}
struct ContentView: View {
#ObservedObject var viewModel : AddEditProjectViewModel
#State var performValidateItems : Bool = false // flag to perform the validation of all items
#State var submitFormFlag = false // flag to detect when validation results come in
#State var itemsValidationResult = [Bool]() // store the validation results of each item
{
didSet {
print(submitFormFlag) // i.e. false
submitFormFlag.toggle() // Even though this gets changed, on changed on it won't get called
print(submitFormFlag) // i.e. true
}
}
init(viewModel : AddEditProjectViewModel) {
self.viewModel = viewModel
var initialValues = [Bool]()
for _ in (0..<viewModel.array.count) { // populate the initial validation results all to false
initialValues.append(false)
}
_itemsValidationResult = State(initialValue: initialValues)
}
//https://stackoverflow.com/questions/56978746/how-do-i-bind-a-swiftui-element-to-a-value-in-a-dictionary
func binding(for index: Int) -> Binding<Bool> {
return Binding(get: {
return self.itemsValidationResult[index]
}, set: {
self.itemsValidationResult[index] = $0
})
}
var body: some View {
HStack {
ForEach(viewModel.array.indices, id: \.self) { i in
AddEditItemView(
text: viewModel.array[i],
isValid: binding(for: i),
doValidation: $performValidateItems
)
}
Text(itemsValidationResult.description)
Button(action: {
performValidateItems.toggle() // triggers the validation of all items
}) {
Text("Validate")
}
.onChange(of: submitFormFlag, perform: { value in // this never gets called
print(value, "forced")
// if all validation results in the array are true, it will submit the form
})
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: AddEditProjectViewModel())
}
}
You shouldn't use didSet on the #State - it's a wrapper and it doesn't behave like standard properties.
See SwiftUI — #State:
Declaring the #State isFilled variable gives access to three
different types:
isFilled — Bool
$isFilled — Binding
_isFilled — State
The State type is the wrapper — doing all the extra work for us — that stores an underlying wrappedValue,
directly accessible using isFilled property and a projectedValue,
directly accessible using $isFilled property.
Try onChange for itemsValidationResult instead:
var body: some View {
HStack {
// ...
}
.onChange(of: itemsValidationResult) { _ in
submitFormFlag.toggle()
}
.onChange(of: submitFormFlag) { value in
print(value, "forced")
}
}
You may also consider putting the code you had in .onChange(of: submitFormFlag) inside the .onChange(of: itemsValidationResult).

NavigationLink 101: How to send data from the host to the secondary View?

Goal: To simply pass struct per List row to a secondary View via NavigationLink.
Baby Step (prior goal): Merely pass a member of a String Array to the secondary view.
Problem: The Secondary View is expecting a Binding-String value in the parameter call vs the closure String value within the context. So I have to set the #State var to the current/context value prior to the call.
That's my problem. I can't simply equate the Binding var with the current context var; because in SwiftUI, such statements are limited to View based stuff only.
This doesn't work:
Here's the actual code:
import SwiftUI
struct ContentView: View {
#State var name = "" //... load with inital value to avoid having to add a call parameter.
var body: some View {
let myArray = ["Larry", "Moe", "Curly"]
NavigationView {
List(myArray, id: \.self) { theStooge in
NavigationLink(destination: SecondView(stoogeName: theStooge)) {
Text(theStooge)
}
}
.navigationBarTitle("Three Stooges").navigationBarTitleDisplayMode(.inline)
}
}
}
struct SecondView: View {
#Binding var stoogeName: String
var body: some View {
Text("Hello \(name)")
}
}
I can merely create the SecondView via a Text("Hello World") in the NavigationLink's destination parameter. But that's not very helpful. I want to pass data (struct of data) to a secondary View per List member.
But I need to set a binding variable.
How?
Do I have to jury rig an EnvironmentObject or Singleton?
Binding can be set to dynamic property but your array is constant local variable. Here is possible solution to work with binding:
struct ContentView: View {
#State var name = "" //... load with inital value to avoid having to add a call parameter.
#State private var myArray = ["Larry", "Moe", "Curly"]
var body: some View {
NavigationView {
List(myArray.indices, id: \.self) { index in
NavigationLink(destination: SecondView(stoogeName: $myArray[index])) {
Text(myArray[index])
}
}
.navigationBarTitle("Three Stooges").navigationBarTitleDisplayMode(.inline)
}
}
}