SwiftUI: Random crash of running project

Hi!

I write a time table application in SwiftUI. Now I get crashes while running in Debugger, e.g.:
/EXC_BAD_ACCESS (code=1, address=0xfffffffffffffff8)
I use Xcode 14.3 on macOS 13.3.

This looks like a SwiftUI bug for me because I expect an error message when something is wrong in my code…

I was able to make a small example which does crash quickly:

  • repeat ( just two or three times):
    • Select a rectangle and click an arrow button in toolbar 3 times.
    • switch selection to the other rectangle

Any thoughts?

Thanks in advance, code is below:

import SwiftUI

struct vTime : Codable {
    var hour : Int
    var min  : Int
    
    mutating func incQuarter() {
        min += 15
        if min == 60 {
            min = 0
            hour += 1
        }
    }
    mutating func decQuarter() {
        min -= 15
        if min < 0 {
            min = 45
            hour -= 1
        }
    }
}

struct RecDate : Codable {
    var day  : Int
    var start: vTime
    var end  : vTime
    
    private enum CodingKeys : String, CodingKey {
        case day
        case start
        case end
    }
    
    func duration() -> CGFloat {
        let dTime : CGFloat = abs(CGFloat(end.hour - start.hour) * 4.0 + CGFloat(end.min - start.min) / 15.0)
        return dTime
    }
}

class Record : Hashable, Equatable, Identifiable, ObservableObject {
    let vID = UUID()
    @Published var subject : String
    @Published var date    : RecDate
    
    init(subject: String, date : RecDate) {
        self.subject = subject
        self.date = date
    }
    
    static func == (lhs: Record, rhs: Record) -> Bool {
        var r = lhs.subject.compare(rhs.subject) == .orderedSame
        r = r && lhs.date.start.hour == rhs.date.start.hour
        r = r && lhs.date.start.min == rhs.date.start.min
        r = r && lhs.date.end.hour == rhs.date.end.hour
        r = r && lhs.date.end.min == rhs.date.end.min
        r = r && lhs.vID == rhs.vID
        return r
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(subject)
        hasher.combine(date.start.hour)
        hasher.combine(date.start.min)
        hasher.combine(date.end.hour)
        hasher.combine(date.end.min)
        hasher.combine(vID)
    }
    
    func isSelected(appSelection : UUID?) -> Bool {
        return appSelection == vID
    }
    
    public func incDate() {
        if !(self.date.end.hour == 19 && self.date.end.min == 0) {
            self.date.start.incQuarter()
            self.date.end.incQuarter()
        }
    }
    
    public func decDate() {
        if !(self.date.start.hour == 8 && self.date.start.min == 0) {
            self.date.start.decQuarter()
            self.date.end.decQuarter()
        }
    }
}

public class RecordHandler : ObservableObject {
    @Published var records : [Record] = [
        Record(subject: "banana", date: RecDate(day: 0, start: vTime(hour: 8, min: 0), end: vTime(hour: 10, min: 0))),
        Record(subject: "strawberry", date: RecDate(day: 1, start: vTime(hour: 11, min: 0), end: vTime(hour: 14, min: 0)))
    ]
    @Published var selectedID  : UUID? = nil

    func setSel(newID : UUID) {
        if selectedID == nil {
            selectedID = newID
        } else {
            if selectedID == newID {
                selectedID = nil
            } else {
                selectedID = newID
            }
        }
    }
}

struct EntryView: View {
    @EnvironmentObject var appData : RecordHandler
    @ObservedObject var setup : Record
    @State private var showDataView : Bool = false
    
    struct SelView : View {
        var body: some View {
            Rectangle()
                .inset(by: -2)
                .stroke(.blue, lineWidth: 5.0)
        }
    }

    var body: some View {
        let qCount = setup.date.duration()
        let height = qCount * 10.0
        let xPos   = 100.0
        let yPos   = 7.0 + (CGFloat(setup.date.start.hour) - 8.0) * 40.0 + CGFloat(setup.date.start.min) / 1.5 + (qCount - 1.0) * 5.0
        ZStack {
            RoundedRectangle(cornerRadius: 10)
                .foregroundColor(.red.opacity(0.80))
            Text(setup.subject)
            if setup.isSelected(appSelection: appData.selectedID) {
                SelView()
            }
        }
        .frame(width: 200, height: height)
        .position(x: xPos, y: yPos)
        .onTapGesture(count: 1) {
            appData.setSel(newID: setup.vID)
        }
    }
}

struct DayView: View {
    @EnvironmentObject var appData : RecordHandler
    var day : Int
    
    var body: some View {
        VStack {
            ForEach(appData.records, id: \.self) { value in
                if value.date.day == day {
                    EntryView(setup: value)
                        .environmentObject(appData)
                }
            }
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var appData : RecordHandler

    var body: some View {
        NavigationStack {
            HStack {
                DayView(day: 0)
                    .environmentObject(appData)
                DayView(day: 1)
                    .environmentObject(appData)
            }
        }
        .toolbar {
            ToolbarItemGroup(placement: .automatic) {
                Button("↑") {
                    if let ds = appData.records.first(where: { $0.vID == appData.selectedID!}) {
                        ds.decDate()
                    }
                }
                .disabled(appData.selectedID == nil)
                Button("↓") {
                    if let ds = appData.records.first(where: { $0.vID == appData.selectedID!}) {
                        ds.incDate()
                    }
                }
                .disabled(appData.selectedID == nil)
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    @StateObject static var previewData = RecordHandler()

    static var previews: some View {
        ContentView()
            .environmentObject(previewData)
    }
}

@main
struct crasherApp: App {
    @StateObject private var appData = RecordHandler()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appData)
        }
    }
}

When debugging this code, I got an error that said you have duplicate Record in the dictionary.

Seems that your Record is both Identifiable and Hashable but the struct implementations of these interfaces do not agree. You must hash only the id in the hash, since it is only used in implementing the Identifiable:

	func hash(into hasher: inout Hasher) {
		hasher.combine(vID)
	}

Well, in another post From Hashable documentation it states that the hasher and the equals function must have the same properties.

Therefore I use all fields in both functions.

Maybe I am wrong then. Just implemented it that way and didn't have the crash anymore. Will take another look if I have time.

Many thanks for your help.

You are mutating the records after asking SwiftUI to identify the records on \.self. You probably want this instead:

ForEach(appData.records, id: \.vID)

Note that you should not conform to Identifiable here since you are using vID. Or if you do you could simply rename vID to id and then you are allowed to simply do this:

ForEach(appData.records)
1 Like

So I suspect that since you use the vTime values in your hash, and then change those values by tapping those buttons on the toolbar, the hash of the object changes. Idea with hashes is that they should stay the same for the object while the app is running and the object lives.

Maybe just use those properties of the object when comparing and hashing, that are permanent. Meaning the UUID and / or the subject.

Also, use id as the name of the identifying element, since Identifiable expects to find a property with name id.

1 Like

@tclementdev, @andreas66: Many thanks for your help.

I simplified the code now so that equals and hasher use just the id and I renamed vID to id.

In my tests I got no more crashes.

Here's refactored and simplified version of your app:

Refactored
import SwiftUI

struct VTime: Hashable {
    var hour: Int
    var min: Int
    
    mutating func incQuarter() {
        min += 15
        if min == 60 {
            min = 0
            hour += 1
        }
    }
    mutating func decQuarter() {
        min -= 15
        if min < 0 {
            min = 45
            hour -= 1
        }
    }
}

struct RecordDate: Hashable {
    var day: Int
    var start: VTime
    var end: VTime
    
    var duration: CGFloat {
        abs(CGFloat(end.hour - start.hour) * 4.0 + CGFloat(end.min - start.min) / 15.0)
    }
}

struct Record: Hashable, Equatable, Identifiable {
    let id = UUID()
    var subject: String
    var date: RecordDate
    
    mutating func incDate() {
        if !(date.end.hour == 19 && date.end.min == 0) {
            date.start.incQuarter()
            date.end.incQuarter()
        }
    }
    
    mutating func decDate() {
        if !(date.start.hour == 8 && date.start.min == 0) {
            date.start.decQuarter()
            date.end.decQuarter()
        }
    }
}

class AppData: ObservableObject {
    @Published var records: [Record] = [
        Record(subject: "banana", date: RecordDate(day: 0, start: VTime(hour: 8, min: 0), end: VTime(hour: 10, min: 0))),
        Record(subject: "strawberry", date: RecordDate(day: 1, start: VTime(hour: 11, min: 0), end: VTime(hour: 14, min: 0)))
    ]
    
    @Published var selectedID: UUID? = nil

    func setSelected(id: UUID) {
        selectedID = selectedID == id ? nil : id
    }
    
    func recordIndex(for id: UUID) -> Int? {
        records.firstIndex { $0.id == id }
    }
    
    func record(for day: Int) -> Record? {
        records.first { $0.date.day == day }
    }
        
    func rect(for record: Record) -> CGRect {
        let qCount = record.date.duration
        let y = 7.0 + (CGFloat(record.date.start.hour) - 8.0) * 40.0 + CGFloat(record.date.start.min) / 1.5 + (qCount - 1.0) * 5.0
        return CGRect(x: 100.0, y: y, width: 200, height: qCount * 10.0)
    }
}

struct SelectionView: View {
    var body: some View {
        Rectangle().inset(by: -2).stroke(.blue, lineWidth: 5)
    }
}

struct DayView: View {
    @ObservedObject var appData: AppData
    let record: Record
    
    var body: some View {
        VStack {
            let rect = appData.rect(for: record)
            ZStack {
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor(.red.opacity(0.80))
                Text(record.subject)
                if record.id == appData.selectedID {
                    SelectionView()
                }
            }
            .frame(width: rect.width, height: rect.height)
            .position(x: rect.origin.x, y: rect.origin.y)
            .onTapGesture(count: 1) {
                appData.setSelected(id: record.id)
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var appData = AppData()

    var body: some View {
        NavigationStack {
            HStack {
                ForEach(appData.records) { record in
                    DayView(appData: appData, record: record)
                }
            }
        }
        .toolbar {
            ToolbarItemGroup(placement: .automatic) {
                let selectedID = appData.selectedID
                Button("↑") {
                    if let i = appData.recordIndex(for: selectedID!) {
                        appData.records[i].decDate()
                    }
                }
                .disabled(selectedID == nil)
                Button("↓") {
                    if let i = appData.recordIndex(for: selectedID!) {
                        appData.records[i].incDate()
                    }
                }
                .disabled(selectedID == nil)
            }
        }
    }
}

@main struct CrasherApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                ContentView()
            }
        }
    }
}

In particular:

  • I removed ObservableObject from records
  • made records structs
  • removed explicit EQ/Hashable implementation (built-in woks just fine)
  • remove codable (re-add it if needed)
  • removed environment object
  • removed record view
  • did a few other simplifications

Seems to be working just fine :ok_hand:

2 Likes

Many thanks. I will look at it and learn, I guess :wink: