I would like to use Contacts in an app. The user should select them and I will store the name and the id in the Core Data database.
When the Contact gets edited from the iOS Contacts app I would like to update the Core Data entities. When doing this I get the purple warning: "This method should not be called on the main thread as it may lead to UI unresponsiveness." (see comment in code) when fetching the contacts.
How to use the right threads to do it how it should be done? Im not really familiar with the best practices. Do I have to go back to the main thread for UI related things or is this not necessary here because of the #FetchRequest in other parts of the app where the saved contacts from Core Data are used?
class KontakteUpdater{
func update(moc: NSManagedObjectContext){
var contacts: [CNContact] = []
let key = [CNContactGivenNameKey,CNContactFamilyNameKey] as [CNKeyDescriptor]
let Kontakt_request = CNContactFetchRequest(keysToFetch: key)
// get contacts from the store
do{
try CNContactStore().enumerateContacts(with: Kontakt_request, usingBlock: { (contact, stoppingPointer) in // here with warning
contacts.append(contact)
})
}
catch
{
print(error)
}
// fetch currently saved contacts from core data
var kontakteUpdate: [Kontakt] = []
let request = NSFetchRequest<Kontakt>(entityName: "Kontakt")
do{
kontakteUpdate = try moc.fetch(request)
}
catch let error{
print("Error fetching. \(error)")
}
// update saved contacts in core data
for k in kontakteUpdate{
if let c = contacts.first(where: {contact in
contact.identifier == k.id
}){
k.vorname = c.givenName
k.nachname = c.familyName
}
}
do{
try moc.save()
}
catch{
print("\(error) - Saving didn't succeed")
}
}
}
Here is the entry point of the sample app:
import SwiftUI
import CoreData
#main
struct testApp: App {
let pub = NotificationCenter.default
.publisher(for: NSNotification.Name.CNContactStoreDidChange)
let kontakteUpdater = KontakteUpdater()
let persistenceContainer = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.onReceive(pub, perform: {output in
kontakteUpdater.update(moc: persistenceContainer.container.viewContext)
})
.environment(\.managedObjectContext, persistenceContainer.container.viewContext)
}
}
}
Related
Whenever I am using a pre-configured NSFetchRequest like so:
extension Note {
static var requestPrivateDBNotesByDate: NSFetchRequest<Note> {
let request = Note.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Note.createdDate, ascending: true)]
request.affectedStores = [PersistenceController.shared.privatePersistentStore]
return request
}
to do a #FetchRequest within a SwiftUI view:
#FetchRequest(fetchRequest: Note.requestPrivateDBNotesByDate)
private var notes: FetchedResults<Note>
the SwiftUI view is not updating when I add a Note entity to CoreData:
func addNote(name: String, context: NSManagedObjectContext) {
context.perform {
let note = Note(context: context)
note.displayName = name
note.createdDate = .now
try? context.save()
}
}
If I use a simple #FetchRequest within my SwiftUI view like so:
#FetchRequest(sortDescriptors: [SortDescriptor(\.displayName, order: .forward)]
) private var notes: FetchedResults<Note>
the view updates whenever I add a now Note.
Why is the pre-configured #FetchRequest not updating my SwiftUI view?
Note: I can force a view update by adding context.refresh(chat, mergeChanges: false) after context.save() but then my question would be, why do I need to force a refresh with a pre-configured #FetchRequest while it is not necessary with a simple #FetchRequest.
Is the forced refresh the only/correct way to go?
Am I missing something?
Update:
This is how I get the privatePersistentStore for the affectedStores property in the pre-configured NSFetchRequest.
var privatePersistentStore: NSPersistentStore {
var privateStore: NSPersistentStore?
let descriptions = persistentContainer.persistentStoreDescriptions
for description in descriptions {
if description.cloudKitContainerOptions?.databaseScope == .private {
guard let url = description.url else { fatalError("NO STORE URL!") }
guard let store = persistentContainer.persistentStoreCoordinator.persistentStore(for: url) else { fatalError("NO STORE!") }
privateStore = store
}
}
guard let privateStore else { fatalError("NO PRIVATE STORE!") }
return privateStore
}
you forgot to assign the new note to the store you are fetching from, e.g.
context.assign(to: PersistenceController.shared.privatePersistentStore)
try? context.save()
This bounty has ended. Answers to this question are eligible for a +100 reputation bounty. Bounty grace period ends in 23 hours.
Bartłomiej Semańczyk is looking for a canonical answer.
This is my code what I do on appear:
import SwiftUI
import CloudKit
#main
struct DemoApp: App {
var isDownloading = false
#Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
// some content
}
.onChange(of: scenePhase) { phase in
if case .active = phase {
Task {
await loadData() // this loads should be done on background thread (I think), that is all.
}
}
}
}
private let container = CKContainer(identifier: "iCloud.pl.myapp.identifier")
private var privateDatabase: CKDatabase {
return container.privateCloudDatabase
}
private func loadData() async {
if !isDownloading {
isDownloading = true
var awaitingChanges = true
var changedRecords = [CKRecord]()
var deletedRecordIDs = [CKRecord.ID]()
let zone = CKRecordZone(zoneName: "fieldservice")
var token: CKServerChangeToken? = nil
do {
while awaitingChanges {
let allChanges = try await privateDatabase.recordZoneChanges(inZoneWith: zone.zoneID, since: token)
let changes = allChanges.modificationResultsByID.compactMapValues { try? $0.get().record }
changes.forEach { _, record in
changedRecords.append(record)
print("Fetching \(changedRecords.count) private records.")
// update ui here with printed info
}
let deletetions = allChanges.deletions.map { $0.recordID }
deletetions.forEach { recordId in
deletedRecordIDs.append(recordId)
print("Fetching \(changedRecords.count) private records.")
// update ui here with printed info
}
token = allChanges.changeToken
awaitingChanges = allChanges.moreComing
}
isDownloading = false
print("in future all records should be saved to core data here")
} catch {
print("error \(error)")
isDownloading = false
}
}
}
}
This is simplified code as much as it can be to better understand the problem.
My Apple Watch when I run the app FIRST TIME it must fetch all cloudkit record changes (but in my opinion it doesn't matter what actually is the task). To make it finished it needs to download ~3k records and it takes ~5-6 minutes on the watch. Downloading is in progress ONLY when an app is in ACTIVE mode. When it changes to INACTIVE (after ~10-11 seconds) it stops downloading and I have to move my wrist to make it ACTIVE again to continue downloading.
I need to change it to not to stop downloading when app is in INACTIVE mode. It should continue downloading until it is finished. While it is being downloaded I update UI with info for example "Fetched 1345 records", "Fetched 1346 records" and so on... When app is inactive it is downloaded and when I make an app ACTIVE I can see the current status of download.
What do I do inside loadData? I simply fetch all CloudKit changes starting with serverChangeToken = nil. It takes ~ 5-6 minutes for ~3k records.
In the following code I have a List of Cars and each Car from that list has its own list of Services, I can add and delete Cars without a problem by calling carViewModel.addNewCar(make:String, model:String) and carViewModel.deleteCar(at indexSet:IndexSet).
Car.swift
import RealmSwift
final class Car: Object, ObjectKeyIdentifiable{
#objc dynamic var make: String = ""
#objc dynamic var model: String = ""
// creation date, ID etc.
dynamic var services = List<CarService>()
}
CarList.swift
import RealmSwift
final class CarList: Object, ObjectKeyIdentifiable{
#objc dynamic var name: String = ""
// creation date, ID etc.
var cars = RealmSwift.List<Car>()
}
CarService.swift
import RealmSwift
final class CarService: Object, ObjectKeyIdentifiable{
#objc dynamic var serviceName: String = ""
// creation date, ID etc.
}
View Model
import RealmSwift
class CarViewModel: ObservableObject{
#Published var cars = List<Car>()
#Published var selectedCarList: CarList? = nil
var token: NotificationToken? = nil
init(){
// Create a the default lists if they don't already exist.
createDefaultCarList()
createDefaultServiceList()
// Initialize the SelectedCarList and the cars variables items from the Default Car List.
if let list = realm?.objects(CarList.self).first{
self.selectedCarList = list
self.cars = list.cars
}
token = selectedCarList?.observe({ [unowned self] (changes) in
switch changes{
case .error(_): break
case.change(_, _):self.objectWillChange.send()
case.deleted: self.selectedCarList = nil
}
})
}
func addNewCar(make:String, model:String){
if let realm = selectedCarList?.realm{
try? realm.write{
let car = Car()
car.make = make
car.model = model
selectedCarList?.cars.append(car)
}
}
}
func deleteCar(at indexSet:IndexSet){
if let index = indexSet.first,
let realm = cars[index].realm{
try? realm.write{
realm.delete(cars[index])
}
}
}
func addService(toCar: Car, serviceName: String){
try? realm?.write{
let service = CarService()
service.serviceName = serviceName
toCar.services.append(service)
}
}
/// Creates the Default Car List if it doesn't already exists otherwise just prints the error.
func createDefaultCarList(){
do {
if (realm?.objects(CarList.self).first) == nil{
try realm?.write({
let defaultList = CarList()
defaultList.name = "Default Car List"
realm?.add(defaultList)
})
}
}catch let error{
print(error.localizedDescription)
}
}
/// Creates the Default Serivice List if it doesn't already exists otherwise just prints the error.
func createDefaultServiceList(){
do {
if (realm?.objects(ServiceList.self).first) == nil{
try realm?.write({
let defaultList = ServiceList()
defaultList.listName = "Default Service List"
realm?.add(defaultList)
})
}
}catch let error{
print(error.localizedDescription)
}
}
}
My issue is adding or deleting Services to existing Cars. When I call carViewModel.addService(toCar: Car, serviceName: String) I get the error below...
Calling the addService() method.
struct NewServiceFormView: View {
#ObservedObject var carViewModel: CarViewModel
#State var selectedCar:Car // pass from other cars view
var body: some View {
NavigationView {
Form {
// fields
}
.navigationBarItems( trailing:Button("Save", action: addNewCar))
}
}
func addNewCar(){
carViewModel.addService(toCar: selectedCar, serviceName: "Oil Change")
}
}
Error
"Cannot modify managed RLMArray outside of a write transaction."
I can add new Services by explicitly selecting a Car from the cars list. I don't get any errors but the UI doesn't update; I don't see the newly added Service until the app is relaunched.
No errors doing it this way but the UI doesn't update.
carViewModel.addService(toCar: carViewModel.cars[1], serviceName: "Rotors")
How can I properly watch, delete and add Services to existing Cars?
EDIT: Added the following code per Mahan's request.
View to present the NewServiceFormView
struct CarServicesView: View {
#State var selectedCar:Car // a car from parent view
#ObservedObject var carViewModel: CarViewModel
var body: some View {
VStack{
List {
Section(header: Text("Services: \(selectedCar.services.count)")) {
ForEach(selectedCar.services) { service in
}
}
}
.listStyle(GroupedListStyle())
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: openNewServiceForm) {
Image(systemName: "plus")
}
}
}
}.sheet(isPresented: $newServiceFormIsPresented){
NewServiceFormView(carViewModel: carViewModel, selectedCar: selectedCar)
}
}
func openNewServiceForm() {
newServiceFormIsPresented.toggle()
}
}
One issue is how the Realm objects are being observed - remember they are ObjC objects under the hood so you need to use Realm observers. So this
#ObservedObject var carViewModel: CarViewModel
should be this
#ObservedRealmObject var carViewModel: CarViewModel
See the documentation for observedRealmObject
Also, keep in mind if you're observing a Results, the same thing applies, use
#ObservedResults
as shown in the documentation
I've made a widget which fetches Codable data and it's working just fine in the simulator ONLY. The widget updates within 30 seconds or less after the data has changed. I've set a 5 minute update limit (I understand it's called far less frequently). It's working actually really great in the simulator without any kind of background data fetches and updates in less time than I set in getTimeline. Then I ran into an issue on a a real test device.
The data won't update anywhere between 2-10+ mins while testing a real device, in the snapshot it's updated and can see the new data changes but not in the widget on springboard. I don't understand why the simulator works just fine but not a real device. The Widget is definitely being updated when the data changes but only in the Simulator so am I suppose to fetch data in the background?
I've come across this Keeping a Widget Up To Date | Apple Developer Documentation. I'm still very new to Swift and SwiftUI so this is a little bit harder for me to grasp. I'm trying to understand the section Update After Background Network Requests Complete to update my Codeable data. My guess is the simulator is different from a real device and I need to fetch data in the background for the must up to date data?
The end goal is to have the widget update as frequently as possible with the most current data. I'm not sure I even need the background data fetch?
My data model for my widget as an example (which is working fine)
class DataModel {
var data: DataClass = DataClass(results: []))
func sessions(_ completion: #escaping (DataClass -> Void) {
guard let url = URL(string: "URL HERE") else { return }
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Accept")
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
if let response = try? JSONDecoder().decode(DataClass.self, from: data) {
self.data = response
completion(self.data)
WidgetCenter.shared.reloadTimelines(ofKind: "Widget")
}
}
}
.resume()
}
}
My getTimeline calling the data model
func getTimeline(in context: Context, completion: #escaping (Timeline<Entry>) -> ()) {
let model = DataModel()
var entries: [SimpleEntry] = []
let currentDate = Date()
let entryDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!
let entry = SimpleEntry(date: entryDate, model: model)
entries.append(entry)
model.sessions {_ in
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
I have this for my background network request
import Foundation
import WidgetKit
class BackgroundManager : NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
var completionHandler: (() -> Void)? = nil
private lazy var urlSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "widget-bundleID")
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
func update() {
let task = urlSession.downloadTask(with: URL(string: "SAME URL FROM DATA MODEL HERE")!)
task.resume()
}
func urlSession(_ session: URLSession ,downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
print (location)
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
self.completionHandler!()
WidgetCenter.shared.reloadTimelines(ofKind: "Widget")
print("Background update")
}
}
Then in my Widget I set .onBackgroundURLSessionEvents(). I never see any background updates or errors in the console. This seems very wrong, the Codable data will never be updated? How do I properly update my data in the background?
struct Some_Widget: Widget {
let kind: String = "Widget"
let backgroundData = BackgroundManager()
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
SomeWidget_WidgetEntryView(entry: entry)
}
.configurationDisplayName("Widget")
.description("Example widget.")
.onBackgroundURLSessionEvents { (sessionIdentifier, completion) in
if sessionIdentifier == self.kind {
self.backgroundData.update()
self.backgroundData.completionHandler = completion
print("background update")
}
}
}
}
I've seen a dozen or so tutorials on how to use Combine and receive a Notification of a task being completed. It seems they all show linear code - the publisher and receiver all in the same place, one row after another.
Publishing a notification is as easy as the code below:
// background download task complete - notify the appropriate views
DispatchQueue.main.async {
NotificationCenter.default.post(name: .dataDownloadComplete, object: self, userInfo: self.dataCounts)
}
extension Notification.Name {
static let dataDownloadComplete = Notification.Name("dataDownloadComplete")
}
SwiftUI has the onReceive() modifier, but I can't find any way to connect the above to a "listener" of the posted notification.
How does a View receive this Notification
FYI, after several days of reading and putting together the confusing-to-me Combine tutorials, I discovered these two methods of receiving the notification. For ease, they are included in the same View. (Some not-related details have been omitted.)
In my case, a fetch (performed on a background thread) is batch loading info into Core Data for several entities. The View was not being updated after the fetch completed.
// in the background fetch download class
...
var dataCounts: [DataSources.Source : Int] = [:]
...
// as each source of data has been imported
self.dataCounts[source] = numberArray.count
...
// in a view
import Combine
struct FilteredPurchasesView: View {
private var downloadCompletePublisher: AnyPublisher<Notification, Never> {
NotificationCenter.default
.publisher(for: .dataDownloadComplete)
.eraseToAnyPublisher()
}
private var publisher = NotificationCenter.default
.publisher(for: .dataDownloadComplete)
.map { notification in
return notification.userInfo as! [DataSources.Source : Int]
}
.receive(on: RunLoop.main)
var body: some View {
List {
ForEach(numbers.indices, id: \.self) { i in
NavigationLink(destination: NumberDetailView(number: numbers[i])) {
NumberRowView(number: numbers[i])
}
.id(i)
}
}
.add(SearchBar(searchText: $numberState.searchText))
.onReceive(downloadCompletePublisher) { notification in
print("dataDownload complete (FilteredPurchasesView 1)")
if let info = notification.userInfo as? [DataSources.Source:Int], let purchaseCount = info[DataSources.Source.purchased] {
if purchaseCount > 0 {
// now the view can be updated/redrawn
} else {
print("purchase update count = 0")
}
}
}
.onReceive(publisher) { notification in
print("dataDownload complete (FilteredPurchasesView 2)")
}
}
}
Some notes about this:
During the first couple of attempts, the FilteredPurchasesView had not yet been initialized. This meant there was no Subscriber to listen for the posted notification.
Both of the Publisher vars are available. As of this writing, I cannot explain why or how they work.
Both onReceive() modifiers contain the notification.
Comments, ideas and feedback welcome.