Search code examples
iosswiftswiftuiswiftdata

SwiftData crashes on trying to access struct property


I've defined the following models in a Swift iOS application:

import Foundation
import SwiftData

@Model
class User: Identifiable {
    let id: UUID
    let name: String
    
    var location: NamedLocation?
    var friend: Bool = false
    
    init(id: UUID = UUID(), name: String) {
        self.id = id
        self.name = name
    }
}


struct NamedLocation: Codable {
    let name: String?
    let loc: Location
    
    init(name: String?, location: Location) {
        self.name = name
        self.loc = location
    }
}

enum Location: Codable {
    case address(String)
    case coordinates(Double, Double)
}

When I try to populate with test data to see how my user interface looks, it works fine:

import SwiftUI
import SwiftData

struct HomeView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [User]
    
    var body: some View {
        // other views omitted for brevity
        VStack(alignment: .leading, spacing: 16.0) {
            Text("your nearby friends")
            List {
                ForEach(items) { item in
                    HStack {
                        VStack {
                            Text(item.name)
                            
                            if let location = item.location {
                                Text(location.name ?? "unknown location")
                            }
                        }
                    }
                }
            }
            .listStyle(.plain)
            .scrollContentBackground(.hidden)
            Spacer()
        }
    }
}

#Preview {
    let config = ModelConfiguration(isStoredInMemoryOnly: true, allowsSave: true)
    let container = try! ModelContainer(for: Schema([User.self]), configurations: config)
    
    for i in 1..<10 {
        var user = User(name: "Example User \(i)")
        container.mainContext.insert(user)
    }
    
    return HomeView().modelContainer(container)
}

But as soon as I add locations to the users, then my preview starts crashing (and it also crashes when I test in the simulator).

for i in 1..<10 {
    var user = User(name: "Example User \(i)")
    container.mainContext.insert(user)

    // I looked at other SO answers, and they suggested the issue comes from
    // not saving the model before trying to modify its properties. No dice.
    try! container.mainContext.save()
    user.location = NamedLocation(name: "hurk", location: .coordinates(40.72, -75.49))
    try! container.mainContext.save()
}

The crash is EXC_BREAKPOINT on User.location.getter, and the crash logs don't provide much detail.


Exception Type:  EXC_BREAKPOINT (SIGTRAP)
Exception Codes: 0x0000000000000001, 0x00000001c4352d5c
Termination Reason: SIGNAL 5 Trace/BPT trap: 5
Terminating Process: exc handler [82555]

Triggered by Thread:  0

Thread 0 Crashed::  Dispatch queue: com.apple.main-thread
0   SwiftData                              0x1c4352d5c 0x1c42d1000 + 531804
1   SwiftData                              0x1c435597c 0x1c42d1000 + 543100
2   SwiftData                              0x1c4316014 0x1c42d1000 + 282644
3   rdvz                                   0x100357830 User.location.getter + 332 (@__swiftmacro_4rdvz4UserC8location18_PersistedPropertyfMa_.swift:10)
4   HomeView.1.preview-thunk.dylib         0x101592f28 closure #1 in closure #1 in closure #1 in closure #1 in closure #1 in closure #2 in closure #1 in HomeView.__preview__body.getter + 476 (HomeView.swift:73)
5   SwiftUI                                0x1c49e38b8 0x1c4371000 + 6760632
6   HomeView.1.preview-thunk.dylib         0x101592b7c closure #1 in closure #1 in closure #1 in closure #1 in closure #2 in closure #1 in HomeView.__preview__body.getter + 144 (HomeView.swift:68)
7   SwiftUI                                0x1c52e0840 0x1c4371000 + 16185408
8   HomeView.1.preview-thunk.dylib         0x1015928cc closure #1 in closure #1 in closure #1 in closure #2 in closure #1 in HomeView.__preview__body.getter + 148 (HomeView.swift:67)
9   SwiftUI                                0x1c532f9a0 0x1c4371000 + 16509344
10  SwiftUI                                0x1c53379e4 0x1c4371000 + 16542180
11  SwiftUI                                0x1c4cfd750 0x1c4371000 + 10012496
12  SwiftUI                                0x1c5337a00 0x1c4371000 + 16542208
13  libswiftCore.dylib                     0x192adba04 withUnsafePointer<A, B>(to:_:) + 20
14  libswiftCore.dylib                     0x192c862ac withUnsafeMutablePointer<A, B>(to:_:) + 12
15  SwiftUI                                0x1c532f0f0 0x1c4371000 + 16507120
16  SwiftUI                                0x1c5330e24 0x1c4371000 + 16514596
17  SwiftUI                                0x1c5330838 0x1c4371000 + 16513080
18  SwiftUI                                0x1c533575c 0x1c4371000 + 16533340
19  SwiftUI                                0x1c52f7950 0x1c4371000 + 16279888
20  SwiftUI                                0x1c497bc10 0x1c4371000 + 6335504
21  SwiftUI                                0x1c5374abc 0x1c4371000 + 16792252

Why is this crash happening, and how do I fix it?

Edit: If I remove the member loc from NamedLocation, or I make it optional and nil, the preview stops crashing. Why? And how do I make it work?

Edit 2: I found out that the issue is related to how Location is persisted. I thought that its implementation of Codable was wrong, so I tried providing a manual one, but it does not work because the data passed to the decoding method does not match the data produced by the encoding method:

enum Location: Codable {
    case address(String)
    case coordinates(Double, Double)
    
    enum CodingKeys: String, CodingKey {
       case kind
       case address
       case latitude
       case longitude
   }

   init(from decoder: Decoder) throws {
       let container = try! decoder.container(keyedBy: CodingKeys.self)
       // this assertion fails; SwiftData gives a container with keys "address" and "coordinates" for some reason
       let kind = try! container.decode(String.self, forKey: .kind)

       switch kind {
       case "address":
           let address = try! container.decode(String.self, forKey: .address)
           self = .address(address)
       case "coordinates":
           let latitude = try! container.decode(Double.self, forKey: .latitude)
           let longitude = try! container.decode(Double.self, forKey: .longitude)
           self = .coordinates(latitude, longitude)
       default:
           throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Invalid location type")
       }
   }

   func encode(to encoder: Encoder) throws {
       var container = encoder.container(keyedBy: CodingKeys.self)

       switch self {
       case .address(let address):
           try! container.encode("address", forKey: .kind)
           try! container.encode(address, forKey: .address)
       case .coordinates(let latitude, let longitude):
           try! container.encode("coordinates", forKey: .kind)
           try! container.encode(latitude, forKey: .latitude)
           try! container.encode(longitude, forKey: .longitude)
       }
   }
}

Solution

  • It looks like SwiftData still can't handle enums with associated values properly. So you need to store the information in another way. One way is to add the associated values as optional properties to the main type.

    Below is one such solution where I made them private and used the enum as a computed property

    struct NamedLocation: Codable {
        let name: String?
        private let address: String?
        private let longitude: Double?
        private let latitude: Double?
        var location: Location? {
            if let address {
                return Location.address(address)
            } else if let longitude, let latitude {
                return Location.coordinates(longitude, latitude)
            } else {
                return nil
            }
        }
    
        init(name: String?, location: Location) {
            self.name = name
            switch location {
            case .address(let string):
                address = string
                latitude = nil
                longitude = nil
            case .coordinates(let long, let lat):
                longitude = long
                latitude = lat
                address = nil
            }
        }
    }