SwiftUI not updating View after changing attribute value of a relationship entity - swiftui

I have two core data entities, a Deck and and Card. The Deck has a relationship with Card (one Deck to many Card). In SwiftUI after changing the attribute of Card in a different screen, the list of Card does not update its view to reflect the change.
struct DeckDetailView: View {
#ObservedObject var deck: Deck
#State private var showSheet : Bool = false
#Environment(\.managedObjectContext) private var viewContext
var body: some View {
Form{
ForEach(deck.cards?.allObjects as? [Card] ?? []){ card in
NavigationLink {
CardDetailView(card: card) { returnedCard in
updateCard(card: returnedCard)
}
} label: {
Text("Question: \(card.question)")
}
}
}
.navigationTitle("\(deck.name)")
.toolbar{
Button {
showSheet.toggle()
} label: {
Label("Add Deck", systemImage: "plus")
}
.sheet(isPresented: $showSheet) {
NavigationStack{
AddCardView { cardData in
addCard(cardData: cardData)
}
}
}
}
}
private func updateCard(card: Card){
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
func addCard(cardData: CardSO){
let card = Card(context: viewContext)
card.id = UUID().uuidString
card.question = cardData.question
card.answer = cardData.answer
deck.addToCards(card)
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}

Excellent question! I have faced this same issue many times. One of the ways, I ended up solving the issue is by using #FetchRequest property wrapper. I would pass Deck to the detail screen but use the Deck object to perform a new FetchRequest to get all the cards. When you use #FetchRequest, it will automatically track the changes.
I wrote a similar article on this issue that you can check out below:
https://azamsharp.com/2023/01/30/active-record-pattern-swiftui-core-data.html. See the section "Creating, Updating and Reading Reminders".
struct MyListDetailView: View {
let myList: MyList
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [])
private var reminderResults: FetchedResults<Reminder>
init(myList: MyList) {
self.myList = myList
_reminderResults = FetchRequest(fetchRequest: Reminder.byList(myList: myList))
}
extension Reminder: Model {
static func byList(myList: MyList) -> NSFetchRequest<Reminder> {
let request = Reminder.fetchRequest()
request.sortDescriptors = []
request.predicate = NSPredicate(format: "list = %#", myList)
return request
}
}

Related

Why does NavigationLink make my app crash when there are no errors?

I am making a simple notes app. When I press a new note I want a text editor to appear for that note. I have had trouble making this run. I finally managed to build the app without errors but it crashes so clearly I didn't solve anything. I know I must have made a logic error somewhere, but I still don't understand why the app builds but crashes when I press a note.
I think the problem has to do with my NavigationLink, more specifically the destination of NoteEditor. Here is my content view:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Note.name, ascending: true)])
var notes: FetchedResults<Note>
#State private var NotesIds: Set<Note.ID> = []
private var selectedNote: Note?{
guard let NoteId = NotesIds.first,
let selectedNote = notes.filter ({$0.id == selectedNoteId}).first else{
return nil
}
return selectedNote
}
var body: some View {
NavigationView{
List(notes, selection: $NotesIds){ note in
NavigationLink(note.name, destination: NoteEditor(note: Note())) //where I think problem is
}
if let note = selectedNote{
Text(note.text)
} else{
Text("No note selected")
.foregroundColor(.secondary)
}
}
.listStyle(SidebarListStyle())
.onDeleteCommand(perform: deleteSelectedQuickNotes)
.toolbar{
ToolbarItem(placement: .primaryAction){
Button(action: createQuickNote){
Label("Create new Note", systemImage: "square.and.pencil")
}
}
}
}
private func createNote(){
createNote(name: "New note", text: "")
}
private func createNote(name: String, text: String) {
withAnimation {
let note = Note(context: viewContext)
note.id = UUID()
note.name = name
note.text = text
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteSelectedNotes(){
let selectedNotes = notes.filter {NotesIds.contains($0.id)}
deleteNotes(notes: selectedNotes)
}
private func deleteNotes(notes: [Note]){
viewContext.perform{ notes.forEach(viewContext.delete)}
}
}
Note Editor:
struct QuickNoteEditor: View {
#Environment(\.managedObjectContext) var viewContext
#ObservedObject var note: Note
var body: some View {
TextEditor(text:$note.text)
.onReceive(note.publisher(for: \.text), perform: setName)
.onReceive(
note.publisher(for: \.text)
.debounce(for: 0.5, scheduler: RunLoop.main)
.removeDuplicates()
){ _ in
try? PersistenceController.shared.saveContext()
}
.navigationTitle(note.name)
}
func setName(from text: String){
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
if text.count > 0{
note.name = String(text.prefix(20))
} else {
note.name = "New Note";
}
}
}
Persistence Controller (not sure if relevant)
final class PersistenceController{
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Notes")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { description, error in
if let error = error {
fatalError("cannot load data \(error)")
}
}
}
public func saveContext(backgroundContext: NSManagedObjectContext? = nil) throws{
let context = backgroundContext ?? container.viewContext
guard context.hasChanges else { return}
try context.save()
}
}
NoteEditor(note: Note()) should be NoteEditor(note: note)
Interesting technique trying to save in onReceive it might break if the context fails to save and gets in an inconsistent state.

Hierarchical List in SwiftUI with CoreData

I am trying to implement hierarchical CoreData in a SwiftUI List. I created a new project in Xcode using CoreData and just added a parent(to-one)/children(t-many) relationship to the Item entity:
I use Codegen Manual/None for Item and created NSManagedObject subclasses for Item. I added an extension to return the children as an Array (so that I can use it in List for the children: parameter):
public var childrenArray: [Item]? {
let set = children as? Set<Item> ?? []
print(set)
return set.sorted(by: { $0.id?.uuidString ?? "NO ID?" < $1.id?.uuidString ?? "NO ID?" })
}
for Item.
In the ContentView.swift, I changed it to use a List with the children: parameter to show my hierarchical data. I also changed the #FetchRequest so that only Items without a parent are obtained:
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)], predicate: NSPredicate(format: "parent == nil"), animation: .default) private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List(Array(items), children: \.childrenArray) { item in
NavigationLink {
VStack {
Text(item.id!.uuidString)
}
} label: {
Text(item.id!.uuidString)
}
}
.toolbar {
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
newItem.id = UUID()
// Add a new item and make it a children of first new item
let newItem2 = Item(context: viewContext)
newItem2.timestamp = Date()
newItem2.id = UUID()
newItem.addToChildren(newItem2)
// ------------------------------------------------------
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
The result works, but I get a down-chevron for the item that does not have children:
Reading Apples doc about List I understand that the children array should be nil to tell the List to not show a chevron. But I can't figure out how to do that in the childrenArray function. How would I have to adopt the childrenArray function to get the right array so that the List will not show a chevron?
Simply make the computed property return nil by using a guard statement and return nil if there are no children
guard let set = children as? Set<Item>, set.isEmpty == false else { return nil }
Full code
extension Item {
public var childrenArray: [Item]? {
guard let set = children as? Set<Item>, set.isEmpty == false else { return nil }
return set.sorted(by: { $0.id?.uuidString ?? "NO ID?" < $1.id?.uuidString ?? "NO ID?" })
}
}

Unable to save custom data into CoreData using Combine framework

I am creating custom data and I want to save them into CoreData when I make favorites. In order to do that I use the Combine framework by subscribing CoreData values back into my custom data. The problem is when I try to map both CoreData and custom data, there is something wrong and I couldn't display even my custom data on the canvas. To be honest, I don't even know what I am doing because most of the ViewModel codes are based on Nick's tutorial video (from the Swiftful Thinking Youtube channel). Please help me with what is wrong with my codes. Thanks in advance.
I create my CoreData with a name "DataContainer" with entity name "DataEntity". In DataEntity, there are three attributes:
'id' with a type "Integer32"
'isFavorite' with a type "Boolean"
'timestamp' with a type "Date"
import Foundation
import CoreData
// This is CoreData class without using Singleton
class CoreDataManager {
private let container: NSPersistentContainer
#Published var savedEntities: [DataEntity] = []
init() {
container = NSPersistentContainer(name: "DataContainer")
container.loadPersistentStores { _, error in
if let error = error {
print("Error loading CoreData! \(error)")
}
}
fetchData()
}
// MARK: Privates
private func fetchData() {
let request = NSFetchRequest<DataEntity>(entityName: "DataEntity")
do {
savedEntities = try container.viewContext.fetch(request)
} catch let error {
print("Error fetching DataEntity! \(error)")
}
}
// Add to CoreData
private func addFavorite(dataID: DataArray, onTappedFavorite: Bool) {
let newFavorite = DataEntity(context: container.viewContext)
newFavorite.id = Int32(dataID.id)
newFavorite.isFavorite = onTappedFavorite
applyChanges()
}
// Update time
private func updateTime() {
let newTime = DataEntity(context: container.viewContext)
newTime.timestamp = Date()
}
// Save to CoreData
private func save() {
do {
try container.viewContext.save()
} catch let error {
print("Error saving to CoreData! \(error)")
}
}
private applyChanges() {
save()
updateTime()
fetchData()
}
private func update(entity: DataEntity, updateFavorite: Bool) {
entity.isFavorite = updateFavorite
applyChanges()
}
private func delete(entity: DataEntity) {
container.viewContext.delete(entity)
applyChanges()
}
// MARK: Public
func updateFavorite(dataID: DataArray, onTappedFavorite: Bool) {
// Checking the data is already taken
if let entity = savedEntities.first(where: { $0.id == dataID.id }) {
if onTappedFavorite {
update(entity: entity, updateFavorite: onTappedFavorite)
} else {
delete(entity: entity)
}
} else {
addFavorite(dataID: dataID, onTappedFavorite: onTappedFavorite)
}
}
}
This will be my Model:
import Foundation
import SwiftUI
struct DataArray: Identifiable {
let id: Int
let cities: String
let name1: String
let name2: String
let isFavorite: Bool
func updateFavorite(favorited: Bool) -> DataArray {
return DataArray(id: id, cities: cities, name1: name1, name2: name2, isFavorite: favorited)
}
}
public struct ListDataArray {
static let dot = [
DataArray(id: 1,
cities: "Baltimore"
name1: "John",
name2: "Mike",
isFavorite: False),
DataArray(id: 2,
cities: "Frederick"),
name1: "Joe",
name2: "Swift",
isFavorite: False),
DataArray(id: 3,
cities: "Catonsville"
name1: "Susan",
name2: "Oliver",
isFavorite: False),
// There will be a lot of data
]
}
This will be my Home ViewModel:
import Foundation
import SwiftUI
import Combine
class Prospect: ObservableObject {
#Published var datas: [DataArray] = []
private let coreDataServices = CoreDataManager()
private var cancellables = Set<AnyCancellable>()
init() {
fetchDataArrays()
fetchCoreData()
}
private func fetchDataArrays() {
let items = ListDataArray.dot
datas = items
}
private func fetchCoreData() {
coreDataServices.$savedEntities
.map({ (coreData) -> [DataArray] in
// Here is something wrong when I check and try to convert CoreData to DataArray
let arrays: [DataArray] = []
return arrays
.compactMap { (data) -> DataArray? in
guard let entity = coreData.first(where: { $0.id == data.id }) else {
return nil
}
return data.updateFavorite(favorited: entity.isFavorite)
}
})
.sink {[weak self] (receivedEntities) in
self?.datas = receivedEntities
}
.store(in: &cancellables)
}
func updateFavoriteData(dataID: DataArray, isFavorite: Bool) {
coreDataServices.updateFavorite(dataID: dataID, onTappedFavorite: isFavorite)
}
// To View Favorite
#Published var showFavorite: Bool = false
}
This is my View:
import SwiftUI
struct Home: View {
#EnvironmentObject var items: Prospect
var body: some View {
ScrollView {
LazyVStack {
ForEach(items.datas) { data in
VStack {
HStack {
Button {
//Action for making favorite or unfavorite
items.updateFavoriteData(dataID: data, isFavorite: data.isFavorite)
} label: {
Image(systemName: data.isFavorite ? "suit.heart.fill" : "suit.heart")
}
Spacer()
Button {
items.showFavorite.toggle()
} label: {
Image(systemName: "music.note.house.fill")
}
.sheet(isPresented: $items.showFavorite) {
FavoriteView()
.environmentObject(items)
}
}
Text("\(data.id)")
.font(.title3)
Text(data.cities)
.font(.subheadline)
Spacer()
}
padding()
}
.padding()
}
}
}
}
struct FavoriteView: View {
#EnvironmentObject var items: Prospect
var body: some View {
VStack {
List {
ForEach(items.datas) { data in
if data.isFavorite {
VStack(spacing: 10) {
Text(data.cities)
Text(data.name1)
Text(data.name2)
}
.font(.body)
}
}
.padding()
}
Spacer()
}
}
}
struct Home_Previews: PreviewProvider {
static var previews: some View {
Home()
.environmentObject(Prospect())
}
}
Here is a different approach
//extend DataEntity
extension DataEntity{
//Have a dataArray variable that links with your array
var dataArray: DataArray?{
ListDataArray.dot.first(where: {
$0.id == self.id
})
}
}
//extend DataArray
extension DataArray{
//have a data entity variable that retrieves the CoreData object
var dataEntity: DataEntity{
//Use DataEntity Manager to map
//Find or Create
return DataEntityManager().retrieve(dataArray: self)
}
}
//Have an entity manager
class DataEntityManager{
let container = PersistenceController.previewAware
//convenience to create
func create(id: Int32) -> DataEntity{
let entity = DataEntity(context: container.container.viewContext)
entity.id = id
save()
return entity
}
//for updating to centralize work
func update(entity: DataEntity){
//I think this is what you intend to update the timestamp when the value changes
entity.timestamp = Date()
//get the array variable
var dataArry = entity.dataArray
//See if they match to prevent loops
if dataArry?.isFavorite != entity.isFavorite{
//if they dont update the array
dataArry = dataArry?.updateFavorite(favorited: entity.isFavorite)
}else{
//Leave alone
}
save()
}
//for updating to centralize work
func update(dataArray: DataArray){
//get the entity
let entity = dataArray.dataEntity
//See if they match to prevent loops
if entity.isFavorite != dataArray.isFavorite{
//if they dont update the entity
DataEntityManager().update(entity: entity)
}else{
//leave alone
}
}
func retrieve(dataArray: DataArray) -> DataEntity{
let request: NSFetchRequest = DataEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %#", dataArray.id)
do{
let result = try controller.container.viewContext.fetch(request).first
//This is risky because it will create a new one
//You can handle it differently if you prefer
let new = create(id: Int32(dataArray.id))
update(entity: new)
return result ?? new
}catch{
print(error)
//This is risky because it will create a new one
//You can handle it differently if you prefer
let new = create(id: Int32(dataArray.id))
update(entity: new)
return new
}
}
func save() {
do{
try container.container.viewContext.save()
}catch{
print(error)
}
}
}
struct DataArray: Identifiable {
let id: Int
let cities: String
let name1: String
let name2: String
var isFavorite: Bool
//Mostly your method
mutating func updateFavorite(favorited: Bool) -> DataArray {
//get the new value
isFavorite = favorited
//update the entity
DataEntityManager().update(dataArray: self)
//return the new
return self
}
}
With this you can now access the matching variable using either object
dataEntity.dataArray
or
dataArray.dataEntity
Remember to update using the methods in the manager or the array so everything stays in sync.
Something to be aware of. CoreData objects are ObservableObjects where ever you want to see changes for the DataEntity you should wrap them in an #ObservedObject

Saving favorites to UserDefaults using struct id

I'm trying to save the users favorite cities in UserDefaults. Found this solution saving the struct ID - builds and runs but does not appear to be saving: On app relaunch, the previously tapped Button is reset.
I'm pretty sure I'm missing something…
Here's my data struct and class:
struct City: Codable {
var id = UUID().uuidString
var name: String
}
class Favorites: ObservableObject {
private var cities: Set<String>
let defaults = UserDefaults.standard
var items: [City] = [
City(name: "London"),
City(name: "Paris"),
City(name: "Berlin")
]
init() {
let decoder = PropertyListDecoder()
if let data = defaults.data(forKey: "Favorites") {
let cityData = try? decoder.decode(Set<String>.self, from: data)
self.cities = cityData ?? []
return
} else {
self.cities = []
}
}
func getTaskIds() -> Set<String> {
return self.cities
}
func contains(_ city: City) -> Bool {
cities.contains(city.id)
}
func add(_ city: City) {
objectWillChange.send()
cities.contains(city.id)
save()
}
func remove(_ city: City) {
objectWillChange.send()
cities.remove(city.id)
save()
}
func save() {
let encoder = PropertyListEncoder()
if let encoded = try? encoder.encode(tasks) {
defaults.setValue(encoded, forKey: "Favorites")
}
}
}
and here's the TestDataView
struct TestData: View {
#StateObject var favorites = Favorites()
var body: some View {
ForEach(self.favorites.items, id: \.id) { item in
VStack {
Text(item.title)
Button(action: {
if self.favorites.contains(item) {
self.favorites.remove(item)
} else {
self.favorites.add(item)
}
}) {
HStack {
Image(systemName: self.favorites.contains(item) ? "heart.fill" : "heart")
.foregroundColor(self.favorites.contains(item) ? .red : .white)
}
}
}
}
}
}
There were a few issues, which I'll address below. Here's the working code:
struct ContentView: View {
#StateObject var favorites = Favorites()
var body: some View {
VStack(spacing: 10) {
ForEach(Array(self.favorites.cities), id: \.id) { item in
VStack {
Text(item.name)
Button(action: {
if self.favorites.contains(item) {
self.favorites.remove(item)
} else {
self.favorites.add(item)
}
}) {
HStack {
Image(systemName: self.favorites.contains(item) ? "heart.fill" : "heart")
.foregroundColor(self.favorites.contains(item) ? .red : .black)
}
}
}
}
}
}
}
struct City: Codable, Hashable {
var id = UUID().uuidString
var name: String
}
class Favorites: ObservableObject {
#Published var cities: Set<City> = []
#Published var favorites: Set<String> = []
let defaults = UserDefaults.standard
var initialItems: [City] = [
City(name: "London"),
City(name: "Paris"),
City(name: "Berlin")
]
init() {
let decoder = PropertyListDecoder()
if let data = defaults.data(forKey: "Cities") {
cities = (try? decoder.decode(Set<City>.self, from: data)) ?? Set(initialItems)
} else {
cities = Set(initialItems)
}
self.favorites = Set(defaults.array(forKey: "Favorites") as? [String] ?? [])
}
func getTaskIds() -> Set<String> {
return self.favorites
}
func contains(_ city: City) -> Bool {
favorites.contains(city.id)
}
func add(_ city: City) {
favorites.insert(city.id)
save()
}
func remove(_ city: City) {
favorites.remove(city.id)
save()
}
func save() {
let encoder = PropertyListEncoder()
if let encoded = try? encoder.encode(self.cities) {
self.defaults.set(encoded, forKey: "Cities")
}
self.defaults.set(Array(self.favorites), forKey: "Favorites")
defaults.synchronize()
}
}
Issues with the original:
The biggest issue was that items was getting recreated on each new launch and City has an id that is assigned a UUID on creation. This guaranteed that every new launch, each batch of cities would have different UUIDs, so a saving situation would never work.
There were some general typos and references to properties that didn't actually exist.
What I did:
Made cities and favorites both #Published properties so that you don't have to call objectWillChange.send by hand
On init, load both the cities and the favorites. That way, the cities, once initially created, will always have the same UUIDs, since they're getting loaded from a saved state
On save, I save both Sets -- the favorites and the cities
In the original ForEach, I iterate through all of the cities and then only mark the ones that are part of favorites
Important note: While testing this, I discovered that at least on Xcode 12.3 / iOS 14.3, syncing to UserDefaults is slow, even when using the now-unrecommended synchronize method. I kept wondering why my changes weren't reflected when I killed and then re-opened the app. Eventually figured out that everything works if I give it about 10-15 seconds to sync to UserDefaults before killing the app and then opening it again.

SwiftUI with MVVM + Realm database: How to create a list with elements?

I want to use a realm database in my SwiftUI app and I would like to apply the MVVM pattern. Unfortunately when I create a list with the elements in my database I get a Fatal error: Unexpectedly found nil while unwrapping an Optional value: error message
DatabaseManager:
class DatabaseManager{
private let realm: Realm
public static let sharedInstance = DatabaseManager()
private init(){
realm = try! Realm()
}
func fetchData<T: Object>(type: T.Type) -> Results<T>{
let results: Results<T> = Realm.objects(type)
return results
}
}
Model:
class FlashcardDeck: Object, Codable, Identifiable{
#objc private (set) dynamic var id = NSUUID().uuidString
#objc dynamic var title: String?
var cards = RealmSwift.List<Flashcard>()
convenience init(title: String?, cards: [Flashcard]){
self.init()
self.title = title
self.cards.append(objectsIn: cards)
}
override class func primaryKey() -> String? {
return "id"
}
}
ViewModel
class FlashcardDeckViewModel: ObservableObject{
let realm = DatabaseManager.sharedInstance
#Published var decks: Results<FlashcardDeck>?
public func fetchDecks(){
decks = realm.fetchData(type: FlashcardDeck.self)
}
}
View
struct FlashcardDeckView: View {
private let gridItems = [GridItem(.flexible())]
#StateObject var viewModel = FlashcardDeckViewModel()
var body: some View {
NavigationView{
ScrollView{
LazyVGrid(columns: gridItems, spacing: 30){
ForEach(viewModel.decks!) { item in // <----- ERROR APPEARS HERE
FlashcardDeckItem(deck: item)
}
}
}
.navigationTitle("Flashcard decks")
}
.onAppear{
self.viewModel.fetchDecks()
print(self.viewModel.cards?[0].title) // <------ prints the title of the deck! So this element exists
}
}
}
I'm pretty sure that my database has an element and if I try to print the name of the deck in the fetchData()function it will be displayed. I know the line ForEach(viewModel.decks!)isn't beautiful code, but this is just for testing/debugging now.
Include it conditionally, like
NavigationView{
if viewModel.decks == nil {
Text("Loading...")
} else {
ScrollView{
LazyVGrid(columns: gridItems, spacing: 30){
ForEach(viewModel.decks!) { item in // <----- ERROR APPEARS HERE
FlashcardDeckItem(deck: item)
}
}
}
.navigationTitle("Flashcard decks")
}
}