• Dec 16, 2024

Unique Macro in Xcode 16 & SwiftUI

Prior to iOS 18, the @Attribute(.unique) property was used to enforce uniqueness constraints in SwiftData models. However, it only supported single-property uniqueness, limiting its use for more complex scenarios. The newly introduced #Unique macro solves this limitation by allowing compound uniqueness constraints across multiple properties. This means you can specify combinations of properties that must be unique together, rather than just enforcing uniqueness on individual properties. When using the #Unique macro, SwiftData performs an upsert operation — if a new object has the same unique values as an existing one, the existing object is updated instead of creating a duplicate or triggering an error.

Prior to iOS 18, the @Attribute(.unique) property was used to enforce uniqueness constraints in SwiftData models. However, it only supported single-property uniqueness, limiting its use for more complex scenarios.

The newly introduced #Unique macro solves this limitation by allowing compound uniqueness constraints across multiple properties. This means you can specify combinations of properties that must be unique together, rather than just enforcing uniqueness on individual properties.

When using the #Unique macro, SwiftData performs an upsert operation — if a new object has the same unique values as an existing one, the existing object is updated instead of creating a duplicate or triggering an error.

Mastering SwiftData by Example in SwiftUI & iOS 18

Let’s explore these concepts with an example. We’ll create a model to track upcoming trips, which will include the following properties:

  • Name of the trip

  • Destination

  • Start date

  • End date

We’ll also add a duration property, which will be a computed property. Since it won’t be saved to the data store, we’ll mark it as Transient.

import SwiftData

@Model
final class TripsModel {
    var name: String
    var destination: String
    var startDate: Date
    var endDate: Date
    
    init(name: String = "", destination: String = "", startDate: Date = .distantPast, endDate: Date = .distantPast) {
        self.name = name
        self.destination = destination
        self.startDate = startDate
        self.endDate = endDate
    }
    
    @Transient
    var duration: Int {
        let calendar = Calendar.current
        let components = calendar.dateComponents([.day], from: startDate, to: endDate)
        return components.day ?? 0
    }
}

Let’s add sample data for previews.

extension TripsModel {
  @MainActor
  static var preview: ModelContainer {
    let container = try! ModelContainer(
      for: TripsModel.self,
      configurations: ModelConfiguration(isStoredInMemoryOnly: true)
    )
     
    container.mainContext.insert(
      TripsModel(
        name: "Summer Trip",
        destination: "Paris",
        startDate: .now,
        endDate: .now.addingTimeInterval(1_000_000)
      )
    )

    container.mainContext.insert(
      TripsModel(
        name: "Winter Getaway",
        destination: "Tokyo",
        startDate: .now.addingTimeInterval(2_000_000),
        endDate: .now.addingTimeInterval(2_500_000)
      )
    )

    container.mainContext.insert(
      TripsModel(
        name: "Autumn Retreat",
        destination: "New York",
        startDate: .now.addingTimeInterval(-1_000_000),
        endDate: .now.addingTimeInterval(-500_000)
      )
    )

    container.mainContext.insert(
      TripsModel(
        name: "Spring Break",
        destination: "Miami",
        startDate: .now.addingTimeInterval(500_000),
        endDate: .now.addingTimeInterval(800_000)
      )
    )

    return container
  }
}

Enforcing Uniqueness

We want the name of the trip to be unique, so we’ll use @Attribute(.unique) on the name property.

import SwiftData

@Model
final class TripsModel {
  @Attribute(.unique)
  var name: String
  var destination: String
  var startDate: Date
  var endDate: Date
   
  init(name: String = "", destination: String = "", startDate: Date = .distantPast, endDate: Date = .distantPast) {
    self.name = name
    self.destination = destination
    self.startDate = startDate
    self.endDate = endDate
  }
   
  @Transient
  var duration: Int {
    let calendar = Calendar.current
    let components = calendar.dateComponents([.day], from: startDate, to: endDate)
    return components.day ?? 0
  }
}

We’ll create a UI to display a list of upcoming trips. Additionally, we’ll add a TextField and a button to allow users to add a new trip with some static data.

import SwiftUI
import SwiftData

struct TripsHomeView: View {
  @Environment(\.modelContext) private var context
  @State private var newCountry = ""
   
  @Query(sort: \TripsModel.name)
  private var trips: [TripsModel]
   
  var body: some View {
    NavigationStack {
      VStack {
        HStack {
          TextField("New", text: $newCountry)
            .textFieldStyle(.roundedBorder)
          Button("➕") {
            DispatchQueue.main.async {
              context.insert(TripsModel(name: newCountry, startDate: .now, endDate: Date.now.addingTimeInterval(1000000)))
              newCountry = ""
              try! context.save()
            }
          }
        }
        List(trips) { trip in
          VStack(alignment: .leading, spacing: 20) {
            HStack {
              Text(trip.name)
                .font(.title3.bold())
              Spacer()
              Text("\(trip.duration.description) days")
                .font(.subheadline.bold())
            }
             
            HStack {
              Text(trip.destination)
              Spacer()
              Group {
                Text(trip.startDate.formatted(date: .numeric, time: .omitted))
                Text("-")
                Text(trip.endDate.formatted(date: .numeric, time: .omitted))
              }
              .font(.caption)
            }
          }
        }
      }
      .padding()
      .navigationTitle("Vacation List")
    }
  }
}

#Preview {
  TripsHomeView()
    .modelContainer(TripsModel.preview)
}

Similar behavior can be achieved with newly introduced iOS 18 macro called #Unique.

import SwiftUI
import SwiftData

@Model
final class TripsModel {
    
    #Unique<TripsModel>([\.name]) // introduced in iOS 18
    
    
    var name: String
    var destination: String
    var startDate: Date
    var endDate: Date
    
    init(name: String = "", destination: String = "", startDate: Date = .distantPast, endDate: Date = .distantPast) {
        self.name = name
        self.destination = destination
        self.startDate = startDate
        self.endDate = endDate
    }
    
    @Transient
    var duration: Int {
        let calendar = Calendar.current
        let components = calendar.dateComponents([.day], from: startDate, to: endDate)
        return components.day ?? 0
    }
}

But this new macro is much more powerful than Attribute(.unique) where multiple properties can create a compound unique entry constraint.

import SwiftUI
import SwiftData

@Model
final class TripsModel {
  #Unique<TripsModel>([\.name, \.destination])
   
  var name: String
  var destination: String
  var startDate: Date
  var endDate: Date
   
  init(name: String = "", destination: String = "", startDate: Date = .distantPast, endDate: Date = .distantPast) {
    self.name = name
    self.destination = destination
    self.startDate = startDate
    self.endDate = endDate
  }
   
  @Transient
  var duration: Int {
    let calendar = Calendar.current
    let components = calendar.dateComponents([.day], from: startDate, to: endDate)
    return components.day ?? 0
  }
}

Let’s update the code to assign a random end date for the newly added sample trip.

import SwiftUI
import SwiftData

struct TripsHomeView: View {
    @Environment(\.modelContext) private var context
    @State private var newCountry = ""
    
    @Query(sort: \TripsModel.name)
    private var trips: [TripsModel]
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("New", text: $newCountry)
                        .textFieldStyle(.roundedBorder)
                    Button("➕") {
                        DispatchQueue.main.async {
                            context.insert(
                                TripsModel(
                                    name: newCountry,
                                    startDate: .now,
                                    endDate: Date.now.addingTimeInterval(
                                        TimeInterval(
                                            Int.random(in: 100000...999999)
                                        )
                                    )
                                )
                            )
                            newCountry = ""
                            try! context.save()
                        }
                    }
                }
                List(trips) { trip in
                    VStack(alignment: .leading, spacing: 20) {
                        HStack {
                            Text(trip.name)
                                .font(.title3.bold())
                            Spacer()
                            Text("\(trip.duration.description) days")
                                .font(.subheadline.bold())
                        }
                        
                        HStack {
                            Text(trip.destination)
                            Spacer()
                            Group {
                                Text(trip.startDate.formatted(
                                    date: .numeric,
                                    time: .omitted))
                                Text("-")
                                Text(trip.endDate.formatted(
                                    date: .numeric,
                                    time: .omitted))
                            }
                            .font(.caption)
                        }
                    }
                }
            }
            .padding()
            .navigationTitle("Vacation List")
        }
    }
}

#Preview {
    TripsHomeView()
        .modelContainer(TripsModel.preview)
}

Inserting another trip with the same name but an empty destination creates a new entry. However, adding it again updates the existing record since both entries have the same empty destination. You can observe this change through the updated trip duration.

Important Considerations

  • As of iOS 18 and Xcode 16, the #Unique macro is not compatible with CloudKit synchronization because SwiftData relies on SQLite’s constraint mechanisms.

  • For relationship attributes, SwiftData only supports unique constraints on references to single persistent models, not arrays of models.

Summary

In this post, we learned how to use the #Unique macro to create powerful compound uniqueness constraints.