Ana içeriğe geç
  1. 100 Günde SwiftUI Notları/

58.Gün - SwiftUI SwiftData: Query Sıralama ve Filtreleme, Relationship ve CloudKit Senkronizasyon

Bugün NSPredicate , fetch requtes’i dinamik olarak değiştirme, ilişki oluşturma gibi konular üzerinde çalışacağız. Bugün üzerinde çalışacağımız konu başlıkları;

  1. SwiftUI ile @Query ‘yi dinamik olarak sıralama ve filtreleme
  2. SwiftData, SwiftUI ve @Query ile ilişki
  3. SwiftData’yı CloudKit ile Senkronize etme

SwiftUI ile @Query ‘yi Dinamik Olarak Sıralama ve Filtreleme #

SwiftData’nın #Predicate ’inin nasıl çalıştığını biraz gördüğünüze göre, aklınıza gelecek bir sonraki soru muhtemelen “kullanıcı girdisiyle nasıl çalışmasını sağlayabilirim?” olacaktır. Cevap … karmaşık. Size bunun nasıl yapıldığını ve aynı tekniğin sıralamayı dinamik olarak ayarlamak için nasıl kullanılabileceğini göstereceğim, ancak nasıl yapıldığını hatırlamanız biraz zaman alacak.

Daha önce incelediğimiz SwiftData kodunu temek alırsak, her kullanıcı nesnesinin farklı bir joinDate property’si vardı, bazıları geçmişte bazıları gelecekte. Ayrıca bir query’nin sonuçlarını gösteren bir List’imiz vardı:

List(users) { user in
    Text(user.name)
}

Yapacağımız şey, bu listeyi ayrı bir view’a taşımak - özellikle SwiftData query’sini çalıştırmak ve sonuçlarını göstermek için bir view, ardından isteğe bağlı olarak tüm kullanıcıları veya yalnızca gelecekte katılacak kullanıcıları göstermesini sağlamak.

Bu nedenle, UsersView adında yeni bir SwiftUI view oluşturun, ona bir SwiftData import ekleyin, ardından List kodunu hiçbir modifier’ını dahil etmeden oraya taşıyın - sadece yukarıda gösterilen kodu.

Artık SwiftData sonuçlarını UsersView’da görüntülediğimize göre, buraya bir @Query property eklememiz gerekiyor. Bu, bir sıralama düzeni veya predicate kullanmamalıdır - en azından henüz değil. Bu yüzden, bu property’yi oraya ekleyin;

@Query var users: [User]

Ve preivew’e modelContainer() modifier’ı ekleyin. En son aşağıdaki gibi bir kod elde etmeniz gerekiyor.

import SwiftData
import SwiftUI

struct UsersView: View {
    @Query var users: [User]

    var body: some View {
        List(users) { user in
            Text(user.name)
        }
    }
}

#Preview {
    UsersView()
        .modelContainer(for: User.self)
}

Bu view’da işimiz bitmeden önce, çalıştırılan query’yi özelleştirmek için bir yola ihtiyacımız var. Bu haliyle, sadece @Query var users: [User] kullanmak, SwiftData’nın filtre ve sıralama düzeni olmadan tüm kullanıcıları yükleyeceği anlamına gelir, ancak gerçekten bunlardan birini veya her ikisini ContentView’dan özelleştirmek istiyoruz.

Bu, bir başlatıcı (initializer) kullanarak görünüme bir değer geçirmek ve ardından bu değeri kullanarak sorguyu oluşturmak suretiyle en iyi şekilde yapılır. Daha önce belirttiğim gibi, amacımız ya tüm kullanıcıları göstermek ya da sadece gelecekte katılacak olan kullanıcıları göstermektir. Bunu, minimum bir katılma tarihi geçirerek ve tüm kullanıcıların en azından o tarihten sonra katıldığından emin olarak gerçekleştireceğiz.

Bu initializer’ı UserView’e şimdi ekleyin;

init(minimumJoinDate: Date) {
    _users = Query(filter: #Predicate<User> { user in
        user.joinDate >= minimumJoinDate
    }, sort: \User.name)
}

Bu çoğunlukla alışık olduğunuz koddur, ancak users’tan önce bir alt çizgi olduğuna dikkat edin. Bu kasıtlı: User array’i değiştirmeye çalışmıyoruz, array’i üreten SwiftData query’yi değiştirmeye çalışıyoruz. Alt çizgi, Swift’in bu query’ye erişme yoludur, yani query’yi aktarılan tarihten oluşturuyoruz.

Bu noktada, UsersView ile işimiz bitti, bu yüzden şimdi ContentView‘e geri dönüp mevcut @Query property’yi silmemiz ve yerine bir tür Boolean değeri değiştiren kod koymamız ve bu değerin mevcut durumunu UsersView‘e geçirmemiz gerekiyor.

İlk olarak, ContentView‘e bu yeni @State özelliğini ekleyin:

@State private var showingUpcomingOnly = false

Ve şimdi ContentView içindeki List kodunu - yine, modifier’ları dahil etmeden - şununla değiştirin:

UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast)

Bu, UsersView‘e iki tarihten birini geçirir: Boolean özelliğimiz doğru (true) olduğunda, sadece mevcut zamandan sonra katılacak kullanıcıları göstermek için .now değerini geçiririz, aksi takdirde en az 2000 yıl öncesine denk gelen .distantPast değerini geçiririz. Kullanıcılarımız arasında bazı Roma imparatorları yoksa, hepsinin katılma tarihleri bundan çok sonra olacağı için tüm kullanıcılar gösterilecektir.

ContentView araç çubuğuna (toolbar) bunu ekleyin:

Button(showingUpcomingOnly ? "Show Everyone" : "Show Upcoming") {
    showingUpcomingOnly.toggle()
}

Bu, butonun etiketini değiştirir, böylece her zaman bir sonraki basıldığında ne olacağını yansıtır.

Bu, tüm çalışmayı tamamlar, dolayısıyla uygulamayı şimdi çalıştırırsanız, kullanıcı listesini dinamik olarak değiştirebileceğinizi göreceksiniz.

Evet, oldukça fazla iş, ancak gördüğünüz gibi mükemmel bir şekilde çalışıyor ve aynı tekniği diğer tür filtreleme işlemlerine de uygulayabilirsiniz.

Bu yaklaşım, verileri sıralamak için de aynı şekilde iyi çalışır: ContentView‘de bir sıralama tanımlayıcıları (sort descriptors) dizisini kontrol edebilir, ardından bunları UsersView‘in başlatıcısına (initializer) geçirerek sorguyu ayarlamalarını sağlayabiliriz.

İlk olarak, UsersView initializer’ı User sınıfımız için bir tür sıralama tanımlayıcısı kabul edecek şekilde yükseltmemiz gerekiyor. Bu, yine Swift’in jeneriklerini kullanır: SortDescriptor türü neyi sıraladığını bilmesi gerektiğinden, köşeli parantezler içinde User‘ı belirtmemiz gerekiyor.

UsersView başlatıcısını şu şekilde değiştirin:

init(minimumJoinDate: Date, sortOrder: [SortDescriptor<User>]) {
    _users = Query(filter: #Predicate<User> { user in
        user.joinDate >= minimumJoinDate
    }, sort: sortOrder)
}

Ayrıca, kodunuzun düzgün bir şekilde derlenmesi için önizleme kodunuzu örnek bir sıralama düzeni geçirecek şekilde güncellemeniz gerekecek:

UsersView(minimumJoinDate: .now, sortOrder: [SortDescriptor(\User.name)])
    .modelContainer(for: User.self)

ContentView‘e geri dönün ve mevcut sıralama düzenini saklamak için başka bir yeni özellik ekleyin. Bunu önce isim sonra katılma tarihini kullanacak şekilde yapacağız, bu mantıklı bir varsayılan gibi görünüyor:

@State private var sortOrder = [
    SortDescriptor(\User.name),
    SortDescriptor(\User.joinDate),
]

Ardından bunu, katılma tarihiyle yaptığımız gibi UsersView‘e geçirebiliriz:

UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast, sortOrder: sortOrder)

Ve son olarak bu diziyi dinamik olarak ayarlayacak bir yola ihtiyacımız var. Bir seçenek, iki seçenek gösteren bir Picker kullanmaktır: İsme Göre Sırala ve Katılma Tarihine Göre Sırala. Bu kendi başına zor değil, ancak her seçeneğe nasıl bir SortDescriptor dizisi ekleyeceğiz?

Cevap, tag() adında kullanışlı bir modifier’da yatıyor. Bu, her picker seçeneğine kendi seçtiğimiz belirli değerleri eklememize olanak tanır. Burada bu, her seçeneğin tag’ını kendi SortDescriptor dizisi yapabileceğimiz anlamına gelir ve SwiftUI otomatik olarak bu etiketi sortOrder özelliğine atayacaktır.

Bunu toolbar’a eklemeyi deneyin:

Picker("Sort", selection: $sortOrder) {
    Text("Sort by Name")
        .tag([
            SortDescriptor(\User.name),
            SortDescriptor(\User.joinDate),
        ])

    Text("Sort by Join Date")
        .tag([
            SortDescriptor(\User.joinDate),
            SortDescriptor(\User.name)
        ])
}

Şimdi uygulamayı çalıştırdığınızda, muhtemelen beklediğinizi görmeyeceksiniz. Kullandığınız cihaza bağlı olarak, “Sort” (Sırala) seçeneğini içinde seçenekler bulunan bir menü olarak göstermek yerine, şunlardan birini göreceksiniz:

  1. Bir daire içinde üç nokta ve buna basıldığında seçenekler görünür.
  2. Navigasyon çubuğunda doğrudan “Sort by Name” (İsme Göre Sırala) gösterilir ve buna dokunulduğunda Katılma Tarihine geçmenize izin verilir.

Her iki seçenek de harika değil, ancak bu fırsatı başka bir kullanışlı SwiftUI görünümü olan Menu‘yü tanıtmak için kullanmak istiyorum. Bu, navigasyon çubuğunda menüler oluşturmanıza olanak tanır ve içine düğmeler, seçiciler ve daha fazlasını yerleştirebilirsiniz.

Bu durumda, mevcut Picker kodumuzu bir Menu ile sararsak, çok daha iyi bir sonuç alacağız. Bunu deneyin:

Menu("Sort", systemImage: "arrow.up.arrow.down") {
    // current picker code
}

Tekrar deneyin ve çok daha iyi olduğunu göreceksiniz. Daha da önemlisi, hem dinamik filtrelememiz hem de sıralamamız artık harika çalışıyor!

SwiftData, SwiftUI ve @Query ile ilişki #

SwiftData, birbirine referans veren modeller oluşturmamıza izin verir. Örneğin, bir School (Okul) modelinin birçok Student (Öğrenci) nesnesinden oluşan bir array’e sahip olduğunu veya bir Employee (Çalışan) modelinin bir Manager (Yönetici) nesnesi depoladığını söyleyebiliriz.

Bunlara ilişkiler denir ve çeşitli biçimlerde gelirler. SwiftData, ne istediğinizi söylediğiniz sürece bu ilişkileri otomatik olarak oluşturmada iyi bir iş çıkarır, ancak yine de bazı sürprizlere yer vardır!

Şimdi bunları deneyelim. Halihazırda aşağıdaki User (Kullanıcı) modeline sahibiz:

@Model
class User {
    var name: String
    var city: String
    var joinDate: Date

    init(name: String, city: String, joinDate: Date) {
        self.name = name
        self.city = city
        self.joinDate = joinDate
    }
}

Bunu, her User (Kullanıcı)’nın kendisine bağlı bir job array’e sahip olabileceğini söyleyecek şekilde genişletebiliriz - işlerinin bir parçası olarak tamamlamaları gereken görevler. Bunu yapmak için, öncelikle şu şekilde yeni bir Job (İş) modeli oluşturmamız gerekiyor:

@Model
class Job {
var name: String
var priority: Int
var owner: User?

init(name: String, priority: Int, owner: User? = nil) {
self.name = name
self.priority = priority
self.owner = owner
    }
}

owner (sahip) property’nin doğrudan User (Kullanıcı) modeline nasıl referans verdiğine dikkat edin - SwiftData’ya iki modelin birbirine bağlı olduğunu açıkça söyledim.

Ve şimdi User (Kullanıcı) modelini job array oluşturacak şekilde ayarlayabiliriz:

var jobs = [Job]()

Yani, işlerin bir sahibi var ve kullanıcıların bir job array’i var - ilişki çift yönlü, ki bu genellikle iyi bir fikirdir çünkü verilerinizle çalışmayı kolaylaştırır.

Bu array hemen çalışmaya başlayacak: SwiftData, bir kullanıcının tüm işlerini ilk talep edildiklerinde yükleyecek, dolayısıyla hiç kullanılmazlarsa bu işi atlayacaktır.

Daha da iyisi, uygulamamız bir sonraki kez başlatıldığında SwiftData sessizce jobs (işler) özelliğini tüm mevcut kullanıcılarına ekleyecek, onlara varsayılan olarak boş bir dizi verecektir. Bu, bir migration (göç) olarak adlandırılır: ihtiyaçlarımız zaman içinde geliştikçe modellerimizde özellikler eklediğimizde veya sildiğimizde. SwiftData bunun gibi basit migration’ları otomatik olarak yapabilir, ancak ilerledikçe daha büyük model değişikliklerini ele almak için özel migration’lar nasıl oluşturabileceğinizi öğreneceksiniz.

İpucu: App struct’ımızda modelContainer() modifier’ını kullandığımızda, SwiftData’nın bu model için depolama alanı ayarlaması gerektiğini bilmesi için User.self‘i geçtik. Job.self‘i oraya eklememize gerek yok çünkü SwiftData ikisi arasında bir ilişki olduğunu görebiliyor, bu yüzden ikisiyle de otomatik olarak ilgileniyor.

Verilerinizi yüklemek için kullandığınız @Query‘i değiştirmenize gerek yok, sadece array’i normal şekilde kullanmaya devam edin. Örneğin, kullanıcıların bir listesini ve iş sayılarını şu şekilde gösterebiliriz:

List(users) { user in
    HStack {
        Text(user.name)

        Spacer()

        Text(String(user.jobs.count))
            .fontWeight(.black)
            .padding(.horizontal, 10)
            .padding(.vertical, 5)
            .background(.blue)
            .foregroundStyle(.white)
            .clipShape(.capsule)
    }
}

Eğer bazı gerçek verilerle çalıştığını görmek isterseniz, seçili kullanıcı için yeni Job (İş) instance’ları oluşturacak bir SwiftUI view oluşturabilirsiniz, ancak test amaçları için küçük bir kısayol alıp bazı örnek veriler ekleyebiliriz.

İlk olarak, aktif SwiftData model context’e erişmek için bir property ekleyin:

@Environment(\.modelContext) var modelContext

Ve şimdi bazı örnek veriler oluşturmak için şöyle bir yöntem ekleyin:

func addSample() {
let user1 = User(name: "Piper Chapman", city: "New York", joinDate: .now)
let job1 = Job(name: "Organize sock drawer", priority: 3)
let job2 = Job(name: "Make plans with Alex", priority: 4)

    modelContext.insert(user1)

    user1.jobs.append(job1)
    user1.jobs.append(job2)
}

Yine, bu kodun neredeyse tamamının sadece normal Swift olduğuna dikkat edin - sadece bir satır gerçekten SwiftData ile ilgili.

Bunu hemen görmek için List’e aşağıdaki modifier’ı ekleyin;

.onAppear(perform: addSample)

Burada biraz deney yapmayı denemenizi teşvik ediyorum. Başlangıç noktanız her zaman verilerinizle çalışmanın sadece normal bir @Observable sınıfıyla çalışmak gibi olduğunu varsaymak olmalıdır - başka türlü yapmak için bir nedeniniz olana kadar SwiftData’nın kendi işini yapmasına izin verin!

Yine de bir küçük sorun var ve devam etmeden önce bunu ele almaya değer: User (Kullanıcı) ve Job (İş)‘i bir kullanıcının yapılacak birçok işi olabilecek şekilde bağladık, peki bir kullanıcıyı silersek ne olur?

Cevap şu ki, tüm işleri intact kalır - silinmezler. Bu, SwiftData’dan akıllıca bir hamledir, çünkü sürpriz veri kaybı yaşamazsınız.

Eğer özellikle bir kullanıcının tüm iş nesnelerinin aynı anda silinmesini istiyorsanız, bunu SwiftData’ya söylememiz gerekiyor. Bu, bir @Relationship makrosu kullanılarak yapılır ve sahibi olan User silindiğinde Job nesnelerinin nasıl ele alınması gerektiğini açıklayan bir silme kuralı sağlanır.

Varsayılan silme kuralı .nullify olarak adlandırılır, bu da her Job nesnesinin owner özelliğinin nil olarak ayarlandığı, yani sahibi olmadığının işaretlendiği anlamına gelir. Bunu .cascade olarak değiştireceğiz, yani bir User silmek otomatik olarak tüm Job nesnelerini silmelidir. Buna cascade (kaskad) denir çünkü silme işlemi tüm ilgili nesneler için devam eder - örneğin, Job nesnemizin bir locations ilişkisi varsa, bunlar da silinirdi ve bu böyle devam ederdi.

Dolayısıyla, User‘daki jobs özelliğini şöyle değiştirin:

@Relationship(deleteRule: .cascade) var jobs = [Job]()

Ve artık açık oluyoruz, yani bir kullanıcıyı silerken herhangi bir gizli Job (İş) nesnesi bırakmıyoruz - çok daha iyi!

SwiftData’yı CloudKit ile Senkronize etme #

SwiftData, tüm kullanıcı verilerinizi iCloud ile senkronize edebilir ve en güzel yanı, bu genellikle hiç kod yazmayı gerektirmez.

Başlamadan önce önemli bir uyarı var: Verileri iCloud’a senkronize etmek aktif bir Apple geliştirici hesabı gerektirir. Eğer bir hesabınız yoksa, aşağıdakiler çalışmayacaktır.

Hâlâ buradasınız, değil mi? Tamam, yerel SwiftData depolamasından iCloud’a veri senkronize etmek için, uygulamanızda iCloud özelliğini etkinleştirmeniz gerekiyor. Uygulama yeteneklerini daha önce özelleştirmemiştik, bu yüzden bu adım yeni.

Öncelikle, proje gezgininizin en üstündeki “SwiftDataTest” uygulama simgesine tıklayın. Bu, SwiftDataTest grubunun hemen üzerinde olmalıdır.

İkinci olarak, “TARGETS” listesinin altındaki “SwiftDataTest"i seçin. Bir sürü sekme görmelisiniz: General, Signing & Capabilities, Resource Tags, Info ve daha fazlası. Biz Signing & Capabilities istiyoruz, bu yüzden şimdi lütfen onu seçin.

Üçüncü olarak, “+ CAPABILITY” düğmesine basın ve iCloud’u seçin; bu, iCloud’un aktif yetenekler listesinde görünmesini sağlayacaktır – üç hizmetin mümkün olduğunu, bir “CloudKit Console” düğmesi ve daha fazlasını göreceksiniz.

Dördüncü olarak, CloudKit işaretli kutuyu işaretleyin; bu, uygulamamızın SwiftData bilgilerini iCloud’da depolamasına izin verir. Ayrıca, verilerin iCloud’da aslında nerede depolandığını yapılandıran yeni bir CloudKit kapsayıcısı eklemek için + düğmesine basmanız gerekecek. Burada uygulamanızın paket kimliği ön ekini “iCloud.” ile kullanmalısınız, örneğin iCloud.com.hackingwithswift.swiftdatatest.

Beşinci olarak, “+ CAPABILITY” düğmesine tekrar basın ve Background Modes yeteneğini ekleyin. Bu, bir sürü yapılandırma seçeneğine sahiptir, ancak sadece “Remote Notifications” kutusunu işaretlemeniz yeterlidir – bu, veriler iCloud’da değiştiğinde uygulamanın haberdar olmasını sağlar, böylece yerel olarak senkronize edilebilir.

Ve işte bu kadar – uygulamanız, SwiftData’yı senkronize etmek için iCloud kullanmaya hazır.

Belki.

Görüyorsunuz, iCloud ile SwiftData’nın yerel SwiftData’nın gerektirmediği bir gereksinimi var: tüm property’ler optional olmalı veya varsayılan değerlere sahip olmalı ve tüm ilişkiler optional olmalıdır. İlk gereklilik küçük bir sıkıntı, ancak ikincisi çok daha büyük bir sıkıntı – kodunuz için oldukça rahatsız edici olabilir.

Ancak bunlar öneri değil, gerekliliktir. Bu nedenle, Job örneğinde property’leri bu şekilde ayarlamamız gerekecektir:

var name: String = "None"
var priority: Int = 1
var owner: User?

Ve User için, bunu kullanmamız gerekecek:

var name: String = "Anonymous"
var city: String = "Unknown"
var joinDate: Date = Date.now
@Relationship(deleteRule: .cascade) var jobs: [Job]? = [Job]()

Önemli: Bu değişiklikleri yapmazsanız, iCloud basitçe çalışmayacaktır. Xcode’un günlüklerine bakarsanız – ve CloudKit, Xcode’un günlüklerine yazmayı çok sever – en üst kısımlara yakın kaydırdığınızda, SwiftData, herhangi bir özelliğin iCloud senkronizasyonunun doğru çalışmasını engellediğinde sizi uyarmaya çalışmalıdır.

Modellerinizi ayarladıktan sonra, kodunuzun optional’ları doğru bir şekilde ele alacak şekilde değiştirilmesi gerekir. Örneğin, bir kullanıcıya iş eklerken optional chaning şöyle kullanılabilir:

user1.jobs?.append(job1)
user1.jobs?.append(job2)

Ve bir kullanıcının iş sayısını okumak, optional chaining ve nil coalescing kullanılarak şöyle yapılabilir:

Text(String(user.jobs?.count ?? 0))

Projede bu tür kodu her yere yaymanın pek de büyük bir hayranı değilim, bu yüzden jobs’u düzenli olarak kullanıyorsam, unwrappedJobs veya benzeri bir isimde salt okunur bir computed property oluşturmayı tercih ederim – bu property, değeri varsa jobs döndürür, aksi takdirde boş bir array döner, şöyle:

var unwrappedJobs: [Job] {
    jobs ?? []
}

Bu küçük bir ayrıntı, ancak kodun geri kalanını daha düzgün hale getirmeye yardımcı olur ve salt okunur hale getirmek, eksik bir array’i yanlışlıkla değiştirmeye çalışmanızı engeller.

Önemli: Simülatör, yerel SwiftData uygulamalarını test etmek için oluşturulmuştur, ancak iCloud’u test etmede oldukça yetersizdir – verilerinizin doğru, hızlı veya hiç senkronize edilmediğini görebilirsiniz. Sorunlardan kaçınmak için lütfen gerçek bir cihaz kullanın!


Bu yazıyı İngilizce olarak da okuyabilirsiniz.
You can also read this article in English.

Bu yazı, SwiftUI Day 58 adresinde bulunan yazılardan kendim için aldığım notları içermektedir. Orjinal dersi takip etmek için lütfen bağlantıya tıklayın.