iosswiftswiftuiuserdefaultsappstorage

Using SwiftUI Picker with RawRepresentable enum that contains associated values crashes the app


I'm working on an app that enables users to choose default and custom fonts.


// MARK: - CustomFont

enum CustomFont: String, CaseIterable, Identifiable, Codable {
    case baskerville = "Baskerville"
    case chalkboardSE = "ChalkboardSE-Regular"
    
    var displayTitle: String {
        switch self {
        case .baskerville:
            return "Baskerville"
        case .chalkboardSE:
            return "Chalkboard SE"
        }
    }
    
    var id: String {
        rawValue
    }
}

// MARK: - AppFont

enum AppFont: Hashable, Codable {
    case `default`
    case custom(font: CustomFont)
}

// MARK: - RawRepresentable

extension AppFont: RawRepresentable {
    init?(rawValue: String) {
        let data = rawValue.data(using: .utf8)!
        self = try! JSONDecoder().decode(AppFont.self, from: data)
    }
    
    var rawValue: String {
        let data = try! JSONEncoder().encode(self) // EXC_BAD_ACCESS CRASH HERE
        return String(decoding: data, as: UTF8.self)
    }
}

I need to persist those fonts as Codable RawRepresentable enums in AppStorage.

// MARK: - ContentView

struct ContentView: View {
    @AppStorage("selectedFont") private var selectedFont: AppFont = .custom(font: .baskerville)
    
    var body: some View {
        VStack {
            Picker(selection: $selectedFont) {
                Text("Default")
                    .tag(AppFont.default)
                
                ForEach(CustomFont.allCases) { customFont in
                    Text(customFont.displayTitle)
                        .tag(AppFont.custom(font: customFont))
                }
                
            } label: {
                Text("Select font")
            }
            
            if case let .custom(font) = selectedFont {
                Text("Hello, world!")
                    .font(.custom(font.rawValue, size: 17))
            } else {
                Text("Hello, world!")
            }
            
        }
    }
}

I know for a fact that it's the Picker that crashes the app. The app hangs for several seconds, and the JSON encoder crashes with the generic EXC_BAD_ACCESS error.

I've already tried embedding the enum into a struct. But it yielded the same result.

Here's a concise sample project.

I don't know if it's on me or if I should submit a bug report. Does anybody have any idea of what I might be doing wrong? Thank you!


Solution

  • It is not the Picker that crashes the app at all, instead it is the encoding of AppFont that enters an infinite loop because in the property rawValue the function call .encode(self) calls self.rawValue because you have declared that self conforms to RawRepresentable so the encoder wants to encode the raw value of the enum case.

    Note what it says in the article

    Remember, that AppStorage only supports RawRepresentable where the RawValue's associatedtype is of type Int or String. So the rawValue property has to return an Int or a String.

    But the AppFont enum does not have a RawValue of type String or Int, in fact the enum can not be made to properly conform to RawRepresentable since it has a case with an associated value.

    Maybe this should be seen as a compiler bug and that the compiler should produce an error here.

    To work around this you can skip the Codable support and use with switch when converting to/from a string

    extension AppFont: RawRepresentable {
        private static let defaultRawValue = "default"
    
        init?(rawValue: String) {
            switch rawValue {
            case Self.defaultRawValue:
                self = .default
            default:
                guard let font = CustomFont(rawValue: rawValue) else { return nil }
                self = .custom(font: font)
            }
        }
    
        var rawValue: String {
            switch self {
            case .default:
                return Self.defaultRawValue
            case .custom(let font):
                return font.rawValue
            }
        }
    }
    

    I didn't include SWiftUI or UserDefaults when examining this, instead this was my test code

    var rawStrings = [AppFont.default.rawValue]
    for font in CustomFont.allCases {
        let string = AppFont.custom(font: font).rawValue
        rawStrings.append(string)
    }
    
    for string in rawStrings {
        if let font = AppFont(rawValue: string) {
            print(font)
        }
    }