2

Sorry if my question is silly, I am a beginner to programming. I have a Navigation Link to a detail view from a List produced from my view model's array. In the detail view, I want to be able to mutate one of the tapped-on element's properties, but I can't seem to figure out how to do this. I don't think I explained that very well, so here is the code.

// model
struct Activity: Identifiable {
    var id = UUID()
    var name: String
    var completeDescription: String
    var completions: Int = 0
}

// view model
class ActivityViewModel: ObservableObject {
    @Published var activities: [Activity] = []
}

// view
struct ActivityView: View {
    @StateObject var viewModel = ActivityViewModel()
    @State private var showingAddEditActivityView = false
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(viewModel.activities, id: \.id) {
                        activity in
                        NavigationLink(destination: ActivityDetailView(activity: activity, viewModel: self.viewModel)) {
                            HStack {
                                VStack {
                                    Text(activity.name)
                                    Text(activity.miniDescription)
                                }
                                Text("\(activity.completions)")
                            }
                        }
                    }
                }
            }
            .navigationBarItems(trailing: Button("Add new"){
                self.showingAddEditActivityView.toggle()
            })
            .navigationTitle(Text("Activity List"))
        }
        .sheet(isPresented: $showingAddEditActivityView) {
            AddEditActivityView(copyViewModel: self.viewModel)
        }
    }
}

// detail view
struct ActivityDetailView: View {
    @State var activity: Activity
    @ObservedObject var viewModel: ActivityViewModel
    
    var body: some View {
        VStack {
            Text("Number of times completed: \(activity.completions)")
            Button("Increment completion count"){
                activity.completions += 1
                updateCompletionCount()
            }
            Text("\(activity.completeDescription)")
        }
    }
    func updateCompletionCount() {
         var tempActivity = viewModel.activities.first{ activity in activity.id == self.activity.id
         }!
        tempActivity.completions += 1
    }
}

// Add new activity view (doesn't have anything to do with question)

struct AddEditActivityView: View {
    @ObservedObject var copyViewModel : ActivityViewModel
    @State private var activityName: String = ""
    @State private var description: String = ""
    var body: some View {
        VStack {
            TextField("Enter an activity", text: $activityName)
            TextField("Enter an activity description", text: $description)
            Button("Save"){
                // I want this to be outside of my view
                saveActivity()
            }
        }
    }
    func saveActivity() {
        copyViewModel.activities.append(Activity(name: self.activityName, completeDescription: self.description))
        print(copyViewModel.activities)
    }
}

In the detail view, I am trying to update the completion count of that specific activity, and have it update my view model. The method I tried above probably doesn't make sense and obviously doesn't work. I've just left it to show what I tried.

Thanks for any assistance or insight.

pakobongbong
  • 123
  • 1
  • 9

2 Answers2

2

The problem is here:

struct ActivityDetailView: View {
    @State var activity: Activity
    ...

This needs to be a @Binding in order for changes to be reflected back in the parent view. There's also no need to pass in the entire viewModel in - once you have the @Binding, you can get rid of it.

// detail view
struct ActivityDetailView: View {
    @Binding var activity: Activity /// here!
    
    var body: some View {
        VStack {
            Text("Number of times completed: \(activity.completions)")
            Button("Increment completion count"){
                activity.completions += 1
            }
            Text("\(activity.completeDescription)")
        }
    }
}

But how do you get the Binding? If you're using iOS 15, you can directly loop over $viewModel.activities:

/// here!
ForEach($viewModel.activities, id: \.id) { $activity in
    NavigationLink(destination: ActivityDetailView(activity: $activity)) {
        HStack {
            VStack {
                Text(activity.name)
                Text(activity.miniDescription)
            }
            Text("\(activity.completions)")
        }
    }
}

And for iOS 14 or below, you'll need to loop over indices instead. But it works.

/// from https://stackoverflow.com/a/66944424/14351818
ForEach(Array(zip(viewModel.activities.indices, viewModel.activities)), id: \.1.id) { (index, activity)  in
    NavigationLink(destination: ActivityDetailView(activity: $viewModel.activities[index])) {
        HStack {
            VStack {
                Text(activity.name)
                Text(activity.miniDescription)
            }
            Text("\(activity.completions)")
        }
    }
}
aheze
  • 24,434
  • 8
  • 68
  • 125
  • Hello, I tried the first solution but got an error that "viewModel.activities did not conform to RandomAccessCollection". I appreciate your response. – pakobongbong Sep 10 '21 at 19:33
  • 1
    @pakobongbong are you on Xcode 12? That will only work on Xcode 13/iOS 15 - try the second solution instead. – aheze Sep 10 '21 at 19:35
  • This works, thanks! I tried downloading Xcode 13 but I don't have enough disk space on 128GB MBP despite downloading "DevCleaner''... I'll have to figure something out. – pakobongbong Sep 10 '21 at 22:42
1

You are changing and increment the value of tempActivity so it will not affect the main array or data source.

You can add one update function inside the view model and call from view.

The view model is responsible for this updation.

class ActivityViewModel: ObservableObject {
    @Published var activities: [Activity] = []
    
    func updateCompletionCount(for id: UUID) {
        if let index = activities.firstIndex(where: {$0.id == id}) {
            self.activities[index].completions += 1
        }
    }
}
struct ActivityDetailView: View {
    var activity: Activity
    var viewModel: ActivityViewModel
    
    var body: some View {
        VStack {
            Text("Number of times completed: \(activity.completions)")
            Button("Increment completion count"){
                updateCompletionCount()
            }
            Text("\(activity.completeDescription)")
        }
    }
    func updateCompletionCount() {
        self.viewModel.updateCompletionCount(for: activity.id)
    }
}

Not needed @State or @ObservedObject for details view if don't have further action.

Raja Kishan
  • 16,767
  • 2
  • 26
  • 52
  • Thank you Raja. I have also been quite confused as to how use a function that accepts parameters inside of a button, as to the best of my knowledge a button only accepts closures that accept no parameters and return nothing. I see that your function runs another function itself accepts a parameter. Are you aware of any resources where I can learn more about this technique? – pakobongbong Sep 10 '21 at 19:17
  • 1
    @pakobongbong you're right, the button action closure accepts no parameters but returns nothing. It's like a function `func doSomething() { ... }`. But, you can put *anything* you want inside those brackets. You can even call functions that need parameters, like `self.viewModel.updateCompletionCount(for: activity.id)` (there's really no need to have a separate `func updateCompletionCount() {`, you can put that directly in the button closure). – aheze Sep 10 '21 at 19:34
  • @Raja Kishan by any chance can you tell me why `@ObservedObject` is not required here? How is the view able to update without a property wrapper? – pakobongbong Sep 12 '21 at 21:42