2

Sorry to make this post so long, but in hindsight I should have shown you the simpler instance of the issue so you could better understand what the problem is. I am assuming the same issue with ForEach is at the root cause of both of these bugs, but I could be wrong. The second instance is still included to give you context, but the first intance should be all you need to fully understand the issue.

First Instance:

Here is a video of the issue: https://i.stack.imgur.com/yBtcO.jpg. As you can see, there are 4 Time Codes, 2 of which are favorite and 2 are not favorites (shown by the yellow star). Additionally, there is text at the top that represents the array of Time Codes being displayed just as a list of favorite (F) or not favorite (N). I click on the last Time Code (Changing to favorite) and press the toggle to unfavorite it. When I hit save, the array of Time Codes is updated, yet as you see, this is not represented in the List. However, you see that the Text of the reduced array immediately updates to FNFF, showing that it is properly updated as a favorite by the ObservedObject.

When I click back on the navigation and back to the page, the UI is properly updated and there are 3 yellow stars. This makes me assume that the problem is with ForEach, as the Text() shows the array is updated but the ForEach does not. Presumably, clicking out of the page reloads the ForEach, which is why it updates after exiting the page. EditCodeView() handles the saving of the TimeCodeVieModel in CoreData, and I am 99% certain that it works properly through my own testing and the fact that the ObservedObject updates as expected. I am pretty sure I am using the dynamic version of ForEach (since TimeCodeViewModel is Identifiable), so I don't know how to make the behavior update immediately after saving. Any help would be appreciated.

Here is the code for the view:

struct ListTimeCodeView: View {
    
    @ObservedObject var timeCodeListVM: TimeCodeListViewModel
    @State var presentEditTimeCode: Bool = false
    @State var timeCodeEdit: TimeCodeViewModel?

    init() {
        self.timeCodeListVM = TimeCodeListViewModel()
    }

    var body: some View {
        VStack {
            HStack {
                Text("TimeCodes Reduced by Favorite:")
                Text("\(self.timeCodeListVM.timeCodes.reduce(into: "") {$0 += $1.isFavorite ? "F" : "N"})")
            }

            List {
                ForEach(self.timeCodeListVM.timeCodes) { timeCode in
                        
                   TimeCodeDetailsCell(fullName: timeCode.fullName, abbreviation: timeCode.abbreviation, color: timeCode.color, isFavorite: timeCode.isFavorite, presentEditTimeCode: $presentEditTimeCode)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            timeCodeEdit = timeCode
                                
                        }
                        .sheet(item: $timeCodeEdit, onDismiss: didDismiss) { detail in
                            EditCodeView(timeCodeEdit: detail)   
                        }
                }    
            }
        }
    }      
}

Here is the code for the View Models (shouldn't be relevant to the problem, but included for understanding):

class TimeCodeListViewModel: ObservableObject {
    
    @Published var timeCodes = [TimeCodeViewModel]()
    
    init() {
        fetchAllTimeCodes()
    }

    func fetchAllTimeCodes() {
        self.timeCodes = CoreDataManager.shared.getAllTimeCodes().map(TimeCodeViewModel.init)
    } 
}


class TimeCodeViewModel: Identifiable {
    var id: String = ""
    var fullName = ""
    var abbreviation = ""
    var color = ""
    var isFavorite = false
    var tags = ""

    
    init(timeCode: TimeCode) {
        self.id = timeCode.id!.uuidString
        self.fullName = timeCode.fullName!
        self.abbreviation = timeCode.abbreviation!
        self.color = timeCode.color!
        self.isFavorite = timeCode.isFavorite
        self.tags = timeCode.tags!
    }
}

Second Instance:

EDIT: I realize it may be difficult to understand what the code is doing, so I have included a gif demoing the problem (unfortunately I am not high enough reputation for it to be shown automatically). As you can see, I select the cells I want to change, then press the button to assign that TimeCode to it. The array of TimeCodeCellViewModels changes in the background, but you don't actually see that change until I press the home button and then reopen the app, which triggers a refresh of ForEach. Gif of issue. There is also this video if the GIF is too fast: https://i.stack.imgur.com/N6cFn.jpg

I am trying to display a grid view using a VStack of HStacks, and am running into an issue where the ForEach I am using to display the content is not refreshing when the array being passed in changes. I know the array itself is changing because if I reduce it to a string and display the contents with Text(), it properly updates as soon as a change is made. But, the ForEach loop only updates if I close and reopen the app, forcing the ForEach to reload. I know that there is a special version of ForEach that is specifically designed for dynamic content, but I am pretty sure I am using this version since I pass in '''id: .self'''. Here is the main code snippet:

var hoursTimeCode: [[TimeCodeCellViewModel]] = []

// initialize hoursTimeCode

VStack(spacing: 3) {
   ForEach(self.hoursTimeCode, id: \.self) {row in
      HStack(spacing: 3){
         HourTimeCodeCell(date: row[0].date) // cell view for hour
            .frame(minWidth: 50)
         ForEach(row.indices, id: \.self) {cell in
            // TimeCodeBlockCell displays minutes normally. If it is selected, and a button is pressed, it is assigned a TimeCode which it will then display
            TimeCodeBlockCell(timeCodeCellVM: row[cell], selectedArray: $selectedTimeCodeCells)
               .frame(maxWidth: .infinity)
               .aspectRatio(1.0, contentMode: .fill)
         }
      }                              
   }                          
}

I'm pretty sure it doesn't change anything, but I did have to define a custom hash function for the TimeCodeCellViewModel, which might change the behavior of the ForEach (the attributes being changed are included in the hash function). However, I have noticed the same ForEach behavior in another part of my project that uses a different view model, so I highly doubt this is the issue.

class TimeCodeCellViewModel:Identifiable, Hashable {
    static func == (lhs: TimeCodeCellViewModel, rhs: TimeCodeCellViewModel) -> Bool {
        if lhs.id == rhs.id {
            return true
        }
        else {
            return false
        }
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        hasher.combine(isSet)
        hasher.combine(timeCode)
        hasher.combine(date)
    }

    var id: String = ""
    var date = Date()
    var isSet = false
    var timeCode: TimeCode
    
    var frame: CGRect = .zero
    
    init(timeCodeCell: TimeCodeCell) {
        self.id = timeCodeCell.id!.uuidString
        self.date = timeCodeCell.date!
        self.isSet = timeCodeCell.isSet
        self.timeCode = timeCodeCell.toTimeCode!
    }
}
greatxp117
  • 57
  • 11
  • Hi, please give full sample code so we can reproduce easily , your problematic – Hikosei Nov 18 '21 at 08:42
  • Change your class to a struct or make it conform to ObservanleObject and observe it by wrapping its usage. Your view isn’t being told about the changes. – lorem ipsum Nov 18 '21 at 11:27
  • @lorem-ipsum Could you please look at the first instance I added? I think the issue is more clear, and it's easier to show you that the ViewModels already conform to Identifiable and ObservedObject. Thank you so much for taking the time to help me! – greatxp117 Nov 26 '21 at 20:43
  • `TimeCodeViewModel` and `TimeCodeCellViewModel` have to be a `struct` or an `ObservableObject` that get their own `@ObservedObject` wrapper in the `View` – lorem ipsum Nov 26 '21 at 20:46
  • 1
    There is so much missing from your code that there is no way to help you those are just observations look at [this](https://stackoverflow.com/questions/68710726/swiftui-view-updating/68713038#68713038) and [this](https://stackoverflow.com/questions/69776338/unable-to-save-custom-data-into-coredata-using-combine-framework/69781410#69781410) there are elements in both questions that apply to what you are trying to do. – lorem ipsum Nov 26 '21 at 20:54
  • Sorry, I feel like putting all the code in this post would make it unwieldy, and I don't really want to put all my code out there for the internet to see. I really appreciate that you've tried to help me. If you give me some way to contact you I can DM you a link to the repo so you can get a better understanding? – greatxp117 Nov 26 '21 at 22:12
  • Lol, no if you are worried about the world seeing beginner code you should create a [Minimal Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) as required. Consultations cost and there are site for that kind of stuff, I don't download repos. – lorem ipsum Nov 26 '21 at 22:14
  • Could you expand a little more on what you mean by ```struct``` or ```ObservableObject```? Is it not enough that ```TimeCodeListViewModel``` is an Observable Object? I haven't really been focusing on this as a source of the issue because the reduced version of the published array is updating, but this is my first real project so I am a bit of a noob and could totally be wrong about my focus. – greatxp117 Nov 26 '21 at 22:17
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/239619/discussion-between-greatxp117-and-lorem-ipsum). – greatxp117 Nov 26 '21 at 22:19
  • `TimeCodeViewModel` is `Identifiable` NOT an `ObservableObject`, `TimeCodeListViewModel` is an `ObservableObject` with an array variable and it will only trigger changes to the `View` when the array as a whole is updated. Such as the `count` or `replacement`. – lorem ipsum Nov 26 '21 at 22:33
  • This line `self.timeCodes = CoreDataManager.shared.getAllTimeCodes().map(TimeCodeViewModel.init)` breaks the entire connection with CoreData making any changes pointless because they go into limbo. – lorem ipsum Nov 26 '21 at 22:34
  • There are so many little errors in you code that comments will not suffice look at those links I attached above. – lorem ipsum Nov 26 '21 at 22:35

2 Answers2

1

Here is a snippet of what you need to make the code work.

See the comments for some basics of why

struct EditCodeView:View{
    @EnvironmentObject var timeCodeListVM: TimeCodeListViewModel
    //This will observe changes to the view model
    @ObservedObject var timeCodeViewModel: TimeCodeViewModel
    var body: some View{
        EditTimeCodeView(timeCode: timeCodeViewModel.timeCode)
            .onDisappear(perform: {
                //*********TO SEE CHANGES WHEN YOU EDIT
                //uncomment this line***********
                //_ = timeCodeListVM.update(timeCodeVM: timeCodeViewModel)
            })
    }
}
struct EditTimeCodeView: View{
    //This will observe changes to the core data entity
    @ObservedObject var timeCode: TimeCode
    var body: some View{
        Form{
            TextField("name", text: $timeCode.fullName.bound)
            TextField("appreviation", text: $timeCode.abbreviation.bound)
            Toggle("favorite", isOn: $timeCode.isFavorite)
        }
    }
}
class TimeCodeListViewModel: ObservableObject {
    //Replacing this whole thing with a @FetchRequest would be way more efficient than these extra view models
    //IF you dont want to use @FetchRequest the only other way to observe the persistent store for changes is with NSFetchedResultsController
    //https://stackoverflow.com/questions/67526427/swift-fetchrequest-custom-sorting-function/67527134#67527134
    //This array will not see changes to the variables of the ObservableObjects
    @Published var timeCodeVMs = [TimeCodeViewModel]()
    private var persistenceManager = TimeCodePersistenceManager()
    init() {
        fetchAllTimeCodes()
    }
    
    func fetchAllTimeCodes() {
        //This method does not observe for new and or deleted timecodes. It is a one time thing
        self.timeCodeVMs = persistenceManager.retrieveObjects(sortDescriptors: nil, predicate: nil).map({
            //Pass the whole object there isnt a point to just passing the variables
            //But the way you had it broke the connection
            TimeCodeViewModel(timeCode: $0)
        })
    }
    
    func addNew() -> TimeCodeViewModel{
        let item = TimeCodeViewModel(timeCode: persistenceManager.addSample())
        timeCodeVMs.append(item)
        //will refresh view because there is a change in count
        return item
    }
    ///Call this to save changes
    func update(timeCodeVM: TimeCodeViewModel) -> Bool{
        let result = persistenceManager.updateObject(object: timeCodeVM.timeCode)
        //You have to call this to see changes at the list level
        objectWillChange.send()
        return result
    }
}

//DO you have special code that you aren't including? If not what is the point of this view model?
class TimeCodeViewModel: Identifiable, ObservableObject {
    //Simplify this
    //This is a CoreData object therefore an ObservableObject it needs an @ObservedObject in a View so changes can be seem
    @Published var timeCode: TimeCode
    init(timeCode: TimeCode) {
        self.timeCode = timeCode
    }
}
lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
0

Your first ForEach probably cannot check if the identity of Array<TimeCodeCellViewModel> has changed. Perhaps you want to use a separate struct which holds internally an array of TimeCodeCellViewModel and conforms to Identifiable, effectively implementing such protocol.

stuct TCCViewModels: Identifiable {
    let models: Array<TimeCodeCellViewModel>
    var id: Int {
        models.hashValue
    }

}

You might as well make this generic too, so it can be reused for different view models in your app:

struct ViewModelsContainer<V: Identifiable> where V.ID: Hashable {
    let viewModels: Array<V>
    let id: Int
    init(viewModels: Array<V>) {
        self.viewModels = viewModels
        var hasher = Hasher()
        hasher.combine(viewModels.count)
        viewModels.forEach { hasher.combine($0.id) }
        self.id = hasher.finalize
    }
    
}
valeCocoa
  • 344
  • 1
  • 8
  • Could you please look at the first instance I added? I think the issue is more clear, and it's easier to show you that the ViewModels already conform to Identifiable and ObservedObject. Thank you so much for taking the time to help me! – greatxp117 Nov 26 '21 at 20:40