Search code examples
swiftuiswiftdataios17

Registering a ValueTransformer for use in SwiftUI Previews


I'm experimenting with using ValueTransformers in a SwiftUI/SwiftData app to handle models that contain Measurement types.

Although the app works as expected when it's run in the simulator, Xcode previews crash as a result of a segmentation error which appears to be the result of a failure to correctly register the ValueTransformer.

I've tried placing it inside the init for the displaying View as well as inside the preview itself but am no further forward. I'll freely admit not knowing too much about the lifecycle for transformers and it might be that the registration is out of scope before it's called or something similar. Any explanations and potentially a fix would be much appreciated.

App code:

@main
struct MeasurementChart_POCApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: MassMeasurementRecordModel.self)
        }
    }
    
    init() {
        ValueTransformer.setValueTransformer(
            MeasurementTransformer(), forName: NSValueTransformerName("MeasurementTransformer")
        )
    }
}

ContentView:

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query(sort: \MassMeasurementRecordModel.date) private var values: [MassMeasurementRecordModel]
    
    var body: some View {
        NavigationStack {
            VStack {
                List {
                    ForEach(values) { value in
                        HStack {
                            Text(
                                value.date.formatted(date: .omitted, time: .standard)
                            )
                            Spacer()
                            Text(value.measurement.formatted())
                        }
                    }
                }
                .padding(.bottom)
                .toolbar {
                    Button("Add samples") { addSampleData() }
                }
                
                Chart(values) { value in
                    BarMark(
                        x: .value("Date", value.date),
                        y: .value(
                            "Weight",
                            PlottableMeasurement(measurement: value.measurement)
                        )
                    )
                }
            }
        }
    }
    
    private func addSampleData() {
        _ = (0...20)
            .map { _ in
                let model = MassMeasurementRecordModel(
                    measurement: Measurement(value: Double.random(in: 0...2), unit: .kilograms),
                    date: Date.distantPast.advanced(by:
                                                        TimeInterval.random(in: 0...125000)
                                                   )
                )
                modelContext.insert(model)
            }
    }
}


#Preview {
    ValueTransformer.setValueTransformer(
        MeasurementTransformer(), forName: NSValueTransformerName("MeasurementTransformer")
    )
    
    let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: MassMeasurementRecordModel.self, configurations: configuration)
    
    return ContentView()
        .modelContainer(container)
    
}

Value transformer:

final class MeasurementTransformer: ValueTransformer {
    override func transformedValue(_ value: Any?) -> Any? {
        // Need to use NSMeasurement rather than Measurement as the latter doesn't
        // conform to NSSecureCoding.
        // This doesn't cause any problems in practice (as far as I can tell)
        // as the classes are interchangeable
        guard let measurement = value as? NSMeasurement else { return nil }
        do {
            let data = try NSKeyedArchiver.archivedData(withRootObject: measurement, requiringSecureCoding: true)
            return data
        } catch {
            debugPrint("Unable to convert Measurement to a persistable form: \(error.localizedDescription)")
            return nil
        }
    }
    
    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let data = value as? Data else { return nil }
        do {
            let measurement = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSMeasurement.self, from: data)
            return measurement
        } catch {
            debugPrint("Unable to convert persisted Data to a Measurement type: \(error.localizedDescription)")
            return nil
        }
    }
}

PlottableMeasurement:

struct PlottableMeasurement<UnitType: Unit> {
    var measurement: Measurement<UnitType>

}

extension PlottableMeasurement: Plottable where UnitType == UnitMass {
    var primitivePlottable: Double {
        self.measurement.converted(to: .kilograms).value
    }
    
    init?(primitivePlottable: Double) {
        self.init(
            measurement:
                Measurement(
                    value: primitivePlottable, unit: .kilograms
                )
        )
    }
}

Model:

@Model
final class MassMeasurementRecordModel {
    @Attribute(.transformable(by: MeasurementTransformer.self)) var measurement: Measurement<UnitMass>
    var date: Date
    
    init(measurement: Measurement<UnitMass>, date: Date) {
        self.measurement = measurement
        self.date = date
    }
}


extension MassMeasurementRecordModel {
    @MainActor
    static var preview: ModelContainer {
        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try! ModelContainer(for: MassMeasurementRecordModel.self, configurations: configuration)
        
        return container
    }
}

View:

import Charts
import SwiftData
import SwiftUI

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query(sort: \MassMeasurementRecordModel.date) private var values: [MassMeasurementRecordModel]
    
    var body: some View {
        NavigationStack {
            VStack {
                List {
                    ForEach(values) { value in
                        HStack {
                            Text(
                                value.date.formatted(date: .omitted, time: .standard)
                            )
                            Spacer()
                            Text(value.measurement.formatted())
                        }
                    }
                }
                .padding(.bottom)
                .toolbar {
                    Button("Add samples") { addSampleData() }
                }
            }
        }
    }
    
    private func addSampleData() {
        _ = (0...20)
            .map { _ in
                let model = MassMeasurementRecordModel(
                    measurement: Measurement(value: Double.random(in: 0...2), unit: .kilograms),
                    date: Date.distantPast.advanced(by:
                                                        TimeInterval.random(in: 0...125000)
                                                   )
                )
                modelContext.insert(model)
            }
    }
}


#Preview {
    ValueTransformer.setValueTransformer(
        MeasurementTransformer(), forName: NSValueTransformerName("MeasurementTransformer")
    )
    
    let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: MassMeasurementRecordModel.self, configurations: configuration)
    
    return ContentView()
        .modelContainer(container)
    
}

Solution

  • Thank you to Joakim Danielson for pointing out an omission in the ValueTransformer that appears to have been responsible for the crash. It's an abstract class and so unfortunately the compiler didn't warn me that I neglected to override class methods allowsReverseTransformation and transformedValueClass.

    What is interesting is that when Joakim tried my code, Xcode Previews and the Simulator crashed (segmentation errors) - this wasn't my experience as the sample code ran happily in the Simulator with Preview presenting the problem.

    Anyway, revised (and working) code as follows:

    final class MeasurementTransformer: ValueTransformer {
        override func transformedValue(_ value: Any?) -> Any? {
            // Need to use NSMeasurement rather than Measurement as the latter doesn't
            // conform to NSSecureCoding.
            // This doesn't cause any problems in practice (as far as I can tell)
            // as the classes are interchangeable
            guard let measurement = value as? NSMeasurement else { return nil }
            do {
                let data = try NSKeyedArchiver.archivedData(withRootObject: measurement, requiringSecureCoding: true)
                return data
            } catch {
                debugPrint("Unable to convert Measurement to a persistable form: \(error.localizedDescription)")
                return nil
            }
        }
        
        override func reverseTransformedValue(_ value: Any?) -> Any? {
            guard let data = value as? Data else { return nil }
            do {
                let measurement = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSMeasurement.self, from: data)
                return measurement
            } catch {
                debugPrint("Unable to convert persisted Data to a Measurement type: \(error.localizedDescription)")
                return nil
            }
        }
        
        override class func allowsReverseTransformation() -> Bool {
            true
        }
        
        override class func transformedValueClass() -> AnyClass {
            NSMeasurement.self
        }
    }