Fatal error when publishing UserDefaults with Combine - swift

I try to observe my array of custom objects in my UserDefaults using a Combine publisher.
First my extension:
extension UserDefaults {
var ratedProducts: [Product] {
get {
guard let data = UserDefaults.standard.data(forKey: "ratedProducts") else { return [] }
return (try? PropertyListDecoder().decode([Product].self, from: data)) ?? []
}
set {
UserDefaults.standard.set(try? PropertyListEncoder().encode(newValue), forKey: "ratedProducts")
}
}
}
Then in my View model, within my init() I do:
UserDefaults.standard
.publisher(for: \.ratedProducts)
.sink { ratedProducts in
self.ratedProducts = ratedProducts
}
.store(in: &subscriptions)
You can see that I basically want to update my #Published property ratedProducts in the sink call.
Now when I run it, I get:
Fatal error: Could not extract a String from KeyPath
Swift.ReferenceWritableKeyPath<__C.NSUserDefaults,
Swift.Array<RebuyImageRating.Product>>
I think I know that this is because in my extension the ratedProduct property is not marked as #objc, but I cant mark it as such because I need to store a custom type.
Anyone know what to do?
Thanks

As you found out you can not observe your custom types directly, but you could add a possibility to observe the data change and decode that data to your custom type in your View model:
extension UserDefaults{
// Make it private(set) so you cannot use this from the outside and set arbitary data by accident
#objc dynamic private(set) var observableRatedProductsData: Data? {
get {
UserDefaults.standard.data(forKey: "ratedProducts")
}
set { UserDefaults.standard.set(newValue, forKey: "ratedProducts") }
}
var ratedProducts: [Product]{
get{
guard let data = UserDefaults.standard.data(forKey: "ratedProducts") else { return [] }
return (try? PropertyListDecoder().decode([Product].self, from: data)) ?? []
} set{
// set the custom objects array through your observable data property.
observableRatedProductsData = try? PropertyListEncoder().encode(newValue)
}
}
}
and the observer in your init:
UserDefaults.standard.publisher(for: \.observableRatedProductsData)
.map{ data -> [Product] in
// check data and decode it
guard let data = data else { return [] }
return (try? PropertyListDecoder().decode([Product].self, from: data)) ?? []
}
.receive(on: RunLoop.main) // Needed if changes come from background
.assign(to: &$ratedProducts) // assign it directly

Related

SwiftUI: keep data even after closing app

i have been trying to make that when a user adds a page to favorites or removes the page it saves it, so when a user closes the app it remembers it. I can't figure out how i can save the mushrooms table. I want to save it locally and is it done by using Prospects ?
class Favorites: ObservableObject {
public var mushrooms: Set<String>
public let saveKey = "Favorites"
init() {
mushrooms = []
}
func contains(_ mushroom: Mushroom) -> Bool {
mushrooms.contains(mushroom.id)
}
func add (_ mushroom: Mushroom) {
objectWillChange.send()
mushrooms.insert(mushroom.id)
save()
}
func remove(_ mushroom: Mushroom) {
objectWillChange.send()
mushrooms.remove(mushroom.id)
save()
}
func save() {
}
}
I was able to figure it out. Here is the code i did if someone else is struggling with this.
I added this to the save function
func save() {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(mushrooms) {
defaults.set(encoded, forKey: "Favorites")
}
}
And to the init() :
let decoder = JSONDecoder()
if let data = defaults.data(forKey: "Favorites") {
let mushroomData = try? decoder.decode(Set<String>.self, from: data)
self.mushrooms = mushroomData ?? []
} else {
self.mushrooms = []
}
EDIT:
and of course add the defaults
let defaults = UserDefaults.standard

Passing data to another view using a model - SwiftUI

I'm trying to pass the data retrieved from the API to a View, but I'm getting the following error:
Class 'ApiManagerViewModel' has no initializers
This is how the ViewModel looks:
class ApiManagerViewModel: ObservableObject {
#Published var blockchainData: ApiDataClass
func callAPI() {
guard let url = URL(string: "myapiurl") else {
print("Invalid URL")
return
}
let request = URLRequest(url: url, timeoutInterval: Double.infinity)
let callAPI = URLSession.shared.dataTask(with: request) { data, responce, error in
do {
if let data = data {
let decodedResponse = try JSONDecoder().decode(APIResponce.self, from: data)
DispatchQueue.main.async {
// update our UI
self.blockchainData = (decodedResponse.data)
}
// Everything is good, so we can exit
return
}
} catch {
print("Unexpected error while fetchign API: \(error).")
return
}
}
callAPI.resume()
}
This is the model:
// MARK: - APIResponce
struct APIResponce: Codable {
let data: ApiDataClass
let error: Bool
}
// MARK: - DataClass
struct ApiDataClass: Codable {
let address, quote_currency: String
let chain_id: Int
let items: [ApiItems]
}
// MARK: - Item
struct ApiItems: Codable {
let contract_decimals: Int32
let contract_name, contract_ticker_symbol, contract_address, logo_url, type, balance: String
let supports_erc: [String]?
let quote_rate: Double?
let quote: Double
}
I've tried initializing it but it's no bueno:
init() {
let address = 0, quote_currency = 0
let chain_id = 0
let items: [ApiItems]
}
If I initialize it like that I get the error, and I also don't want to repeat the same thing the model has:
Return from initializer without initializing all stored properties
I also tried with the variable like:
#Published var blockchainData = []
and I get the error on this line: self.blockchainData = (decodedResponse.data):
Cannot assign value of type 'ApiDataClass' to type '[Any]'
How can I make the variable blockchainData have the value coming from decodedResponse.data so I can pass it to another view?
Thanks
You're getting that error because you've declared var blockchainData: ApiDataClass, but haven't given it an initial value (your attempt at providing an initializer for ApiDataClass didn't help because the problem is ApiManagerViewModel).
The easiest solution to this is to turn it into an optional:
#Published var blockchainData: ApiDataClass?
Then, in your View, you'll probably want to check if it's available. Something like:
if let blockchainData = viewModel.blockchainData {
//code that depends on blockchainData
}
(assuming your instance of ApiManagerViewModel is called viewModel)

Combine these into a single var

Apologies for the stupid question, I'm still really new to the Swift language.
Following up on this answer by #matt, I want to combine these two statements into a single var
UserDefaults.standard.set(try? PropertyListEncoder().encode(songs), forKey:"songs")
if let data = UserDefaults.standard.value(forKey:"songs") as? Data {
let songs2 = try? PropertyListDecoder().decode(Array<Song>.self, from: data)
}
I've thought maybe using a var with didSet {} like something along the lines of
var test: Array = UserDefaults.standard. { // ??
didSet {
UserDefaults.standard.set(try? PropertyListEncoder().encode(test), forKey: "songs")
}
}
But I can't think of where to go from here.
Thanks for the help in advance :))
The property should not be a stored property. It should be a computed property, with get and set accessors:
var songsFromUserDefaults: [Song]? {
get {
if let data = UserDefaults.standard.value(forKey:"songs") as? Data {
return try? PropertyListDecoder().decode(Array<Song>.self, from: data)
} else {
return nil
}
}
set {
if let val = newValue {
UserDefaults.standard.set(try? PropertyListEncoder().encode(val), forKey:"songs")
}
}
}
Notice that since the decoding can fail, the getter returns an optional. This forces the setter to accept an optional newValue, and I have decided to only update UserDefaults when the value is not nil. Another design is to use try! when decoding, or return an empty array when the decoding fails. This way the type of the property can be non-optional, and the nil-check in the setter can be removed.
While you can use computed properties like Sweeper suggested (+1), I might consider putting this logic in a property wrapper.
In SwiftUI you can use AppStorage. Or you can roll your own. Here is a simplified example:
#propertyWrapper public struct Saved<Value: Codable> {
private let key: String
public var wrappedValue: Value? {
get {
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
return (try? JSONDecoder().decode(Value.self, from: data))
}
set {
guard
let value = newValue,
let data = try? JSONEncoder().encode(value)
else {
UserDefaults.standard.removeObject(forKey: key)
return
}
UserDefaults.standard.set(data, forKey: key)
}
}
init(key: String) {
self.key = key
}
}
And then you can do things like:
#Saved(key: "username") var username: String?
Or
#Saved(key: "songs") var songs: [Song]?

How to store user data locally in SwiftUI

I try to accomplish having an observable object with a published value training. On every change it should save the custom struct to the user defaults. On every load (AppState init) it should load the data:
class AppState: ObservableObject {
var trainings: [Training] {
willSet {
if let encoded = try? JSONEncoder().encode(trainings) {
let defaults = UserDefaults.standard
defaults.set(encoded, forKey: "trainings")
}
objectWillChange.send()
}
}
init() {
self.trainings = []
if let savedTrainings = UserDefaults.standard.object(forKey: "trainings") as? Data {
if let loadedTraining = try? JSONDecoder().decode([Training].self, from: savedTrainings) {
self.trainings = loadedTraining
}
}
}
}
I know if this is best practice, but I want to save the data locally.
The code I wrote is not working and I can't figure out why.
I'm a beginner and I never stored data to a device.
Each time you call the init method the first line resets the value stored in UserDefaults and in-turn returns the empty array instead of the value that was previously stored. Try this modification to your init method to fix it:
init() {
if let savedTrainings = UserDefaults.standard.object(forKey: "trainings") as? Data,
let loadedTraining = try? JSONDecoder().decode([Training].self, from: savedTrainings) {
self.trainings = loadedTraining
} else {
self.trainings = []
}
}
Better Approach: A much better approach would to modify your trainings property to have a get and set instead of the current setup. Here is an example:
var trainings: [Training] {
set {
if let encoded = try? JSONEncoder().encode(newValue) {
let defaults = UserDefaults.standard
defaults.set(encoded, forKey: "trainings")
}
objectWillChange.send()
}
get {
if let savedTrainings = UserDefaults.standard.object(forKey: "trainings") as? Data,
let loadedTraining = try? JSONDecoder().decode([Training].self, from: savedTrainings) {
return loadedTraining
}
return []
}
}
Note: This can again be improved using Swift 5.1's #PropertyWrapper. Let me know in the comments if anyone wants me to include that as well in the answer.
Update: Here's the solution that makes it simpler to use UserDefaults using Swift's #PropertyWrapper as you have requested for:-
#propertyWrapper struct UserDefault<T: Codable> {
var key: String
var wrappedValue: T? {
get {
if let data = UserDefaults.standard.object(forKey: key) as? Data {
return try? JSONDecoder().decode(T.self, from: data)
}
return nil
}
set {
if let encoded = try? JSONEncoder().encode(newValue) {
UserDefaults.standard.set(encoded, forKey: key)
}
}
}
}
class AppState: ObservableObject {
#UserDefault(key: "trainings") var trainings: [Training]?
#UserDefault(key: "anotherProperty") var anotherPropertyInUserDefault: AnotherType?
}

Swift Firebase Reuse a function throughout the app

In my app, in a few places I am loading data from Firebase Firestore database and showing the data. The problem is I am not adopting the DRY technique and I know I shouldn't, but I am reusing this same load function in different places in my app.
func loadData() {
let user = Auth.auth().currentUser
db.collection("users").document((user?.uid)!).collection("children").getDocuments() {
QuerySnapshot, error in
if let error = error {
print("\(error.localizedDescription)")
} else {
// get all children into an array
self.childArray = QuerySnapshot!.documents.flatMap({Child(dictionary: $0.data())})
DispatchQueue.main.async {
self.childrenTableView.reloadData()
}
}
}
}
The function simply grabs all the children from the database and adds them to my child array.
Is there some better way to do this or a central place I can put this function where it can be called as and when I need it in the app instead of repeatedly adding it in multiple view controllers?
I thought about a helper class, and just calling the function, but then not sure how to add the result to the childArray in the viewcontroller I needed it?
my Child model
import UIKit
import FirebaseFirestore
protocol DocumentSerializable {
init?(dictionary:[String:Any])
}
// Child Struct
struct Child {
var name: String
var age: Int
var timestamp: Date
var imageURL: String
var dictionary:[String:Any] {
return [
"name":name,
"age":age,
"timestamp":timestamp,
"imageURL":imageURL
]
}
}
//Child Extension
extension Child : DocumentSerializable {
init?(dictionary: [String : Any]) {
guard let name = dictionary["name"] as? String,
let age = dictionary["age"] as? Int,
let imageURL = dictionary["imageURL"] as? String,
let timestamp = dictionary["timestamp"] as? Date else {
return nil
}
self.init(name: name, age: age, timestamp: timestamp, imageURL: imageURL)
}
}
EDIT: I have updated to safely unwrap the optionals. You may still have to modify as I am not sure what your Firebase structure is, nor do I know your Child initializer.
You could just write this as a static function and then reuse it everywhere. I assume you might have some class related to whatever "children" is, and that'd be the best place to implement. You could pass the results (as an option array of Child) in a completion handler so that you can do whatever you need with those results. It'd look something like this:
static func loadData(_ completion: (_ children: [Child]?)->()) {
guard let user = Auth.auth().currentUser else { completion(nil); return }
Firestore.firestore().collection("users").document(user.uid).collection("children").getDocuments() {
querySnapshot, error in
if let error = error {
print("\(error.localizedDescription)")
completion(nil)
} else {
guard let snapshot = querySnapshot else { completion(nil); return }
// get all children into an array
let children = snapshot.documents.flatMap({Child(dictionary: $0.data())})
completion(children)
}
}
}
Assuming you have this implemented in your Child class you would use it like this:
Child.loadData { (children) in
DispatchQueue.main.async {
if let loadedChildren = children {
//Do whatever you need with the children
self.childArray = loadedChildren
}
}
}