NEW: Learn to build amazing SwiftUI apps for macOS with my new book! >>

Day 47: Habit Tracker

Forums > 100 Days of SwiftUI

Hi! App crashes when removing a row.

If I create a new habit, then remove - it's ok. (although strangly animated) But if I create a new habit, then edit something in DetailView() and, then, remove, app crushes.

This is the 'error' I get - "Thread 1: Fatal error: Index out of range" on the line of - Text("(habits.items[index].description)") (description is highlighted red)

Paul is talking about similar issue in lists. (day37 https://www.hackingwithswift.com/books/ios-swiftui/working-with-identifiable-items-in-swiftui)

So ,that I should do the following

ForEach(habits.items, id: \.id) { item in
....
}

//and that even in such case there's no need for 'id'
ForEach(habits.items) { item in
....
}

But! What I was looking for is - to pass index to the next view, so in the DetailView() I can edit the habit. I need a reference and not a copy of value.

The same with a list, as there I want to do the edits in ContentView(). For ex., increment the completion times amount.

How to fix the List to work properly?

struct HabitItem: Identifiable, Codable {
    let id = UUID()
    let name: String
    var description: String
    var dates: [Date]
    var amount: Int {
        didSet {
            if amount != 0 {
                dates.append(Date())
            } else if amount == 0 {
                dates.removeAll()
            }
        }
    }
}

class Habits: ObservableObject {
    @Published var items = [HabitItem]() {
        ......
}

struct ScaleButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 2 : 1)
    }
}
struct ContentView: View {
    @ObservedObject var habits = Habits()
    @State private var showingAddHabit = false

    @State private var scaleValue = CGFloat(1)

    var body: some View {
        NavigationView {

            List {
                ForEach(habits.items.indices, id: \.self) { index in
                    HStack {
                        NavigationLink(destination: DetailView(habits: habits, index: index)) {
                            VStack {
                                Text(habits.items[index].name)
                                    .font(.headline)
                            }

                            Spacer()

                            Text("\(habits.items[index].amount)")
                                .foregroundColor(.purple)
                                .underline()
                        }

                        Divider()

                        Button(action: { habits.items[index].amount += 1 }) {
                            Image(systemName: "plus.square")
                                .resizable()
                                .scaledToFit()
                                .scaleEffect(self.scaleValue)
                                .frame(height: 20)
                                .foregroundColor(.blue)
                        }.buttonStyle(ScaleButtonStyle())
                        .padding()

                    }
                }.onDelete(perform: removeItems)
            }

        func removeItems(at offsets: IndexSet) {
              habits.items.remove(atOffsets: offsets)
        }
}
struct DetailView: View {
    @ObservedObject var habits: Habits
    let index: Int
    @State private var isExpanded = false

    @State private var editText = ""
    @State private var isEditMode = false

    private var lastDate: String {
        if let lastDate = habits.items[index].dates.last {
            return "\(DetailView.lastCompletedFormatter.string(from: lastDate))"
        } else if let lastDate = habits.items[index].dates.first {
            return "\(DetailView.lastCompletedFormatter.string(from: lastDate))"
        } else {
            return "Not started yet"
        }
    }

    private static let formatter: DateFormatter = {
        let df = DateFormatter()
        df.dateStyle = .medium
        df.timeStyle = .short
        return df
    }()

    private static let lastCompletedFormatter: DateFormatter = {
        let df = DateFormatter()
        df.dateStyle = .medium
        return df
    }()

    var body: some View {
        VStack {

            Form {

                Section(header: Text(isEditMode ? "Edit description" : "Description").foregroundColor(isEditMode ? .blue : .black).animation(.default)) {
                    if !isEditMode {
                        Text("\(habits.items[index].description)")
                    }
                    else {
                        TextField("", text: $habits.items[index].description)
                            .padding(2)
                            .border(Color(UIColor.separator))
                    }
                }
                Section {
                    Text("Completed \(habits.items[index].amount) times")
                    DisclosureGroup("All Dates", isExpanded: $isExpanded) {

                        ForEach(habits.items[index].dates, id: \.self) { date in
                            Text(DetailView.formatter.string(from: date))
                        }
                    }.buttonStyle(PlainButtonStyle())
                    .foregroundColor(.black)
                }

                Section(header: Text("Last Completed")) {
                    Text(lastDate)
                }

                Section {
                    HStack{
                        Button("Reset") {
                            habits.items[index].amount = 0
                        }
                    }
                }
            }

            Spacer()

            Button(action: {
                habits.items[index].amount += 1
            }, label: {
                VStack {
                    Image(systemName: "plus.square.fill")
                        .resizable()
                        .foregroundColor(.purple)
                        .scaledToFit()
                        .frame(width: 50)
                }
            }).padding()

            .navigationBarTitle(Text(habits.items[index].name))
            .navigationBarItems(trailing:
                                    Button(action: {
                                        isEditMode.toggle()

                                    }, label: {
                                        Text(isEditMode ? "Save" : "Edit")
                                    })
            )

        }

    }
}

1      

Hi,

I’m having the same issue - my code the same as @MikeMaus. I can add and delete habits from the list without any issues. However, as soon as I call the DetailView() passing the index, return to the list and delete habits so that the number of habits left in the list is less than the index of the last habit viewed in the DetailView(), the app crashes.

Stepping through the code, it appears that after calling the onDelete modifier the DetailView() code is executed again before the list view is rebuild. Because the index value is now higher than the size of the array, the app crashes. Any idea why onDelete would result in the DetailView() code being executed?

Any suggestions on how to fix this issue or alternative solutions would be much appreciated.

Thanks

1      

DetailView does not need to receive the entire array of habits. Just pass the habit itself. That should sort the problem.

You do so by simply declaring a @Binding habit: HabitItem and you pass it in the ForEach as: DetailView(habit: item)

1      

Thanks @MarcusKay - I tried @Binding but get an error: Cannot convert value of type 'Habit' to expected argument type 'Binding<Habit>'

Is it because the habbitArray is within a class?

Here's my code:

class Habits: ObservableObject {
    @Published var habitArray = [Habit]() {
        didSet {
            let encoder = JSONEncoder()
            if let encoded = try? encoder.encode(habitArray) {
                UserDefaults.standard.set(encoded, forKey: "Habits")
            }
        }
    }

    init() {
        if let habits = UserDefaults.standard.data(forKey: "Habits") {
            let decoder = JSONDecoder()

            if let decoded = try? decoder.decode([Habit].self, from: habits) {
                self.habitArray = decoded
                return
            }
        }
        self.habitArray = []
    }

}
struct ContentView: View {
    @ObservedObject var habits = Habits() 
    @State private var showingAddHabit = false

    var body: some View {
        NavigationView {

            List {
                ForEach(habits.habitArray) { habit in
                    NavigationLink(destination: HabitView(habit: habit)) {
                        VStack(alignment: .leading) {
                            Text(habit.name)
                            Text(habit.description)
                                .font(.caption)
                        }
                    }
                }
                .onDelete(perform: deleteHabits)
            }

            .navigationBarTitle("Habits++")
            .navigationBarItems(
                leading:
                    EditButton(),
                trailing:
                    Button(action: {
                        showingAddHabit = true
                    }) {
                        Text("Add Habit")
                    }
            )
            .sheet(isPresented: $showingAddHabit) {
                AddHabitView(habits: habits)
            }

        }
    }

    func deleteHabits(at offsets: IndexSet) {
        habits.habitArray.remove(atOffsets: offsets)
    }

}
struct HabitView: View {

    @Binding var habit: Habit

    var body: some View {
        Spacer()
        Text(habit.name)
            .font(.largeTitle)
        Text(habit.description)
            .font(.body)
            .padding(.bottom)
        Text("Completed \(String(habit.count)) times")
            .font(.title2)
            .padding()
        Button {
            habit.count += 1
        } label: {
                Text("Done!")
                .padding()
                .background(Capsule().fill(Color.blue))
                .foregroundColor(.white)
                .shadow(color: .gray, radius: 2, x: 2, y: 2)
        }
        Spacer()
        Spacer()
    }
}

1      

Sorry... it is because I forgot the $ sign before habit.

HabitView(habit: $habit)

This makes it a binding being passed.

1      

Thanks again, but this time I get "Cannot find $habit in scope"

struct ContentView: View {
    @ObservedObject var habits = Habits()
    @State private var showingAddHabit = false

    var body: some View {
        NavigationView {

            List {
                ForEach(habits.habitArray) { habit in
                    NavigationLink(destination: HabitView(habit: $habit)) {
                        VStack(alignment: .leading) {
                            Text(habit.name)
                            Text(habit.description)
                                .font(.caption)
                        }
                    }
                }
                .onDelete(perform: deleteHabits)
            }

1      

I've reverted back to my original solution of pasing the index to the HabitView and stopped my app crashing by coding around the issue - I now check the index value before accesses the habbitArray in the HabitView. It’s a pretty inelegant solution but it’s the best I can do with my current level of knowledge. Hopefully as I learn more in the course I’ll be able to come back and update this post with a better solution, or someone else will.

Trying to fix it has been a REALLY useful exercise because I understand better what was causing the problem, even if I can’t fix it. The onDelete modifier was changing the habitArray which is a @Published property and therefore triggered all views observing it to reload, even if they were not being displayed on screen. When the HabitView reloaded it still had the last value of index which was now out of range because items had been deleted from the array.

Here's my workaround:

struct HabitView: View {

    @ObservedObject var habits: Habits
    var index: Int

    var body: some View {
        Spacer()
        Text(index < habits.habitArray.count ? habits.habitArray[index].name : "")
            .font(.largeTitle)
        Text(index < habits.habitArray.count ? habits.habitArray[index].description : "")
            .font(.body)
            .padding(.bottom)
        Text("Completed \(String(index < habits.habitArray.count ? habits.habitArray[index].habitCompletionCount : 0)) times")
            .font(.title2)
            .padding()
        Button {
            if index < habits.habitArray.count {
                habits.habitArray[index].habitCompletionCount += 1
            }
        } label: {
                Text("Done!")
                .padding()
                .background(Capsule().fill(Color.blue))
                .foregroundColor(.white)
                .shadow(color: .gray, radius: 2, x: 2, y: 2)
        }
        Spacer()
        Spacer()
    }
}

1      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Spend less time managing in-app purchase infrastructure so you can focus on building your app. RevenueCat gives everything you need to easily implement, manage, and analyze in-app purchases and subscriptions without managing servers or writing backend code.

Get Started

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.