ObservableObject is updating all views and causing menus to close in SwiftUI - ios

I have this class which calls an API every 3 seconds (using a timer) and saves the result in the #Published array named items:
public class ApiAdapter: ObservableObject {
#Published var items = [Item]()
init() {
api = Api(...)
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { timer in
api.getItems() { items in
if let _items = items {
self.items = _items
}
}
}
}
}
The ApiAdapter is used throughout the entire app so I decided to make it an #EnvironmentObject.
In the main view of my app I'm accessing the apiAdapter.items array for displaying it in a list like so:
struct MainListView: View {
#EnvironmentObject var apiAdapter: ApiAdapter
var body: some View {
NavigationView {
List {
ForEach(apiAdapter.items, id: \.hash) { i in
Text(i.name)
...
}
}
.navigationBarItems(trailing:
Menu {
Picker(selection: $category, label: Text("Filter")) {
Text("Category 1").tag(1)
Text("Category 2").tag(2)
Text("Category 3").tag(3)
}
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.imageScale(.large)
}
}
}
}
In MainListView I have also some Menus in the navigationBar which get closed every time the apiAdapter updates the items variable, so it makes it hard to use them.
How is it possible to update only the list and not the entire view? or even better how is it possible to update only the items that changed in the last api call?

Remove the EnvironmentObject from your MainListView. Create a new View for the List only like this:
struct ListView : View {
#EnvironmentObject var apiAdapter: ApiAdapter
var body : some View {
List {
ForEach(apiAdapter.items, id: \.hash) { i in
Text(i.name)
}
}
}
}
Now your subview only updates, while your main view remains the same and the Menu remains opened.
And your MainListView as follows:
struct MainListView: View {
var body: some View {
NavigationView {
ListView() //<< here comes your ListView which refreshes independent
.navigationBarItems(trailing:

Related

SwiftUI List Row Context Menu Label Based on Index Array Property

Searched around and have not found an answer. Believe I know what the issue is, but not sure how to resolve it.
I have a swiftUI list that displays a context menu when a certain type of row is selected(not all rows qualify, this works as it should. When the context menu is displayed, the label is generated by the index of the array populating the lists object property.
The context menu selection performs performs a task that should result in the context menu label changing. And sometimes it works, other times it does not. This is resolved by scrolling that particular row off screen and scrolling back too it (has to be far enough away). The Object array is from a singleton data store passed as an environment object.
I believe this is related to the size of the array and the data being lazy loaded in swiftUI lists. I also would use the List selection property for this, but the context menu being populated by the row does not update the lists selected row.
A snippet example of my code is below.
#EnvironmentObject var singletonStore: MyObjectStore
#State private var selectedRow: Int?
var body: some View {
VStack {
List(singletonStore.myArray.indices, id: \.self, selection: $selectedRow) { index in
LazyVGrid(columns: gridColumns) {
ItemGridView(item: $singletonStore.myArray[index], opacityOffset: getRowOpacity(index: index))
}
.contextMenu {
if singletonStore.myArray[index].thisDate == nil {
if singletonStore.myArray[index].thisNeedsDone > 0 {
Button {
selectedRow = index
//these functions will add or remove a users id or initials to the appropriate property, and this updates values in my list view.
if singletonStore.myArray[index].id != nil {
//do this
} else {
//do that
}
} label: {
Label{
//This is where my issue is - even though the items in the list view are updating, the label of the context menu is not updating until the row is reloaded
Text(singletonStore.myArray[index].initials != nil ? "This Label" : "That Label") } icon: {
Image(systemName: "aqi.medium")
}
}
}
}
} //context menu close
} // list close
.listStyle(PlainListStyle())
}
}
My closures may be off, but its because I modified the code significantly on this platform to make is easier to follow. Nothing removed would affect this issue.
if there was a way to have opening the context menu update the lists selected row, that would solve the issue and I could use the selected row for the index of the singletonStores array objects property, but I may be approaching the problem from thinking the index is incorrect when the actual issue is the context menu is not being reloaded with the environment objects new information. Any help is always appreciated!
EDIT:
After some tinkering I further found that the issue must be related to the context menu itself not refreshing its data. I separated my views and used a #Viewbuilder function to return the needed view for the button - however it still does not refresh the context menus data.
EDIT 2:
currently (and subject to change) my SingletonStore class loads the data from another network class and publishes that data in the form of an array
final class SingletonStore: ObservableObject {
static private(set) var shared = singletonStore()
static func reset() {
shared = StagingStore()
}
#Published var myArray: [CustomObject] = []
private func getMyData() {
//uses other class and methods to retrieve and set data
//works and updates view on refresh
}
}
My View is called from a different View that is just a Tab bar controller, that code looks as follows:
struct ContainerView: View {
#StateObject var singletonStore = SingletonStore.shared
var body: some View {
TabView{
GenericView().environmentObject(singletonStore)
.tabItem {
Label("This View", systemImage: "camera.metering.matrix")
}
}
}
I have created a demo project inspired by your sample code above. In order to reproduce the issue I had to improvise some.
List is binded to a collection, when any of the item change, view hierarchy gets built and changes reflects.
Code for reference is as follow. Notice I am calling a view model method from button action, which makes a change in the collection that is binded.
import Foundation
class ContentViewModel: ObservableObject {
#Published var myArray: [Item] = []
init() {
for i in 0...100 {
let obj = Item(id: UUID().uuidString, thisDate: Date.now, thisNeedsDone: i, initials: "That Label")
myArray.append(obj)
}
}
func updateTheRow(item: Item) {
if let indexOfItem = myArray.firstIndex(where: { obj in
obj.id == item.id
})
{
myArray[indexOfItem] = item
}
}
}
struct Item: Identifiable, Equatable, Hashable {
var id: String
var thisDate: Date?
var thisNeedsDone: Int
var initials: String?
}
import SwiftUI
struct ContentView: View {
#StateObject var viewModel = ContentViewModel()
let columns = [
GridItem(.adaptive(minimum: 80))
]
var body: some View {
VStack {
List(viewModel.myArray, id: \.self) { item in
LazyVGrid(columns: columns) {
VStack{
Text("My bad")
}
}
.contextMenu {
if item.thisDate != nil {
if item.thisNeedsDone > 0 {
Button {
//these functions will add or remove a users id or initials to the appropriate property, and this updates values in my list view.
var modifiedItem = item
modifiedItem.initials = "Modified Label"
viewModel.updateTheRow(item: modifiedItem)
} label: {
Label{
//This is where my issue is - even though the items in the list view are updating, the label of the context menu is not updating until the row is reloaded
Text(item.initials!) } icon: {
Image(systemName: "aqi.medium")
}
}
}
}
} //context menu close
} // list close
.listStyle(PlainListStyle())
}
}
}

Is there a way to create objects in swiftUI view based on a value gathered from a previous view?

I have recently started my journey into iOS development learning swift and swift UI. I keep running into issues when it comes to app architecture. The problem i am trying to solve is this: Let's say I have an app where the user first selects a number and then presses next. The user selected number is supposed to represent the number of text fields that appear on the next view. For example, if the user selects 3 then 3 text fields will appear on the next view but if the user selects 5 then 5 texts fields will appear. Is the solution to just have a view for each case? Or is there some way to dynamically add objects to a view based on the user input. Can anyone explain how they would handle a case like this?
Views can get passed parameters (including in NavigationLink) that can determine what they look like. Here's a simple example with what you described:
struct ContentView : View {
#State var numberOfFields = 3
var body: some View {
NavigationView {
VStack {
Stepper(value: $numberOfFields, in: 1...5) {
Text("Number of fields: \(numberOfFields)")
}
NavigationLink(destination: DetailView(numberOfFields: numberOfFields)) {
Text("Navigate")
}
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct DetailView : View {
var numberOfFields : Int
var body: some View {
VStack {
ForEach(0..<numberOfFields) { index in
TextField("", text: .constant("Field \(index + 1)"))
}
}
}
}
Notice how numberOfFields is stored as #State in the parent view and then passed to the child view dynamically.
In general, it would probably be a good idea to visit some SwiftUI tutorials as this type of thing will be covered by most of them. Apple's official tutorials are here: https://developer.apple.com/tutorials/swiftui
Another very popular resource is Hacking With Swift: https://www.hackingwithswift.com/100/swiftui
Update, based on comments:
struct ContentView : View {
#State var numberOfFields = 3
var body: some View {
NavigationView {
VStack {
Stepper(value: $numberOfFields, in: 1...5) {
Text("Number of fields: \(numberOfFields)")
}
NavigationLink(destination: DetailView(textInputs: Array(repeating: "test", count: numberOfFields))) {
Text("Navigate")
}
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct Model : Identifiable {
var id = UUID()
var text : String
}
class ViewModel : ObservableObject {
#Published var strings : [Model] = []
}
struct DetailView : View {
var textInputs : [String]
#StateObject private var viewModel = ViewModel()
var body: some View {
VStack {
ForEach(Array(viewModel.strings.enumerated()), id: \.1.id) { (index,text) in
TextField("", text: $viewModel.strings[index].text)
}
}.onAppear {
viewModel.strings = textInputs.map { Model(text: $0) }
}
}
}

Complete list is recreates all views even if just one item has changed

I have a very simple schoolbook example of a SwiftUI List view that renders items from data in an array. Data in the array is Identifiable. But, when I change the the data in the array, add or remove a item then all rows in the list view are recreated. Is that correct? My understanding was that Identifiable should make sure that only the view in the list that are changed are recreated.
My list is inside a navigation view and each row links to a detail view. The problem is that since all items in the list are removed and recreated every time the data is changed then if that that happens when Im in a detail view (it's triggered by a notification) then Im thrown out back to the list.
What am I missing?
Edit: Added code example
This is my data struct:
struct Item: Identifiable {
let id: UUID
let name: String
init(name: String) {
self.id = UUID()
self.name = name
}
}
This is my ItemView
struct ItemView: View {
var item: Item
init(item: Item) {
self.item = item
print("ItemView created \(self.item.id)")
}
var body: some View {
Text(self.item.name)
}
}
An finally my list view:
struct KeyList: View {
#State var items = [Item(name: "123"), Item(name: "456"), Item(name: "789")]
var body: some View {
VStack {
List(self.items) { item in
ItemView(item: item)
}
Button(action: {
self.items.append(Item(name: "New"))
}) {
Text("Add")
}
}
}
}
When I press add it will print "ItemView created" 4 times. My understanding is that it should only do it 1 time?
Here is an example of how this could work. Tested and working on iOS 13.5
The List doesn't get recreated again when only one item is being removed. So this was accomplished.
About the poping of the View this has already been answered here:
SwiftUI ForEach refresh makes view pop
I have here a small workaround for this problem. Add the items you want to remove to an array. Then when going back, remove these items (Which will make the view pop) or go back programmatically and nothing gets removed
struct ContentView: View {
#State var text:Array<String> = ["a", "b", "c"]
var body: some View {
NavigationView() {
VStack() {
List() {
ForEach(self.text, id: \.self){ item in
NavigationLink(destination: SecondView(textItem: item, text: self.$text)) {
Text(item)
}
}
}
Button(action: {
self.text.remove(at: 0)
}){
Text("Remove \(self.text[0])")
}
}
}
}
}
struct SecondView: View {
#State var textItem: String
#Binding var text: Array<String>
#State var tmpArray: Array<String> = []
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack() {
Text(self.textItem)
Button(action: {
//Append to a tmp array which will later be used to determine what to remove
self.tmpArray.append(self.text[0])
}){
Text("Remove \(self.text[0])")
}
Button(action: {
if self.tmpArray.count > 0 {
//remove your stuff which will automatically pop the view
self.text.remove(at: 0)
} else {
// programmatically go back as nothing has been deleted
self.presentationMode.wrappedValue.dismiss()
}
}){
Text("Go Back")
}
}
}
}

Can’t pass data correctly to modal presentation using ForEach and CoreData in SwiftUI

Im trying to pass data of some object from list of objects to modal sheet, fetching data from CoreData.
The problem is that no matter what object I click on in the list, only the data form last added object appears in the details view.
The same goes for deleting the object - no matter what object I'm trying to delete, the last one is deleted all the time.
Problem disappears using NavigationLink, but it is not suitable for me.
Here is my code:
import SwiftUI
struct CarScrollView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Cars.entity(), sortDescriptors: []) var cars: FetchedResults<Cars>
#State var showDetails = false
var body: some View {
VStack {
ScrollView (.vertical, showsIndicators: false) {
ForEach (cars, id: \.self) { car in
Text("\(car.text!)")
.onTapGesture {
self.showDetails.toggle()
}
.sheet(isPresented: self.$showDetails) { CarDetail(id: car.id, text: car.text)
}
}
}
}
}
}
There should be only one sheet in view stack, so just move it out of ForEach, like below
struct CarScrollView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Cars.entity(), sortDescriptors: []) var cars: FetchedResults<Cars>
#State private var selectedCar: Car? = nil
var body: some View {
VStack {
ScrollView (.vertical, showsIndicators: false) {
ForEach (cars, id: \.self) { car in
Text("\(car.text!)")
.onTapGesture {
self.selectedCar = car
}
}
}
}
.sheet(item: self.$selectedCar) { car in
CarDetail(id: car.id, text: car.text)
}
}
}

SwiftUI List Rows Not Refreshed After Updating Binding In Other Screen

I have a simple watchOS SwiftUI Application. The application has three screens. The first screen consists of a List of items. When you press that item, it will redirect to another screen & when you tap a button there it will open up a .sheet View which allows you to edit the item in the list.
The first view looks like this:
class Object: NSObject {
var title: String
init(title: String) {
self.title = title
}
}
struct Row: View {
#Binding var object: Object
var body: some View {
Text(self.object.title)
}
}
struct ContentView: View {
#State private var objects = [Object]()
var body: some View {
NavigationView {
List {
ForEach(objects.indices, id: \.self) { idx in
NavigationLink(destination: SecondView(object: self.$objects[idx])) {
Row(object: self.$objects[idx])
}
}
}
}
.onAppear {
self.objects = [
Object(title: "Test 1"),
Object(title: "Test 2")
]
}
}
}
These are the second & third views:
struct SecondView: View {
#Binding var object: Object
#State private var showPicker: Bool = false
var body: some View {
VStack {
Text(object.title)
Button(action: {
self.showPicker.toggle()
}) {
Text("Press Here")
}
}
.sheet(isPresented: $showPicker) {
ThirdView(object: self.$object)
}
}
}
struct ThirdView: View {
#Binding var object: Object
var body: some View {
VStack {
Text(object.title)
Button(action: {
self.update()
}, label: {
Text("Tap here")
})
}
}
func update() {
let newObj = self.object
newObj.title = "Hello, World!"
self.object = newObj
}
}
I'd expect, whenever I tap the button in the third view, the Binding (and thus the State) get's updated with "Hello, World". However, that is not the case, although not immediately.
What I currently see happening is that when I tap the button in the third view, the Text in that view does not get updated. When I dismiss the third view and go back to the second view, I do see "Hello, World". But when I go back to the list, the row still has the old value.
One other thing I noticed is that, when I fill the array of objects directly, like so:
#State private var objects = [Object(title: "Test 1"), Object(title: "Test 2")]
and remove the filling of the array in .onAppear, this work totally how I'd expect it to (everything updates immediately to "Hello, World".
Does anyone one know what I'm doing wrong here or did I might hit a bug?
Thanks!
Complex objects need to be classes conforming to #ObservableObject.
Observed ivars need to be published.
class Object: ObservableObject {
#Published var title: String
[...]
}
Observing views would use them as #ObservedObject
struct Row: View {
#ObservedObject var object: Object
[...]
}
You might have to create an object wrapper for your lists
class ObjectList: ObservableObject {
#Published var objects: [Object]
[...]
}

Resources