SwiftUI state discarded when scrolling items in a list - swiftui

I'm very new to Swift and SwiftUI so apologies for the very basic question. I must be misunderstanding something about the SwiftUI lifecycle and it's interaction with #State.
I've have a list, and when you click on the row, it increments a counter. If I click on some row items to increment the counter, scroll down, and scroll back up again - the state is reset to 0 again. Can anyone point out where I'm going wrong? Many thanks.
struct TestView : View {
#State private var listItems:[String] = (0..<50).map { String($0) }
var body: some View {
List(listItems, id: \.self) { listItem in
TestViewRow(item: listItem)
}
}
}
struct TestViewRow: View {
var item: String
#State private var count = 0
var body: some View {
HStack {
Button(item, action: {
self.count += 1
})
Text(String(self.count))
Spacer()
}
}
}

Items in a List are potentially lazily-loaded, depending on the os (macOS vs iOS), length of the list, number of items on the screen, etc.
If a list item is loaded and then its state is changed, that state is not reassigned to the item if that item has been since unloaded/reloaded into the List.
Instead of storing #State on each List row, you could move the state to the parent view, which wouldn't be unloaded:
struct ContentView : View {
#State private var listItems:[(item:String,count:Int)] = (0..<50).map { (item:String($0),count:0) }
var body: some View {
List(Array(listItems.enumerated()), id: \.0) { (index,item) in
TestViewRow(item: item.item, count: $listItems[index].count)
}
}
}
struct TestViewRow: View {
var item: String
#Binding var count : Int
var body: some View {
HStack {
Button(item, action: {
count += 1
})
Text(String(count))
Spacer()
}
}
}

Related

How to redraw a child view in SwiftUI?

I have a ContentView that has a state variable "count". When its value is changed, the number of stars in the child view should be updated.
struct ContentView: View {
#State var count: Int = 5
var body: some View {
VStack {
Stepper("Count", value: $count, in: 0...10)
StarView(count: $count)
}
.padding()
}
}
struct StarView: View {
#Binding var count: Int
var body: some View {
HStack {
ForEach(0..<count) { i in
Image(systemName: "star")
}
}
}
}
I know why the number of stars are not changed in the child view, but I don't know how to fix it because the child view is in a package that I cannot modify. How can I achieve my goal only by changing the ContentView?
You are using the incorrect ForEach initializer, because you aren't explicitly specifying an id. This initializer is only for constant data, AKA a constant range - which this data isn't since count changes.
The documentation for the initializer you are currently using:
It's important that the id of a data element doesn't change unless you
replace the data element with a new data element that has a new
identity. If the id of a data element changes, the content view
generated from that data element loses any current state and animations.
Explicitly set the id like so, by adding id: \.self:
struct StarView: View {
#Binding var count: Int
var body: some View {
HStack {
ForEach(0 ..< count, id: \.self) { _ in
Image(systemName: "star")
}
}
}
}
Similar answer here.

Update text with Slider value in List from an array in SwiftUI

I have a list of sliders, but I have a problem updating the text that shows the slider value.
The app workflow is like this:
User taps to add a new slider to the list.
An object that defines the slider is created and stored in an array.
The class that has the array as a property (Db) is an ObservableObject and triggers a View update for each new item.
The list is updated with a new row.
So far, so good. Each row has a slider whose value is stored in a property in an object in an array. However, the value text doesn't update as soon as the slider is moved, but when a new item is added. Please see the GIF below:
The Slider doesn't update the text value when moved
How can I bind the slider movements to the text value? I thought that by defining
#ObservedObject var slider_value: SliderVal = SliderVal()
and binding that variable to the slider, the value would be updated simultaneously but that is not the case. Thanks a lot for any help.
import SwiftUI
import Combine
struct ContentView: View {
#ObservedObject var db: Db
var body: some View {
NavigationView{
List(db.criteria_db){criteria in
VStack {
HStack{
Text(criteria.name).bold()
Spacer()
Text(String(criteria.slider_value.value)) //<-- Problem here
}
Slider(value: criteria.$slider_value.value, in:0...100, step: 1)
}
}
.navigationBarTitle("Criteria")
.navigationBarItems(trailing:
Button(action: {
Criteria.count += 1
db.criteria_db.append(Criteria(name: "Criteria\(Criteria.count)"))
dump(db.criteria_db)
}, label: {
Text("Add Criteria")
})
)
}
.listStyle(InsetGroupedListStyle())
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(db: Db())
}
}
struct Criteria: Identifiable {
var id = UUID()
var name: String
#ObservedObject var slider_value: SliderVal = SliderVal()
static var count: Int = 0
init(name: String) {
self.name = name
}
}
class Db: ObservableObject {
#Published var criteria_db: [Criteria] = []
}
class SliderVal: ObservableObject {
#Published var value:Double = 50
}
The #ObservableObject won't work within a struct like that -- it's only useful inside a SwiftUI View or a DynamicProperty. With your use case, because the class is a reference type, the #Published property has no way of knowing that the SliderVal was changed, so the owner View never gets updated.
You can fix this by turning your model into a struct:
struct Criteria: Identifiable {
var id = UUID()
var name: String
var slider_value: SliderVal = SliderVal()
static var count: Int = 0
init(name: String) {
self.name = name
}
}
struct SliderVal {
var value:Double = 50
}
The problem, once you do this, is you don't have a Binding to use in your List. If you're lucky enough to be on SwiftUI 3.0 (iOS 15 or macOS 12), you can use $criteria within your list to get a binding to the element being currently iterated over.
If you're on an earlier version, you'll need to either use indexes to iterate over the items, or, my favorite, create a custom binding that is tied to the id of the item. It looks like this:
struct ContentView: View {
#ObservedObject var db: Db = Db()
private func bindingForId(id: UUID) -> Binding<Criteria> {
.init {
db.criteria_db.first { $0.id == id } ?? Criteria(name: "")
} set: { newValue in
db.criteria_db = db.criteria_db.map {
$0.id == id ? newValue : $0
}
}
}
var body: some View {
NavigationView{
List(db.criteria_db){criteria in
VStack {
HStack{
Text(criteria.name).bold()
Spacer()
Text(String(criteria.slider_value.value))
}
Slider(value: bindingForId(id: criteria.id).slider_value.value, in:0...100, step: 1)
}
}
.navigationBarTitle("Criteria")
.navigationBarItems(trailing:
Button(action: {
Criteria.count += 1
db.criteria_db.append(Criteria(name: "Criteria\(Criteria.count)"))
dump(db.criteria_db)
}, label: {
Text("Add Criteria")
})
)
}
.listStyle(InsetGroupedListStyle())
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(db: Db())
}
}
class Db: ObservableObject {
#Published var criteria_db: [Criteria] = []
}
Now, because the models are all value types (structs), the View and #Published know when to update and your sliders work as expected.
try something like this:
Slider(value: criteria.$slider_value.value, in:0...100, step: 1)
.onChange(of: criteria.slider_value.value) { newVal in
DispatchQueue.main.async {
criteria.slider_value.value = newVal
}
}

How can I make a view wrapped in a `State` property update with SwiftUI

The code below creates a simple HStack that ends up looking like this:
The problem is that hitting "increment" increments "Count" but not "Nested". Does anyone know why this is the case and possibly how to fix this? Or do SwiftUI views just fundamentally break when they are nested in a State variable?
struct ContentView: View {
var body: some View {
VStack {
Text("Count: \(count)")
nested
Button(action: {
self.count += 1
self.nested.count += 1
}) { Text("Increment") }
}
}
#State var count = 0
struct Nested: View {
var body: some View {
Text("Nested: \(count)")
}
#State var count = 0
}
#State var nested = Nested()
}
SwiftUI was designed to "nest" views, but you're not using it as intended. State variables are for data owned by the view, and a nested view isn't (or at least, not typically) meant to be a data owned by the view, so it need not be a state variable.
Instead, you can just the count variable as a parameter to the Nested view, and any time the count state variable changes in the parent, its body will be re-rendered:
struct ContentView: View {
var body: some View {
VStack {
Text("Count: \(count)")
Nested(count: count) // pass the count as an init param
Button(action: {
self.count += 1
self.nested.count += 1
}) { Text("Increment") }
}
}
#State var count = 0
struct Nested: View {
var body: some View {
Text("Nested: \(count)")
}
var count: Int
}
}

SwiftUI: .sheet() doesn't go to the previous view with expected data when dismiss current sheet

As minimal, my code is like below. In SinglePersonView When user tap one image of movie in MovieListView(a movie list showing actor attended movies), then it opens the SingleMovieView as sheet mode.
The sheet could be popped up as tapping. But I found after close the sheet and re-select other movie in MovieListView, the sheet always opened as my previous clicked movie info aka the first time chosen one. And I could see in console, the movie id is always the same one as the first time. I get no clues now, do I need some reloading operation on the dismissal or something else?
And is it the correct way to use .sheet() in subView in SwiftUI, or should always keep it in the main body, SinglePersonView in this case.
struct SinglePersonView: View {
var personId = -1
#ObservedObject var model = MovieListViewModel()
var body: some View {
ScrollView() {
VStack() {
...
MovieListView(movies: model.movies)
...
}
}.onAppear {
// json API request
}
}
}
struct MovieListView: View {
var movies: [PersonMovieViewModel]
#State private var showSheet = false
ScrollView() {
HStack() {
ForEach(movies) { movie in
VStack() {
Image(...)
.onTapGesture {
self.showSheet.toggle()
}
.sheet(isPresented: self.$showSheet) {
SingleMovieView(movieId: movie.id)
}
}
}
}
}
}
There should be only one .sheet in view stack, but in provided snapshot there are many which activated all at once - following behaviour is unpredictable, actually.
Here is corrected variant
struct MovieListView: View {
var movies: [PersonMovieViewModel]
#State private var showSheet = false
#State private var selectedID = "" // type of your movie's ID
var body: some View {
ScrollView() {
HStack() {
ForEach(movies) { movie in
VStack() {
Image(...)
.onTapGesture {
self.selectedID = movie.id
self.showSheet.toggle()
}
}
}
}
.sheet(isPresented: $showSheet) {
SingleMovieView(movieId: selectedID)
}
}
}
}

SwiftUI: Get toggle state from items inside a List

I'm building an app where I generate a dynamic list inside a view, the items inside this list have a toggle button. If a button in the parent view is pressed and all the items have their toggle activated. Then a function is carried on.
How can I get the #state of all the list items in order to do the function when the button is pressed.
Here is some basic code for it:
struct OrderView: View {
var pOrder: OrderObject
var body: some View {
VStack(alignment: .leading){
Button(action: buttonAction) { Text("myBttn") }
List(pOrder.contents, id: \.name) { item in
Child(pOrder: item)
}
}
}
}
And here is the code for the child view
struct Child: View {
var pContents: Contents
#State var selected: Bool = false
var body: some View {
Toggle(isOn: $selected){ Text("Item") }
}
}
struct ObjectOrder: Identifiable {
var id = UUID()
var order = ""
var isToggled = false
init(order: String) {
self.order = order
}
}
struct ContentView: View {
#State var pOrder = [
ObjectOrder(order: "Order1"),
ObjectOrder(order: "Order2"),
ObjectOrder(order: "Order3"),
ObjectOrder(order: "Order4"),
ObjectOrder(order: "Order5")
]
var body: some View {
List(pOrder.indices) { index in
HStack {
Text("\(self.pOrder[index].order)")
Toggle("", isOn: self.$pOrder[index].isToggled)
}
}
}
}
Try using .indices would give you an index to your object in the array. Adding a property to track the toggle (isToggled in the example above) would allow you to store and keep track of the objects toggled status.