how to use a #EnvironmentObject in combination with a List - swiftui

The code for the basic app from Anlil's answer works fine. If I edit the datamodel to be more like mine, with a multidimensional String array, I get something like:
import SwiftUI
import Combine
struct ContentView: View {
#EnvironmentObject var dm: DataManager
var body: some View {
NavigationView {
List {
NavigationLink(destination:AddView().environmentObject(self.dm)) {
Image(systemName: "plus.circle.fill").font(.system(size: 30))
}
ForEach(dm.array, id: \.self) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item[0])
}
}
}
}
}
}
struct DetailView: View {
var item : [String] = ["", "", ""]
var body: some View {
VStack {
Text(item[0])
Text(item[1])
Text(item[2])
}
}
}
struct AddView: View {
#EnvironmentObject var dm: DataManager
#State var item0 : String = "" // needed by TextField
#State var item1 : String = "" // needed by TextField
#State var item2 : String = "" // needed by TextField
#State var item : [String] = ["", "", ""]
var body: some View {
VStack {
TextField("Write something", text: $item0)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
TextField("Write something", text: $item1)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
TextField("Write something", text: $item2)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
Button(action: {
self.item = [self.item0, self.item1, self.item2]
print(self.item)
self.dm.array.append(self.item)
}) {
Text("Save")
}
}
}
}
class DataManager: BindableObject {
var willChange = PassthroughSubject<Void, Never>()
var array : [[String]] = [["Item 1","Item 2","Item 3"],["Item 4","Item 5","Item 6"],["Item 7","Item 8","Item 9"]] {
didSet {
willChange.send()
}
}
}
There are no errors and the code runs as expected. Before I'm going to rewrite my own code (with the lessons I've learned solar) it would be nice if the code could be checked.
I'm really impressed with SwiftUI!

If your "source of truth" is an array of some "model instances", and you just need to read values, you can pass those instance around like before:
import SwiftUI
import Combine
struct ContentView: View {
#EnvironmentObject var dm: DataManager
var body: some View {
NavigationView {
List(dm.array, id: \.self) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item)
}
}
}
}
}
struct DetailView: View {
var item : String
var body: some View {
Text(item)
}
}
class DataManager: BindableObject {
var willChange = PassthroughSubject<Void, Never>()
let array = ["Item 1", "Item 2", "Item 3"]
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(DataManager())
}
}
#endif
You need to pass the EnvironmentObject only if some views are able to manipulate the data inside the instances... in this case you can easily update the EnvironmentObject's status and everything will auto-magically updated everywhere!
The code below shows a basic App with "list", "detail" and "add", so you can see 'environment' in action (the only caveat is that you have to manually tap < Back after tapped the Save button). Try it and you'll see the list that will magically update.
import SwiftUI
import Combine
struct ContentView: View {
#EnvironmentObject var dm: DataManager
var body: some View {
NavigationView {
List {
NavigationLink(destination:AddView().environmentObject(self.dm)) {
Image(systemName: "plus.circle.fill").font(.system(size: 30))
}
ForEach(dm.array, id: \.self) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item)
}
}
}
}
}
}
struct DetailView: View {
var item : String
var body: some View {
Text(item)
}
}
struct AddView: View {
#EnvironmentObject var dm: DataManager
#State var item : String = "" // needed by TextField
var body: some View {
VStack {
TextField("Write something", text: $item)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
Button(action: {
self.dm.array.append(self.item)
}) {
Text("Save")
}
}
}
}
class DataManager: BindableObject {
var willChange = PassthroughSubject<Void, Never>()
var array : [String] = ["Item 1", "Item 2", "Item 3"] {
didSet {
willChange.send()
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(DataManager())
}
}
#endif

Related

#State var with array does not update view when grandchild updates it

I have 3 views. The issue I have is that the 1st one (a parent view of 2) is not changing when the 3rd one (a children of view 2) has updated a property in the array.
Let's use some code so it is easier to understand:
public struct Item {
public let id: String
public var name: String?
public var inStock: Bool
import SwiftUI
#main
struct ThisIsMessedUpApp: App {
var body: some Scene {
WindowGroup {
ItemsMainView()
}
}
}
import Foundation
import SwiftUI
struct ItemsMainView: View {
#State var items = [Item]()
var body: some View {
VStack {
Text("Item count is \(items.count)")
Divider()
VStack {
Text("ItemsMainView has:")
HStack {
ForEach(self.items, id: \.id) { item in
Text(item.name ?? "nothing found")
Spacer()
Text(item.inStock.description)
}
}
}
ItemsView(items: $items)
}
}
}
struct ItemsView: View {
#Binding var items: [Item]
var body: some View {
Button("Add new item (call made from ItemsView", action: {
self.items.append(Item(id: UUID().uuidString,
name: "Test #1",
inStock: false))
})
VStack {
ForEach($items, id: \.id) { $item in
ItemView(item: $item)
}
}
}
}
struct ItemView: View {
#Binding var item: Item
#State var draftItemName: String = ""
var body: some View {
Text("ItemView has")
HStack {
TextField("TextField", text: $draftItemName)
.onSubmit {
item.name = draftItemName
}
Spacer()
Text(item.inStock.description)
}
.onAppear {
draftItemName = item.name ?? ""
}
}
}
Some of the Text are for debugging purposes.
If you run this code and change the second TextField's value to, say, "Test #2", you will see that you end up with an inconsistent UI state: ItemsMainView has "Test #1", whereas ItemView has "Test #2"
In Xcode 14.2, create a new project for ios or macos. Copy and paste the following code, replacing
the original ContentView.
Tell us if this code, tested on real ios 16.3 devices (not Previews), macCatalyst and MacOS 13.2 only, works for you.
struct ContentView: View {
var body: some View {
ItemsMainView()
}
}
public struct Item {
public let id: String
public var name: String?
public var inStock: Bool
}
struct ItemsMainView: View {
#State var items = [Item]()
var body: some View {
VStack {
Text("Item count is \(items.count)")
Divider()
VStack {
Text("ItemsMainView has:")
ForEach(items, id: \.id) { item in
HStack {
Text(item.name ?? "nothing found").foregroundColor(.red)
Spacer()
Text(item.inStock.description).foregroundColor(.red)
}
}
}
ItemsView(items: $items)
}
}
}
struct ItemsView: View {
#Binding var items: [Item]
var body: some View {
Button("Add new item (call made from ItemsView", action: {
self.items.append(Item(id: UUID().uuidString, name: "Test #1", inStock: false))
}).buttonStyle(.bordered)
VStack {
ForEach($items, id: \.id) { $item in
ItemView(item: $item)
}
}
}
}
struct ItemView: View {
#Binding var item: Item
#State var draftItemName: String = ""
var body: some View {
Text("ItemView has")
HStack {
TextField("TextField", text: $draftItemName)
.onSubmit {
item.name = draftItemName
}
Spacer()
Text(item.inStock.description)
}
.onAppear {
draftItemName = item.name ?? ""
}
}
}

#EnvironmentObject bug in SwiftUI

When I press the Move button in the contextMenu, I change the isCopied and setOriginPath variables in the EnvironmentObject. When this change is made, the List view is cleared and I can't see anything on the screen. I don't have any problems when I don't use EnvironmentObject.
ContextMenu:
.contextMenu {
Button {
safeFileVM.hideSelectedFile(fileName: currentFile.fileName)
safeFileVM.takeArrayOfItems()
} label: {
HStack {
Text(!currentFile.isLock ? "Hide" : "Show")
Image(systemName: currentFile.isLock ? "eye" : "eye.slash")
}
}
Button {
safeFileClipboard.setOriginPath = URL(fileURLWithPath: currentFile.localPath)
safeFileClipboard.isCopied = true
} label: {
HStack {
Text("Move")
Image(systemName: "arrow.up.doc")
}
}
}
View:
struct DetailObjectView: View {
#ObservedObject var safeFileVM: SafeFileViewModel = SafeFileViewModel()
#EnvironmentObject var safeFileClipboard: SafeFileClipBoard
var currentFile: MyFile
var currentLocation = ""
var body: some View {
VStack {
.....
}
.contextMenu {
Button {
safeFileVM.hideSelectedFile(fileName: currentFile.fileName)
safeFileVM.takeArrayOfItems()
} label: {
HStack {
Text(!currentFile.isLock ? "Hide" : "Show")
Image(systemName: currentFile.isLock ? "eye" : "eye.slash")
}
}
Button {
safeFileClipboard.setOriginPath = URL(fileURLWithPath: currentFile.localPath)
safeFileClipboard.isCopied = true
} label: {
HStack {
Text("Move")
Image(systemName: "arrow.up.doc")
}
}
}
}
}
In the mini project below, when the EnvironmentObject value changes, navigation goes to the beginning. Why ? How can I fix this ?
Example Project:
Main:
#main
struct EnvironmentTestApp: App {
#StateObject var fooConfig: FooConfig = FooConfig()
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
.environmentObject(fooConfig)
}
}
}
}
ContentView:
struct ContentView: View {
#EnvironmentObject var fooConfig: FooConfig
private let numbers: [Number] = [.init(item: "1"), .init(item: "2"), .init(item: "3")]
var body: some View {
List {
ForEach(numbers, id: \.id) { item in
DetailView(itemNumber: item.item)
}
}
}
}
struct Number: Identifiable {
var id = UUID()
var item: String
}
DetailView:
struct DetailView: View {
#EnvironmentObject var fooConfig: FooConfig
var itemNumber: String = ""
var body: some View {
NavigationLink(destination: ContentView().environmentObject(fooConfig)) {
Text("\(itemNumber) - \(fooConfig.fooBool == true ? "On" : "Off")")
.environmentObject(fooConfig)
.contextMenu {
Button {
fooConfig.fooBool.toggle()
} label: {
HStack {
Text(fooConfig.fooBool != true ? "On" : "Off")
}
}
}
}
}
}
ObservableObject:
class FooConfig: ObservableObject {
#Published var fooBool: Bool = false
}
Move that from scene into ContentView, because scene is a bad place to update view hierarchy, it is better to do inside view hierarchy, so here
struct EnvironmentTestApp: App {
var body: some Scene {
WindowGroup {
ContentView() // only root view here !!
}
}
}
everything else is inside views, like
struct ContentView: View {
#StateObject private var foo = FooConfig()
var body: some View {
NavigationView {
MainView()
.environmentObject(foo) // << here !!
}
}
}
struct MainView: View {
#EnvironmentObject var fooConfig: FooConfig
private let numbers: [Number] = [.init(item: "1"), .init(item: "2"), .init(item: "3")]
var body: some View {
List {
ForEach(numbers, id: \.id) { item in
DetailView(itemNumber: item.item)
}
}
}
}
and so on...
Tested with Xcode 14 / iOS 16

NavigationLink dismisses after TextField changes

I have a navigation stack that's not quite working as desired.
From my main view, I want to switch over to a list view which for the sake of this example represents an array of strings.
I want to then navigate to a detail view, where I want to be able to change the value of the selected string.
I have 2 issues with below code:
on the very first keystroke within the TextField, the detail view is being dismissed
the value itself is not being changed
Also, I suppose there must be a more convenient way to do the binding in the detail view ...
Here's the code:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
TestMainView()
}
}
}
struct TestMainView: View {
var body: some View {
NavigationView {
List {
NavigationLink("List View", destination: TestListView())
}
.navigationTitle("Test App")
}
}
}
struct TestListView: View {
#State var strings = [
"Foo",
"Bar",
"Buzz"
]
#State var selectedString: String? = nil
var body: some View {
List(strings.indices) { index in
NavigationLink(
destination: TestDetailView(selectedString: $selectedString),
tag: strings[index],
selection: $selectedString) {
Text(strings[index])
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("List")
}
}
}
struct TestDetailView: View {
#Binding var selectedString: String?
var body: some View {
VStack {
if let _ = selectedString {
TextField("Placeholder",
text: Binding<String>( //what's a better solution here?
get: { selectedString! },
set: { selectedString = $0 }
)
)
.padding()
.textFieldStyle(RoundedBorderTextFieldStyle())
}
Spacer()
}
.navigationTitle("Detail")
}
}
struct TestMainView_Previews: PreviewProvider {
static var previews: some View {
TestMainView()
}
}
I am quite obviously doing it wrong, but I cannot figure out what to do differently...
You're changing the NavigationLink's selection from inside the NavigationLink which forces the TestListView to reload.
You can try the following instead:
struct TestListView: View {
#State var strings = [
"Foo",
"Bar",
"Buzz",
]
var body: some View {
List(strings.indices) { index in
NavigationLink(destination: TestDetailView(selectedString: self.$strings[index])) {
Text(self.strings[index])
}
}
}
}
struct TestDetailView: View {
#Binding var selectedString: String // remove optional
var body: some View {
VStack {
TextField("Placeholder", text: $selectedString)
.padding()
.textFieldStyle(RoundedBorderTextFieldStyle())
Spacer()
}
}
}

list not updating after texts change

i am sure i am making something wrong, but i don't know what.
I have a small list and i update texts with a timer which i can see in the debugger which updates. But the list won't be updated...
Thank you for help.
struct ListTest: View {
#State var texts : [String]
var body: some View {
List {
ForEach(self.texts, id: \.self) { text in
Text(text)
}
}
}
}
struct ContentView: View {
#State var texts = ["a"]
var body: some View {
ListTest(texts: self.texts)
.onAppear() {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { (timer) in
self.texts.append("\(self.texts.count)")
print(self.texts)
}
}
}
}
remove #State property wrapper from your ListTest
struct ListTest: View {
var texts : [String]
var body: some View {
List {
ForEach(self.texts, id: \.self) { text in
Text(text)
}
}
}
}
Finally, think about more "combine compatible" code
import SwiftUI
struct ListTest: View {
var texts: [String]
var body: some View {
List {
ForEach(self.texts, id: \.self) { text in
Text(text)
}
}
}
}
struct ContentView: View {
#State var texts = ["a"]
let tp = Timer.publish(every: 0.5, on: .main, in: .default).autoconnect()
var body: some View {
ListTest(texts: self.texts)
.onReceive(tp) { (date) in
self.texts.append("\(self.texts.count)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
which is functionally equal

Save selected item in List

This looks like a very simple thing, but I can't figure out how to do this:
I have a List embedded in a NavigationView, containing a NavigationLink to view the detail of the item.
I have a save bar button where I would like to save the selected item. But how can I access the selected item?
It isn't visible in the button's action closure.
struct ItemList : View {
#EnvironmentObject var items: ItemsModel
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: ItemDetail(item: item)) {
Text(item.name)
}
}
.navigationBarTitle(Text("Item"))
.navigationBarItems(trailing: Button(action: {
self.save(/*item: item */) // How can I access item here?
}, label: {
Text("Save")
}))
}
}
func save(item: Item) {
print("Saving...")
}
}
Navigation links are not obligatory to accomplish this.
import SwiftUI
struct ContentView: View {
struct Ocean: Identifiable, Hashable {
let name: String
var id: Self { self }
}
private var oceans = [
Ocean(name: "Pacific"),
Ocean(name: "Atlantic"),
Ocean(name: "Indian"),
Ocean(name: "Southern"),
Ocean(name: "Arctic")
]
#State private var selectedOceans = [Ocean]()
#State private var multiSelection = Set<Ocean.ID>()
var body: some View {
VStack {
Text("Oceans")
List(oceans, selection: $multiSelection) {
Text($0.name)
}
.navigationTitle("Oceans")
.environment(\.editMode, .constant(.active))
.onTapGesture {
// Walkaround: try how it works without `asyncAfter()`
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
selectedOceans = Array(multiSelection)
print(selectedOceans)
})
}
Divider()
Text("Selected oceans")
List(selectedOceans, selection: $multiSelection) {
Text($0.name)
}
}
Text("\(multiSelection.count) selections")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}