I have been trying to deal with an #escaping closure in swift but I'm just not getting my head around it. My aim is to retrieve a download link from Google Firebase Storage - using the id field retrieved from a DB Document (originally) so I can download via NukeUI's LazyImage but I'm clearly missing something, basically is this the best way to approach this and if so what am I doing wrong here? If there is a more suitable approach to solve this can you point me in the right direction.
the code from the swiftUI view
var body: some View {
GeometryReader { geometry in
ScrollView {
let side = geometry.size.width / 4
let item = GridItem(.fixed(side), spacing: 2)
LazyVGrid(columns: Array(repeating: item, count: 4), spacing: 2) {
// Image Section.
ForEach(0..<siteData.sites.count, id: \.self) { index in
let currentSiteURL: String = grabURL(id: siteData.sites[index].id!)
LazyImage(source: currentSiteURL)
.frame(width: side, height: side)
.onAppear { model.onAppear(index) }
.onDisappear { model.onDisappear(index) }
}
}
}
}
}
And the function to retrieve the url to download:
func grabURL(id: String, completion: #escaping (String?)->Void) {
let ref = Storage.storage().reference().child("ImagesThumb")
ref.child("\(id).jpeg").downloadURL { (url, error) in
if let error = error {
print("Error Occured: \(error)")
completion(nil)
return
}
guard let siteURL = url else {
completion(nil)
return
}
completion(siteURL.absoluteString)
}
}
We use #escaping when the closure passed will outlive the parent function. Looking at your implementation, I think it's safe to say that your use of #escaping is right.
However, as vadian commented, your data fetching function is a bad practice. Consider using .task and async/await.
Related
I am following tutorials to understand SwiftUI, and specifically how to call an API when a view appears.
I saw this:
List(results, id: \.trackId) { item in
ListRow(item)
}
.task {
// perform API here
}
But as my app targets iOS 14, I get this error:
'task(priority:_:)' is only available in iOS 15.0 or newer
So what could I do instead? Thank you for your help
You can write a version of task { } that works for iOS 13, iOS 14 and uses apple's version for iOS 15:
extension View {
#available(iOS, deprecated: 15.0, message: "This extension is no longer necessary. Use API built into SDK")
func task(priority: TaskPriority = .userInitiated, _ action: #escaping #Sendable () async -> Void) -> some View {
self.onAppear {
Task(priority: priority) {
await action()
}
}
}
}
async await is available for iOS 13+.
https://developer.apple.com/documentation/swift/task
if you need to use an async call is wrap the call in Task
.onAppear(){
Task{
//Your async code here
// await yourFuncHere()
}
}
.onAppear is a bit un reliable so I might opt for an init of an ObservableObject as an alternative.
Just switching to .onAppear is not correct since it's missing the point of Structured Concurrency. Every time you create a Task yourself you should be suspicious, you are doing something out of the ordinary.
Granted, in this case we don't have available a "structured concurrency aware" lifecycle modifier, so we need to make our own with Task init, but that means you need to be responsible of respecting structured concurrency!
This means that getting a proper backwards compatible solution to work is a bit more code, since you want to handle cancellation properly. For that you need to use also .onDisappear and cancel the task that you started on .onAppear.
If you want to have it reusable you can make a custom .task modifier.
import SwiftUI
struct ContentView: View {
#State var results = [TaskEntry]()
var body: some View {
List(results, id: \.id) { item in
VStack(alignment: .leading) {
Text(item.title)
}
// this one onAppear you can use it
}.onAppear(perform: loadData)
}
func loadData() {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/todos") else {
print("Your API end point is Invalid")
return
}
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
if let response = try? JSONDecoder().decode([TaskEntry].self, from: data) {
DispatchQueue.main.async {
self.results = response
}
return
}
}
}.resume()
}
}
Noob here.
I'm making a lyrics search app that simply uses an API which receives a song name along with an artist name and simply returns the lyrics. I basically have two questions:
First one Being: Im having trouble showing a new Sheet with information that comes from the API. So my code works as follows: From the View, press a button which, if the user is connected to the internet, call a method that does the whole API calling, creates a SongDetails object with all the info on that song(name, artist and lyrics) and add it to the #Published searchedSongs array (previously checking the same song hasnt been searched before). Once that is done, I want the sheet to show the lyrics from that array.
My problem is the app crashes with an error of IndexOutOfRange when I want to access the searchedSongs array from the view since it seems its not actually waiting for the SongDetails object to be fully added to the array before rendering the sheet. This seems to be some sort of concurrency problem I guess. Is there any way to only show the sheet once the SongDetails object has been added to the array? My current code is:
HomeView.swift
HStack {
Spacer()
Button(action: {
if(!NetworkMonitor.shared.isConnected) {
self.noConnectionAlert.toggle()
} else {
viewModel.loadApiSongData(songName: songName, artistName: artistName)
self.showingLyricsSheet = true
}
}, label: {
CustomButton(sfSymbolName: "music.note", text: "Search Lyrics!")
})
.alert(isPresented: $noConnectionAlert) {
Alert(title: Text("No internet connection"), message: Text("Oops! It seems you arent connected to the internet. Please connect and try again!"), dismissButton: .default(Text("Got it!")))
}
Spacer()
}
.padding(.top, 20)
.sheet(isPresented: $showingLyricsSheet) {
LyricsView(vm: self.viewModel, songName: songName, artistName: artistName)
}
ViewModel.swift
class ViewModel : ObservableObject {
#Published var searchedSongs = [SongDetails]()
func loadApiSongData(songName: String, artistName: String) {
let rawUrl = "https://api.lyrics.ovh/v1/\(artistName)/\(songName)"
let fixedUrl = rawUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
print("Old url: \(rawUrl)")
print("New url: \(fixedUrl!)")
guard let url = URL(string: fixedUrl!) else {
print("Invalid URL")
return
}
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
if let decodedResponse = try? JSONDecoder().decode(Song.self, from: data) {
// we have good data – go back to the main thread
DispatchQueue.main.async {
// update our UI
print("Good. Lyrics:")
if(!self.songAlreadySearched(songName: songName)) {
let song = SongDetails(songName: songName, artistName: artistName, lyrics: decodedResponse.lyrics)
self.searchedSongs.append(song)
}
}
// everything is good, so we can exit
return
}
}
// if we're still here it means there was a problem
print("Fetch failed: \(error?.localizedDescription ?? "Unknown error")")
}.resume()
}
LyricsView.swift
ScrollView {
Text(vm.searchedSongs[0].lyrics)
.foregroundColor(.white)
.multilineTextAlignment(.center)
}
Second one: Im having a hard time understanding how URLSession handles error cases. If for whatever reason (say I submit "asd" as song name and "fds" as artist name) the api fails to retrieve the lyrics, how can I know that from the view and be able to not even show the lyrics sheet in the first place since there wont be any lyrics to show at all.
Any help is appreciated. Thanks!
Your question doesn't include enough code that I can show you exactly what to do, but I can give you the general steps.
Don't set showingLyricsSheet directly after your loadApiSongData call. loadApiSongData is asynchronous, so this will practically guarantee that the sheet will be shown before the API call loads. Instead, bind the sheet's presentation to a variable on your view model that only gets set once the API request has finished. I'd recommend using the sheet(item:) form instead of sheet(isPresented:) in order to avoid pitfalls that are common with getting the most recently-updated values in the sheet.
Instead of having LyricsView access vm.searchedSongs, perhaps pass the songs directly as a parameter to LyricsView. Again, this would be easy with the strategy from #1 (including using sheet(item:)).
Here's a simple mockup illustrating the concepts from #1 and #2:
struct APIResponse : Identifiable {
var id = UUID()
var apiValues : [String] = []
}
class ViewModel : ObservableObject {
#Published var apiResponse : APIResponse?
func apiCall() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.apiResponse = APIResponse(apiValues: ["Testing","1","2","3"])
}
}
}
struct ContentView : View {
#StateObject private var viewModel = ViewModel()
var body: some View {
Text("Hello, world")
.sheet(item: $viewModel.apiResponse) { item in
LyricsView(lyrics: item.apiValues)
}
.onAppear {
viewModel.apiCall()
}
}
}
struct LyricsView : View {
var lyrics : [String]
var body: some View {
Text(lyrics.joined(separator: ", "))
}
}
I want to implement a Text field that displays the current user's existing score in the DB (Firestore). Because of the nature of async in Firebase query, I also need to do some adjustment in my codes. However, it seems that completion() handler does not work well:
// ViewModel.swift
import Foundation
import Firebase
import FirebaseFirestore
class UserViewModel: ObservableObject {
let current_user_id = Auth.auth().currentUser!.uid
private var db = Firestore.firestore()
#Published var xp:Int?
func fetchData(completion: #escaping () -> Void) {
let docRef = db.collection("users").document(current_user_id)
docRef.getDocument { snapshot, error in
print(error ?? "No error.")
self.xp = 0
guard let snapshot = snapshot else {
completion()
return
}
self.xp = (snapshot.data()!["xp"] as! Int)
completion()
}
}
}
// View.swift
import SwiftUI
import CoreData
import Firebase
{
#ObservedObject private var users = UserViewModel()
var body: some View {
VStack {
HStack {
// ...
Text("xp: \(users.xp ?? 0)")
// Text("xp: 1500")
.fontWeight(.bold)
.padding(.horizontal)
.foregroundColor(Color.white)
.background(Color("Black"))
.clipShape(CustomCorner(corners: [.bottomLeft, .bottomRight, .topRight, .topLeft], size: 3))
.padding(.trailing)
}
.padding(.top)
.onAppear() {
self.users.fetchData()
}
// ...
}
}
My result kept showing 0 in Text("xp: \(users.xp ?? 0)"), which represents that the step is yet to be async'ed. So what can I do to resolve it?
I would first check to make sure the data is valid in the Firestore console before debugging further. That said, you can do away with the completion handler if you're using observable objects and you should unwrap the data safely. Errors can always happen over network calls so always safely unwrap anything that comes across them. Also, make use of the idiomatic get() method in the Firestore API, it makes code easier to read.
That also said, the problem is your call to fetch data manually in the horizontal stack's onAppear method. This pattern can produce unsavory results in SwiftUI, so simply remove the call to manually fetch data in the view and perform it automatically in the view model's initializer.
class UserViewModel: ObservableObject {
#Published var xp: Int?
init() {
guard let uid = Auth.auth().currentUser?.uid else {
return
}
let docRef = Firestore.firestore().collection("users").document(uid)
docRef.getDocument { (snapshot, error) in
if let doc = snapshot,
let xp = doc.get("xp") as? Int {
self.xp = xp
} else if let error = error {
print(error)
}
}
}
}
struct ContentView: View {
#ObservedObject var users = UserViewModel()
var body: some View {
VStack {
HStack {
Text("xp: \(users.xp ?? 0)")
}
}
}
}
SwiftUI View - viewDidLoad()? is the problem you ultimately want to solve.
I have a SwiftUI app that fetches some information from the backend when the view appears and then attempts to update the State by setting #Published vars in an ObservableObject. The problem I have is it doesn't update at first fetch (it remains empty since it was initialized with an empty array) but if I click to another view and come back it's updated (since the information was already fetched).
Obviously, the intended thing I'm going for with using #Published is for the view to update once the information is fetched. This is part of a larger app but I have the reduced version of what I have below.
First, we have a parent view that contains the view I want to update.
struct ParentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
SummaryView()
// In real life I have various forms of summary
// but to simplify here I will just use this one SummaryView.
SummaryView()
SummaryView()
}
}
}
}
}
Here is the summary view itself:
struct SummaryView: View {
#ObservedObject var model = AccountsSummaryViewModel()
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Accounts")
.font(.title)
Spacer()
NavigationLink(
destination: AccountView(),
label: {
Image("RightArrow")
})
}
if model.accounts.count > 0 {
Divider()
}
// And if I add the following line for debugging
//Text(model.accounts.count)
// It remains 0.
ForEach(model.accounts, id: \.id) { account in
Text(account.account.text)
}
}
.padding()
.onAppear() {
model.onAppear()
}
}
}
Here is it's simple view model:
class AccountsSummaryViewModel: ObservableObject, Identifiable {
#Published var accounts: [AccountIdentifiable] = []
func onAppear() {
AccountsService.accounts { (success, error, response) in
DispatchQueue.main.async {
// This always succeeds
if let response = response {
// All AccountIdentifiable does is make a struct that is Identifiable (has an account and a var id = UUID())
self.accounts = Array(response.accounts.map { AccountIdentifiable(account: $0) }.prefix(3))
}
}
}
}
}
Here is the contents of the AccountsService also, I will note that the URL is a localhost but I'm not sure if that matters:
public struct AccountsService {
public static func accounts(completion: #escaping ((Bool, Error?, AccountsResponse?) -> Void)) {
guard let url = getAllAccountsURL() else {
completion(false, nil, nil)
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.allHTTPHeaderFields = ["Content-Type": "application/json",
BusinessConstants.SET_COOKIE : CredentialsObject.shared.jwt]
let task = URLSession.shared.dataTask(with: request) { (data, urlResponse, error) in
guard let data = data else {
completion(false, error, nil)
return
}
guard let response = try? JSONDecoder().decode(AccountsResponse.self, from: data) else {
completion(false, error, nil)
return
}
// This does successfully decode and return here.
completion(true, nil, response)
return
}
task.resume()
}
private static func getAllAccountsURL() -> URL? {
let address = "\(BusinessConstants.SERVER)/plaid/accounts"
return URL(string: address)
}
}
I have read that there are issues with an empty ScrollView, however, my ScrollView is never empty as I have those static text elements. I also read that if you use a ForEach without the id it can fail - but you can see I am using the id so I'm kind of at a loss.
I have print statements in the onAppear() so I know it runs and successfully sets the #Published accounts but looking at the UI and putting breakpoints in the ForEach I can see the view does not update. However, if I navigate somewhere else in my app, and then come back to the ParentView then since the #Published accounts is non-empty (already fetched) it updates perfectly.
It looks like you're running into a problem because of the two levels of observed objects, with model: AccountsSummaryViewModel containing accounts: [AccountIdentifiable].
SwiftUI will only watch one level, leading to your ParentView not updating when accounts is set more than one UI level down.
As discussed here, one option is to use PublishedObject via the Swift Package Manager in Xcode. Changing model in your SummaryView to #PublishedObject may be all that's required to fix this.
The reason it was not working was due to the fact that I was using #ObservedObject instead of #StateObject in SummaryView. Making the change fixed the issue.
I am running a func that goes into a for loop and append to a an array. In the next line I am running another func that uses the first element of that array, however, the app crashes since at the time of the 2nd func execution it finds the array empty. I trued using sync() queue and completion handlers but still have the issue. The only way that it is working at the moment is to call a Timer to wait for a few seconds but that is not ideal way to do it of course. Do you have any suggestions?
The 1st func is as follows:
func openRun () {
let openPanel = NSOpenPanel()
...
if result.rawValue == NSApplication.ModalResponse.OK.rawValue {
let rawURL = openPanel.url!.path
//some codes that extract image files from the openned path
for image in imageList {
images.append(newImage)
}
}
}
It is hard to see, what you try to do. Check next Playground snippet which use NSOpenPanel to select some .swift file(s) and asynchronously (random delay mimics the real world usage) calculates length of its absolute path and show the results in SwiftUI View.
//: A Cocoa based Playground to present user interface
import AppKit
import SwiftUI
import PlaygroundSupport
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.allowedFileTypes = ["swift"]
struct Info: Identifiable {
let id = UUID()
let txt: String
let length: Int
}
struct ContentView: View {
#State var arr: [Info] = []
var body: some View {
VStack {
Button(action: {
panel.begin { (respond) in
panel.urls.forEach { (url) in
self.urlLength(url: url) { (i) in
self.arr.append(Info(txt: url.lastPathComponent, length: i))
}
}
}
}) {
Text("action")
}.padding()
List(arr) { (item) in
HStack {
Text(item.txt)
Text(item.length.description).foregroundColor(Color.yellow)
}
}
}.frame(width: 200, height: 400)
.border(Color.red)
}
func urlLength(url: URL, completion: #escaping (Int)->()) {
DispatchQueue.global().asyncAfter(deadline: .now() + Double.random(in: 0.0 ..< 3.0)) { [url] in
let c = url.absoluteString.count
DispatchQueue.main.async {
completion(c)
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
this funny example demonstrates how to use asynchronous code with SwiftUI