Fetching JSON, appending to array: Escaping closure captures mutating 'self' parameter - swift

I have prepared a simple test project at Github to demo my problem:
I have a SwiftUI List and I try to display the var items:[String] in it.
When I only have a hardcoded array like below - it works fine and displays in iPhone:
items = (1...200).map { number in "Item \(number)" }
But when I try to fetch JSON web page and append results to items then I get the error:
Escaping closure captures mutating 'self' parameter
I understand that the line items.append(str) modifies the parent ContentView object out of dataTask closure and that is not good for some reason... but how to fix my code then?
import SwiftUI
struct TopResponse: Codable {
let data: [Top]
}
struct Top: Codable {
let uid: Int
let elo: Int
let given: String
let photo: String?
let motto: String?
let avg_score: Double?
let avg_time: String?
}
struct ContentView: View {
var items:[String];
init() {
items = (1...200).map { number in "Item \(number)" }
let url = URL(string: "https://slova.de/ws/top")!
let task = URLSession.shared.dataTask(with: url) {
data, response, error in
let decoder = JSONDecoder()
guard let data = data else { return }
do {
let tops = try decoder.decode(TopResponse.self, from: data)
for (index, top) in tops.data.enumerated() {
let str = "\(index + 1): \(top.given)"
items.append(str) // this results in compile error!
}
} catch {
print("Error while parsing: \(error)")
}
}
task.resume()
}
var body: some View {
List(items, id: \.self) { item in
Text(item)
}
}
}
Should I move the items out of the View maybe?
My final target is to have the JSON data in Core Data and then update/notify the List from it.
I have such an app in Android (structured as MVVM) and now I am trying to port it to SwiftUI, being a Swift newbie.
UPDATE:
I have added a view model file as suggested by achu (thanks!) and it kind of works, but the List is only updated with new items when I drag at it. And there is a warning
[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

I will move the items to ViewModel and eventually move the service call to an APIManager class
EDIT: The UI update should be in the main thread. Added service call on ViewModel init().
struct TestView: View {
#ObservedObject var viewModel = TestViewModel()
var body: some View {
List(viewModel.items, id: \.self) { item in
Text(item)
}
}
}
class TestViewModel: ObservableObject {
#Published var items: [String] = []
init() {
self.fetchData()
}
func fetchData() {
let url = URL(string: "https://slova.de/ws/top")!
let task = URLSession.shared.dataTask(with: url) {
data, response, error in
let decoder = JSONDecoder()
guard let data = data else { return }
do {
let tops = try decoder.decode(TopResponse.self, from: data)
for (index, top) in tops.data.enumerated() {
let str = "\(index + 1): \(top.given)"
self.updateItems(str)
}
} catch {
print("Error while parsing: \(error)")
}
}
task.resume()
}
func updateItems(_ str: String) {
DispatchQueue.main.async {
self.items.append(str)
}
}
}

Related

How to use downloaded URL correctly in AsyncImage?

How to use downloaded URL from getData class in AsyncImage?
struct RecentItemsView: View {
var item: dataType // var's from getData class
var body: some View {
HStack(spacing: 15) {
AsyncImage(url: URL(string: item.pic), content: { image in // item.pic here
image.resizable()
}, placeholder: {
ProgressView()
})
I have full URL from downloadURL but when Im using item.pic parameter in AsyncImage I get error: (See Image)
I understand that the error contains the path to the image, which is not suitable for AsyncImage, that's why I downloaded the full URL, the question is how to use the received URL in AsyncImage?
class getData : ObservableObject {
#Published var datas = [dataType]()
init() {
let db = Firestore.firestore()
db.collection("items").getDocuments { (snap, err) in
if err != nil {
print((err?.localizedDescription)!)
return
}
for i in snap!.documents {
let id = i.documentID
let title = i.get("title") as! String
let description = i.get("description") as! String
let pic = i.get("pic") as! String
self.datas.append(dataType(id: id, title: title, description: description, pic: pic))
let storage = Storage.storage()
let storageRef = storage.reference().child("\(pic)")
storageRef.downloadURL { url, error in
if let error = error {
print("Failed to download url:", error)
return
} else {
print(url!) // Full Url- https://firebasestorage.googleapis.com:...
}
}
}
}
}
}
struct dataType : Identifiable {
var id = UUID().uuidString
var title : String
var description : String
var pic : String
}
Error:
Storage:
Firestore:
This is going to look quite a bit different from your current approach but give it a try, it will simplify your code overall.
Main differences are the use of async await and FirebaseFirestoreSwift.
I choose using async await/Concurrency because it provides a more linear approach to the code and I think resolves your issue about sharing the variable with all the objects.
This is what your ObservableObject will look like
//Keeps UI Updates on the main thread
#MainActor
//Classes and structs should always be uppercased
class GetData : ObservableObject {
#Published var datas = [DataType]()
private var task: Task<Void, Never>? = nil
init() {
task = Task{
do{
try await getData()
}catch{
//Ideally you should present this error
//to the users so they know that something has gone wrong
print(error)
}
}
}
deinit{
task?.cancel()
}
func getData() async throws {
let documentPath = "items"
let svc = FirebaseService()
//async await allows a more linear approach. You can get the images individually
var items : [DataType] = try await svc.retrieve(path: documentPath)
for (idx, item) in items.enumerated() {
//Check if your url is a full url
if !item.pic.localizedCaseInsensitiveContains("https"){
//If it isnt a full url get it from storage and replace the url
items[idx].pic = try await svc.getImageURL(imagePath: item.pic).absoluteString
//Optional update the object so you dont have to retrieve the
//The url each time.
try svc.update(path: documentPath, object: items[idx])
}
}
datas = items
}
}
and your struct should change to use #DocumentID.
//This is a much simpler solution to decoding
struct DataType : Identifiable, FirestoreProtocol {
#DocumentID var id : String?
//If you get decoding errors make these variables optional by adding a ?
var title : String
var description : String
var pic : String
}
Your Views can now be modified to use the updated variables.
#available(iOS 15.0, *)
public struct DataTypeListView: View{
#StateObject var vm: GetData = .init()
public init(){}
public var body: some View{
List(vm.datas){ data in
DataTypeView(data: data)
}
}
}
#available(iOS 15.0, *)
struct DataTypeView: View{
let data: DataType
var body: some View{
HStack{
Text(data.title)
AsyncImage(url: URL(string: data.pic), content: { phase in
switch phase{
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
case .failure(let error):
Image(systemName: "rectangle.fill")
.onAppear(){
print(error)
}
case .empty:
Image(systemName: "rectangle.fill")
#unknown default:
Image(systemName: "rectangle.fill")
}
})
}
}
}
The class GetData is pretty bare bones an uses the code below to actually make the calls, I like using generics to simplify code and so it can be reused by various places.
You don't have to completely understand what is going on with this now but you should, I've put a ton of comments so it should be easy.
import FirebaseStorage
import FirebaseFirestore
import FirebaseFirestoreSwift
import SwiftUI
import FirebaseAuth
struct FirebaseService{
private let storage: Storage = .storage()
private let db: Firestore = .firestore()
///Retrieves the storage URL for an image path
func getImageURL(imagePath: String?) async throws -> URL{
guard let imagePath = imagePath else {
throw AppError.unknown("Invalid Image Path")
}
typealias PostContinuation = CheckedContinuation<URL, Error>
//Converts an completion handler approach to async await/concurrency
return try await withCheckedThrowingContinuation { (continuation: PostContinuation) in
storage.reference().child(imagePath).downloadURL { url, error in
if let error = error {
continuation.resume(throwing: error)
} else if let url = url {
continuation.resume(returning: url)
} else {
continuation.resume(throwing: AppError.unknown("Error getting image url"))
}
}
}
}
///Retireves the documetns from the Firestore and returns an array of objects
func retrieve<FC>(path: String) async throws -> [FC] where FC : FirestoreProtocol{
let snapshot = try await db.collection(path).getDocuments()
return snapshot.documents.compactMap { doc in
do{
return try doc.data(as: FC.self)
}catch{
//If you get any decoding errors adjust your struct, you will
//likely need optionals
print(error)
return nil
}
}
}
///Updates the provided document into the provided path
public func update<FC : FirestoreProtocol>(path: String, object: FC) throws{
guard let id = object.id else{
throw AppError.needValidId
}
try db.collection(path).document(id).setData(from: object)
}
}
enum AppError: LocalizedError{
case unknown(String)
case needValidId
}
protocol FirestoreProtocol: Identifiable, Codable{
///Use #DocumentID from FirestoreSwift
var id: String? {get set}
}
All of this code works, if you put all this code in a .swift file it will compile and it should work with your database.

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)

Load json from #propertyWrapper in SwiftUI ContentView

I've been using a json decoding utility that works great. I'm wondering if that utility can be abstracted into a propertyWrapper that accepts the json file name as a string.
The call site in the ContentView looks like this:
struct ContentView: View {
#DataLoader("tracks.json") var tracks: [Tracks]
...
My rough sketch of the property wrapper looks like this:
#propertyWrapper
struct DataLoader<T: Decodable>: DynamicProperty {
private let fileName: String
var wrappedValue: T {
get {
return Bundle.main.decode(T.self, from: fileName)
}
set {
//not sure i need to set anything since i just want to get the array
}
}
init(_ fileName: String) {
self.fileName = fileName
wrappedValue = Bundle.main.decode(T.self, from: fileName)
}
}
Currently the body of the the ContentView shows this error:
Failed to produce diagnostic for expression; please file a bug report
I like the idea of removing some boilerplate code, but I think I'm missing something fundamental here.
In SwiftUI views are refreshed very often. When a view is refreshed then the #propertyWrapper will be initialised again - this might or might not be desirable. But it's worth noting.
Here is a simple demo showing how to create a property wrapper for loading JSON files. For simplicity I used try? and fatalError but in the real code you'll probably want to add a proper error handling.
#propertyWrapper
struct DataLoader<T> where T: Decodable {
private let fileName: String
var wrappedValue: T {
guard let result = loadJson(fileName: fileName) else {
fatalError("Cannot load json data \(fileName)")
}
return result
}
init(_ fileName: String) {
self.fileName = fileName
}
func loadJson(fileName: String) -> T? {
guard let url = Bundle.main.url(forResource: fileName, withExtension: "json"),
let data = try? Data(contentsOf: url),
let result = try? JSONDecoder().decode(T.self, from: data)
else {
return nil
}
return result
}
}
Then, assuming you have a sample JSON file called items.json:
[
{
"name": "Test1",
"count": 32
},
{
"name": "Test2",
"count": 15
}
]
with a corresponding struct:
struct Item: Codable {
let name: String
let count: Int
}
you can load your JSON file in your view:
struct ContentView: View {
#DataLoader("items") private var items: [Item]
var body: some View {
Text(items[0].name)
}
}

JSON with SwiftUI using Array

I am new to SwiftUI and only used UIKit before. I tried to use JSON to show a title but all tutorial videos work with lists. I dont want to use any list with JSON which shows all data. Only want to fetch for example the second or a specific array for title.
How can I remove the list in SwiftUI?
My View:
struct ContentView: View {
#ObservedObject var networkManager = NetworkManager()
var body: some View {
NavigationView {
List(networkManager.posts) { post in
HStack {
Text(String(post.points))
Text(post.title)
}}
.navigationBarTitle("H4X0R NEWS")
}
.onAppear {
self.networkManager.fetchData()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
NetworkManager:
class NetworkManager: ObservableObject {
#Published var posts = [Post]()
func fetchData() {
if let url = URL(string: "https://hn.algolia.com/api/v1/search?tags=front_page") {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) { (data, response, error) in
if error == nil {
let decoder = JSONDecoder()
if let safeData = data {
do {
let results = try decoder.decode(Results.self, from: safeData)
DispatchQueue.main.async {
self.posts = results.hits
}
} catch {
print(error)
}
}
}
}
task.resume()
}
}
}
And my struct files for Json:
struct Results: Decodable {
let hits: [Post]
}
struct Post: Decodable, Identifiable {
var id: String {
return objectID
}
let objectID: String
let points: Int
let title: String
}
I dont want to use any list with JSON which shows all data. Only want
to fetch for example the second or a specific array for title.
You can use a computed property to access the specific element (and its title) from the posts array:
struct ContentView: View {
#ObservedObject var networkManager = NetworkManager()
// return the title of the second item in the `posts` array
var title: String {
guard networkManager.posts.count >= 2 else {
// decide what to do when data is not yet loaded or count is <= 1
return "Loading..."
}
return networkManager.posts[1].title
}
var body: some View {
NavigationView {
Text(title)
.navigationBarTitle("H4X0R NEWS")
}
.onAppear {
self.networkManager.fetchData()
}
}
}

SwiftUI How can I pass in data into an ObservedObject function

I am new to SwiftUI and Swift . I got a Search Bar and a Listview whenever a user types something in the searchbar I do an http request and new data comes in . The issue is that the list is not updating with the new data and I think I know why . I need to pass my SearchBar response into the ObservedObject variable . I was reading this swiftui ObservedObject function call in all view however I still didn't find my answer . This is my code
struct RegistrationView: View {
#State private var searchTerm: String = ""
#State var txt = "" // Txt has the SearchBar text
#ObservedObject var getData = datas(location: "") // I need to pass it here
var body: some View {
VStack {
Text("Registration")
searchView(txt: $txt)
// datas(location: txt)
NavigationView {
List(getData.jsonData.filter{ txt == "" ? true : $0.name.localizedCaseInsensitiveContains(txt)}) { i in
ListRow(name: i.name,statelong: i.statelong)
}
}
.padding(.top, 5.0)
}
}
}
class datas: ObservableObject
{
#Published var jsonData = [datatype]()
init(location: String) {
let session = URLSession(configuration: .default)
if location == "" {
return
}
let parameter = "location=\(location)"
if location == "" {
return
}
let url = URL(string:"url")!
let request = RequestObject(AddToken: true, Url: url, Parameter: parameter)
session.dataTask(with:request, completionHandler: {(data, response, error) in
do
{
if data != nil
{
let fetch = try JSONDecoder().decode([datatype].self, from: data!)
DispatchQueue.main.async {
self.jsonData = fetch
print(fetch)
}
}
}
catch
{
print(error.localizedDescription)
}
}).resume()
}
}
In the above code I want to pass in the txt variable into the getData variable or do something like this #ObservedObject var getData = datas(location: txt) . When the SearchBar is updated then txt gets whatever is inserted into the SearchBar .
If I do something like this
#ObservedObject var getData = datas(location: "Me")
Then the list will update and correctly have everything that starts with Me my only issue is getting the SearchBar value inside datas so I don't have to hardcode things . As stated before I need to pass in txt to datas . Any help would be great
You don't need to init the class with that variable. You can just make a function for that and fetch it when ever you need. It could be just once.
class datas: ObservableObject {
#Published var jsonData = [datatype]()
func get(location: String) {
let session = URLSession(configuration: .default)
guard !location.isEmpty else { return }
let parameter = "location=\(location)"
let url = URL(string:"url")!
let request = RequestObject(AddToken: true, Url: url, Parameter: parameter)
session.dataTask(with:request, completionHandler: {(data, response, error) in
do {
guard data != nil else { return }
let fetch = try JSONDecoder().decode([datatype].self, from: data!)
DispatchQueue.main.async {
self.jsonData = fetch
print(fetch)
}
} catch {
print(error.localizedDescription)
}
}).resume()
}
}