Weird behavior with NavigationSplitView and #State - swiftui

I have a NavigationSplitView in my app, I have an #State variable in my detail view that gets created in init.
When I select something from the sidebar and the detail view renders, at first everything looks ok. But when I select a different item on the sidebar, the contents of the #state variable don't get recreated.
Using the debugger I can see the init of the detail view get called every time I select a new item in the sidebar, and I can see the #State variable get created. But when it actually renders, the #State variable still contains the previous selection's values.
I've reduced this problem to a test case I'll paste below. The top text in the detail view is a variable passed in from the sidebar, and the second line of text is generated by the #State variable. Expected behavior would be, if I select "one" the detail view would display "one" and "The name is one". If I select "two" the detail view would display "two" and "The name is two".
Instead, if I select "one" first, it displays correctly. But when I select "two", it displays "two" and "The name is one".
Note that if I select "two" as the first thing I do after launching the app, it correctly displays "two" and "The name is two", but when I click on "one" next, it will display "one" and "the name is two". So the state variable is being set once, then never changing again,
Here's the sample code and screenshots:
import SwiftUI
struct Item: Hashable, Identifiable {
let id = UUID()
let name: String
}
struct ContentView: View {
#State private var selectedItem: Item.ID? = nil
private let items = [Item(name: "one"), Item(name: "two"), Item(name: "three")]
func itemForID(_ id: UUID?) -> Item? {
guard let itemID = id else { return nil }
return items.first(where: { item in
item.id == itemID
})
}
var body: some View {
NavigationSplitView{
List(selection: $selectedItem) {
ForEach(items) { item in
Text(item.name)
.tag(item.id)
}
}
} detail: {
if let name = itemForID(selectedItem)?.name {
DetailView(name: name)
} else {
Text("Select an item")
}
}
}
}
struct DetailView: View {
#State var detailItem: DetailItem
var name: String
init(name: String) {
self.name = name
_detailItem = State(wrappedValue: DetailItem(name: name))
}
var body: some View {
VStack {
Text(name)
Text(detailItem.computedText)
}
}
}
struct DetailItem {
let name: String
var computedText: String {
return "The name is \(name)"
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Question. What is the purpose of having detailItem as a #State? if you remove the #State, this test case works.
Will the way computedText change over time?
struct DetailView: View {
// #State var detailItem: DetailItem
var detailItem: DetailItem
var name: String
init(name: String) {
self.name = name
// _detailItem = State(wrappedValue: DetailItem(name: name))
detailItem = DetailItem(name: name)
}
var body: some View {
VStack {
Text(name)
Text(detailItem.computedText)
}
}
}

This has nothing to do with NavigationSplitView, but how you initialise #State property.
According to the Apple document on #State (https://developer.apple.com/documentation/swiftui/state):
Don’t initialise a state property of a view at the point in the view hierarchy where you instantiate the view, because this can conflict with the storage management that SwiftUI provides.
As well as the documentation of init(wrappedValue:) (https://developer.apple.com/documentation/swiftui/state/wrappedvalue):
Don’t call this initializer directly. Instead, declare a property with the State attribute, and provide an initial value:
#State private var isPlaying: Bool = false
From my understanding, if you force to initialise the state in the view init, it will persist through the lifetime of the view, and subsequence change of it won't take any effect on the view.
The recommended way in Apple documentation is to create the struct in the parent view and pass it to the child view, and if you need to change the struct in the child view, use #Binding to allow read and write access.
If you want to ignore the documentation and force it to work, you can give an id to your DetailView, forcing it to refresh the view when the item id has changed:
var body: some View {
NavigationSplitView{
List(selection: $selectedItem) {
ForEach(items) { item in
Text(item.name)
.tag(item.id)
}
}
} detail: {
if let name = itemForID(selectedItem)?.name {
DetailView(name: name).id(selectedItem)
} else {
Text("Select an item")
}
}
}

Your Item struct is bad, if the name is unique it should be:
struct Item: Identifiable {
var id: String { name }
let name: String
}
Otherwise:
struct Item: Identifiable {
let id = UUID()
let name: String
}

Related

How to store selection of multiple Pickers?

I have 4 dogs - represented by 4 Pickers - and each one of them has a favourite treat. How can I store the selection of each Picker? I tried storing it in a dictionary, but when clicking on one of the treats nothing gets selected and the Picker view also does not get dismissed.
import SwiftUI
import OrderedCollections
enum Dog : String, CaseIterable, Identifiable {
var id: Self { self }
case Anton, Ben, Charlie, Didi
}
struct MainConstants {
let treats : OrderedDictionary <String, Int> = [
"Bone" : 123,
"Sausage" : 456,
"Cookies" : 789
]
}
struct FavouriteTreatsView: View {
let constants = MainConstants()
#State var favouriteTreats : [Dog : String] = [:]
var body: some View {
NavigationView {
Form {
ForEach (Dog.allCases) {dog in
Picker(dog.rawValue ,selection: $favouriteTreats[dog]) {
ForEach (constants.treats.keys, id: \.self) {treat in
Text(treat)
}
}
}
}
}
}
}
struct FavoriteTreatsView_Previews: PreviewProvider {
static var previews: some View {
FavouriteTreatsView()
}
}
I don't know about treating dogs, however the example below works well with lions...
First, you can have a dedicated view for each single picker - that view will store the treat of each lion on a #State var. You use the .id() modifier to tell the picker what value needs to be stored.
The view will also have a #Binding var that will receive the dictionary, to store the treat selected on the right lion.
In that picker view, you listen to changes in the treat: when a treat is selected, you store that value in the dictionary passed with the binding var.
So, assuming they are lions, here's the picker view:
struct TreatMe: View {
let constants = MainConstants()
// treat for a single Lion
#State private var treat = ""
// The dictionary that will be updated after the treat is selected
#Binding var favouriteTreats: [Lion: String]
// the Lion that will receive the treat
let lion: Lion
var body: some View {
Picker(lion.rawValue ,selection: $treat) {
ForEach (constants.treats.keys, id: \.self) {treat in
Text(treat)
// This will ensure the right value is stored
.id(treat)
}
}
// Listen to changes in the treat, then store it in the dictionary
.onChange(of: treat) { value in
favouriteTreats[lion] = value
}
}
}
You call it from the FavouriteTreatsView:
struct FavouriteTreatsView: View {
#State var favouriteTreats : [Lion : String] = [:]
var body: some View {
NavigationView {
Form {
ForEach (Lion.allCases) {lion in
// Pass the variables
TreatMe(favouriteTreats: $favouriteTreats, lion: lion)
}
}
}
}
}

SwiftUI Navigation popping back when modifying list binding property in a pushed view

When I update a binding property from an array in a pushed view 2+ layers down, the navigation pops back instantly after a change to the property.
Xcode 13.3 beta, iOS 15.
I created a simple demo and code is below.
Shopping Lists
List Edit
List section Edit
Updating the list title (one view deep) is fine, navigation stack stays same, and changes are published if I return. But when adjusting a section title (two deep) the navigation pops back as soon as I make a single change to the property.
I have a feeling I'm missing basic fundamentals here, and I have a feeling it must be related to the lists id? but I'm struggling to figure it out or work around it.
GIF
Code:
Models:
struct ShoppingList {
let id: String = UUID().uuidString
var title: String
var sections: [ShoppingListSection]
}
struct ShoppingListSection {
let id: String = UUID().uuidString
var title: String
}
View Model:
final class ShoppingListsViewModel: ObservableObject {
#Published var shoppingLists: [ShoppingList] = [
.init(
title: "Shopping List 01",
sections: [
.init(title: "Fresh food")
]
)
]
}
Content View:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}
}
}
ShoppingListsView
struct ShoppingListsView: View {
#StateObject private var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
ShoppingListEditView
struct ShoppingListEditView: View {
#Binding var shoppingList: ShoppingList
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $shoppingList.title)
}
Section(header: Text("Sections")) {
List($shoppingList.sections, id: \.id) { $section in
NavigationLink(destination: ShoppingListSectionEditView(section: $section)) {
Text(section.title)
}
}
}
}
.navigationBarTitle("Edit list")
}
}
ShoppingListSectionEditView
struct ShoppingListSectionEditView: View {
#Binding var section: ShoppingListSection
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("title", text: $section.title)
}
}
.navigationBarTitle("Edit section")
}
}
try this, works for me:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}.navigationViewStyle(.stack) // <--- here
}
}
Try to make you object confirm to Identifiable and return value which unique and stable, for your case is ShoppingList.
Detail view seems will pop when object id changed.
The reason your stack is popping back to the root ShoppingListsView is that the change in the list is published and the root ShoppingListsView is registered to listen for updates to the #StateObject.
Therefore, any change to the list is listened to by ShoppingListsView, causing that view to be re-rendered and for all new views on the stack to be popped in order to render the root ShoppingListsView, which is listening for updates on the #StateObject.
The solution to this is to change the #StateObject to #EnvironmentObject
Please refactor your code to change ShoppingListsViewModel to use an #EnvironmentObject wrapper instead of a #StateObject wrapper
You may pass the environment object in to all your child views and also add a boolean #Published flag to track any updates to the data.
Then your ShoppingListView would look as below
struct ShoppingListsView: View {
#EnvironmentObject var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
Don't forget to pass the viewModel in to all your child views.
That should fix your problem.

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
}
}

Whats the best way to get SwiftUI Picker() selection to update on app state change?

I'm trying to make a UI with SwiftUI that lists a collection of things that can be of different kinds and can each be updated. I'd like to make the type settable in the UI with a Picker, and I also want the view to update when the item is modified some other way (say, from another part of the UI, or over the network.)
I like a "redux"-style setup, and I don't want to jettison that.
Here's a simple example that shows two items, each with a "randomize" button that changes the item at random and a Picker that lets you choose the new item type. The latter works as expected: the Picker changes the #State var, the store gets updated, etc. The 'randomize' button updates the store and the let property label, but the #State and the Picker don't update.
I would love some advice on good ways to get this to work the way I want.
import SwiftUI
import Combine
#main
struct PuffedWastApp: App {
var store = Store()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(store)
}
}
}
enum ItemState:String, CaseIterable {
case apple
case bannana
case coconut
}
enum Action {
case set(Int,ItemState)
}
class Store: ObservableObject {
#Published var state:[ItemState] = [.apple, .apple]
func reduce(_ action:Action) {
print("do an action")
switch (action) {
case let .set(index,new_state):
print("set \(index) to \(new_state)")
state[index] = new_state
self.objectWillChange.send()
}
}
}
struct ContentView: View {
#EnvironmentObject var store: Store
var body: some View {
VStack {
ForEach(store.state.indices) { index in
ItemContainer(index: index)
//Text("\(index)")
}
}
.padding()
}
}
struct ItemContainer: View {
#EnvironmentObject var store: Store
let index: Int
var body: some View {
ItemView(
index: index,
label: store.state[index], // let property: updates on change in the Store
localLabel: store.state[index], //#State variable; doesn't update on change in the Store
dispatch: store.reduce
)
.padding()
}
}
struct ItemView: View {
let index: Int
let label: ItemState
#State var localLabel: ItemState
let dispatch: (Action)->()
var body: some View {
HStack{
//Text("\(index)")
Text(label.rawValue)
Text(localLabel.rawValue)
Button("Randomize") { dispatch( .set(index, ItemState.allCases.randomElement() ?? .apple ) ) }
Picker("Item type", selection: $localLabel ) {
ForEach( ItemState.allCases , id: \.self ) {
Text($0.rawValue).tag($0)
}
}.onChange(of: localLabel) { dispatch(.set(index, $0)) }
}
.padding()
}
}
Try changing this line:
#State var localLabel: ItemState
to
#Binding var localLabel: ItemState
and pass it in your ItemView init as:
ItemView(
index: index,
label: store.state[index],
localLabel: $store.state[index],
dispatch: store.reduce
)

Changes to array objects not saving to main ObservableObject

I'm starting with SwiftUI and I'm running into a roadblock with array items of an ObservableObject not saving to the main object.
Main object:
class Batch: Codable, Identifiable, ObservableObject {
let id: String
var items = [Item]()
}
Item object:
class Item: Codable, Identifiable, ObservableObject {
let id: String
var description: String
}
I have a BatchView which I pass a batch into:
struct BatchView: View {
#ObservedObject var batch: Batch
var body: some View {
List {
ForEach(batch.items) { item in
ItemView(item: item)
}
}
.navigationBarTitle(batch.items.reduce("", { $0 + $1.description }))
}
}
In the ItemView I change the description:
struct ItemView: View {
#ObservedObject var item: Item
#State private var descr = ""
var body: some View {
VStack(alignment: .leading) {
Text("MANUFACTURED")
TextField("", text: $descr) {
self.updateDescr(descr: self.descr)
}
}
}
private func updateDescr(descr: String) {
item.description = descr
}
}
But when I update the description for a batch item, the title of BatchView doesn't change, so the changes to the Item isn't coming back to the root Batch.
How do I make the above work?
This answer helped me. I had to explicitly add #Published in front of the variable I was changing.