I am showing a Card incl. a recipe summary in my app. Via an Edit-Sheet, I am able to change the respecting recipe to a new one. After saving, all the information in the card are updated, but the picture of the old recipe is still shown. Only after I go back and forth in the navigation tree, the picture is updated.
Is there an easy way to tell my ImageLoaderView to reload the picture whenever the value of the recipeVM changes?
import FirebaseStorage
import SDWebImageSwiftUI
import SwiftUI
struct ImageLoaderView: View {
#EnvironmentObject var recipeVM: RecipeViewModel
let storage = Storage.storage()
#State private var imageURL = URL(string: "")
var body: some View {
WebImage(url: imageURL)
.resizable()
.placeholder {
Rectangle().foregroundColor(.gray)
}
.indicator(.activity)
.onAppear(perform: loadImageFromFirebase)
}
func loadImageFromFirebase() {
let storage = Storage.storage().reference(withPath: "images/\(recipeVM.recipe.id ?? "").jpg")
storage.downloadURL { url, error in
if error != nil {
print((error?.localizedDescription)!)
return
}
self.imageURL = url!
}
}
}
For me it looks like the problem is, that the "onAppear" method of the ImageLoaderView is not loaded when coming back from the sheet. So I somehow have to trigger the "loadImageFromFirebase" method whenever the value of "recipeVM" changes. I tried using "didSet" with the var, but this did not work either.
Any ideas?
It would be helpful to see RecipeViewModel, but I'm going to make an assumption that it looks something like this:
struct Recipe {
var id : UUID
}
class RecipeViewModel : ObservableObject {
#Published var recipe : Recipe = Recipe(id: UUID())
}
(Keep in mind that recipe has to be #Published for this to work`)
In that case, the you can listen for changes on recipe by doing something like this in your ImageLoaderView:
.onReceive(recipeVM.$recipe) { _ in
loadImageFromFirebase()
}
This can replace your onAppear, since it'll get called with the initial value and then again any time it changes.
If you wanted to, you could even refactor this to send the specific recipe into the loadImageFromFirebase function:
.onReceive(recipeVM.$recipe) { recipe in
loadImageFromFirebase(recipe: recipe)
}
//....
func loadImageFromFirebase(recipe: Recipe) {
let storage = Storage.storage().reference(withPath: "images/\(recipe.id ?? "").jpg")
storage.downloadURL { url, error in
if error != nil {
print((error?.localizedDescription)!)
return
}
self.imageURL = url!
}
}
Related
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 am trying to implement a search bar in my app, as now I want to use the keyword typed in the search bar to make an API call to fetch backend data, here is my code:
struct SearchView: View {
#State private var searchText : String=""
#ObservedObject var results:getSearchList
init(){
results = SearchList(idStr: self.searchText)
}
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
}.navigationBarTitle(Text("Search"))
}
}
}
I implement SearchBar view followed the this tutorial https://www.appcoda.com/swiftui-search-bar/ exactly,
and getSearchList is a class which has an var called idStr,
struct searchResEntry: Codable, Identifiable{
var id:Int
var comment:String
}
class SearchList: ObservableObject {
// 1.
#Published var todos = [searchResEntry]()
var idStr: String
init(idStr: String) {
self.idStr = idStr
let url = URL(string: "https://..." + idStr)!
// 2.
URLSession.shared.dataTask(with: url) {(data, response, error) in
do {
if let todoData = data {
// 3.
let decodedData = try JSONDecoder().decode([searchResEntry].self, from: todoData)
DispatchQueue.main.async {
self.todos = decodedData
}
} else {
print("No data")
}
} catch {
print("Error")
}
}.resume()
}
}
the problem I am struggling now is that I want to use the variable searchText to initialize the getSearchList , getSearchList has an var called idStr, this idStr is to used to store the typed keyword, my code always get an error: 'self' used before all stored properties are initialized , I have no idea how to deal with this.
Here is your code, edited by me:
struct SearchView: View {
#StateObject var results = SearchList()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $results.searchText)
}.navigationBarTitle(Text("Search"))
}
}
}
struct SearchResEntry: Codable, Identifiable {
var id:Int
var backdrop_path:String
}
class SearchList: ObservableObject {
#Published var todos = [SearchResEntry]()
#Published var searchText: String = ""
var cancellable: AnyCancellable?
init() {
cancellable = $searchText.debounce(
for: .seconds(0.2),
scheduler: RunLoop.main
).sink { _ in
self.performSearch()
}
}
func performSearch() {
if let pathParam = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(string: "https://hw9node-310902.uc.r.appspot.com/mutisearch/\(pathParam)") {
URLSession.shared.dataTask(with: url) {(data, response, error) in
do {
if let todoData = data {
let decodedData = try JSONDecoder().decode([SearchResEntry].self, from: todoData)
DispatchQueue.main.async {
self.todos = decodedData
}
} else {
print("No data")
}
} catch {
print("Error")
}
}.resume()
} else {
print("Invalid URL")
}
}
}
Explanation
You are free to reverse the optional changes i made, but here are my explanations:
Use capital letter at the beginning of a Type's name. e.g write struct SearchResEntry, don't write struct searchResEntry. This is convention. Nothing big will happen if you don't follow conventions, but if anyone other than you (or maybe even you in 6 months) look at that code, chances are they go dizzy.
Dont start a Type's name with verbs like get! Again, this is just a convention. If anyone sees a getSomething() or even GetSomething() they'll think thats a function, not a Type.
Let the searchText be a published property in your model that performs the search. Don't perform search on init, instead use a function so you can initilize once and perform search any time you want (do results.performSearch() in your View). Also you can still turn your searchText into a binding to pass to your search bar (look at how i did it).
EDIT answer to your comment
I could right-away think of 3 different answers to your comment. This is the best of them, but also the most complicated one. Hopefully i chose the right option:
As you can see in the class SearchList i've added 2 things. First one is a cancellable to store an AnyCancellable, and second is the thing in init() { ... }. In init, we are doing something which results in an AnyCancellable and then we are storing that in the variable that i added.
What am i doing In init?
first $searchText gives us a Publisher. Basically, the publisher is called whenever the searchText value changes. Then you see .debounce(for: .seconds(0.2), on: RunLoop.main) which means only let the latest input go through and reach the next thing (the next thing is .sink { } as you can see), only if the user has stopped writing for 0.2 seconds. This is very helpful to avoid a load of requests to the server which can eventually make servers give you a 429 Too Many Requests error if many people are using your app (You can remove the whole .debounce thing if you don't like it). And the last thing is .sink { } which when any value reaches that point, it'll call the performSearch func for you and new results will be acquired from the server.
Alternative way
(again talking about your comment)
This is the simpler way. Do as follows:
remove init() { ... } completely if you've added it
remove var cancellable completely if you've added it
in your SearchView, do:
.onChange(of: results.searchText) { _ in
results.performSearch()
}
pretty self-explanatory; it'll perform the search anytime the searchText value is changed.
I'm new in Swift and I'm trying to display inside a SwiftUI ListView :
some datas
an image (linked to datas)
It's kind of a contact app.
All these datas are stored in firestore. I created a function which gives the image URL on firestore :
func getURL(path: String, completion: #escaping (((URL?) -> Void))) {
let storage = Storage.storage()
storage.reference().child(path).downloadURL(completion: { url, error in
guard let url = url, error == nil else {
return
}
let urlPath = url.absoluteURL
completion(urlPath)
})
}
But when i call this function in the SwiftUI View, the following error appears :
"Type '()' cannot conform to 'View'; only struct/enum/class types can conform to protocols"
There is the calling code of the function :
getURL(path: bike.access1, completion: { path in AnimatedImage(url: path)})
I'm aware that i can't call function inside a view, but i don't see how to manage to display an image from Firestore inside a SwiftUI List View.
If anyone know a strategy, I'm really interested.
Thanks in advance
Jean
The completion handler is (in general) a great way of dealing with asynchronous code. But, it a View in SwiftUI, it's a little more common to use a #State or #Published value and then render the view conditionally based on its state.
I like using an ObservableObject view model for this sort of thing:
class ViewModel : ObservableObject {
#Published var imageURL : URL?
func getURL(path: String) {
let storage = Storage.storage()
storage.reference().child(path).downloadURL(completion: { url, error in
guard let url = url, error == nil else {
return
}
self.imageURL = url
})
}
}
struct ContentView : View {
#StateObject private var viewModel = ViewModel()
var body: some View {
VStack {
if let url = viewModel.imageURL {
AnimatedImage(url: url)
}
}.onAppear {
viewModel.getURL(path: "URL_STRING_GOES_HERE")
}
}
}
Note that my types
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.
Here is the scenario: Onappear my view calls the getData method of my ObservableObject Class. During debug I see that it retrieves the data and returns the array as expected. When my view appears on the simulator the data flashes briefly on the list, then the list goes blank. I use this same pattern in several places in my application and in some cases it works (the list appears as it should) and in others it goes blank.
I'm testing on both 13.0 and 13.3 simulators due to various swiftUI bugs that work on one or the other. I thought it may be because of the async call to Firestore, but I've implemented completion handlers. IF I change the ObservedObject to a #State then the list appears correctly, but when new items are added it doesn't refresh.
Here is code for one of the examples that is not working:
My VIEW:
import SwiftUI
import Firebase
struct ManagerListView: View {
var circuitId: String
#State private var isMgr:Bool = true //TODO: reset to false
#EnvironmentObject var session : SessionStore
#ObservedObject var managerArray = ManagerList()
func deleteMgrDb(manager: Manager) {
let docRef = Firestore.firestore().collection("circuits").document(circuitId).collection("managers").document(manager.id)
docRef.delete { (error) in
if error != nil {
print(error!.localizedDescription)
}
}
}
var body: some View {
List {
ForEach(managerArray.managers) { i in
Text("\(i.firstName) \(i.lastName) \(i.phone)").deleteDisabled(!self.isMgr)
}.onDelete { (indexset) in
let manager = self.managerArray.managers[indexset.first!]
print("Here \(self.managerArray.managers[indexset.first!])")
//TODO: Check if owner (can't remove owner) or maybe if at least one manager (then delete owner?)
self.managerArray.managers.remove(atOffsets: indexset)
self.deleteMgrDb(manager: manager)
}.disabled(!isMgr)
}.onAppear(perform: {
self.managerArray.getData(forcircuitid: self.circuitId) { (error) in
if error != nil {
//TODO: Return data error to user
print(error!.localizedDescription)
}
//TODO: isMgr
}
}).onDisappear(perform: {
self.managerArray.stopMgrListListener()
}).navigationBarItems(trailing: isMgr ?
NavigationLink(destination: CircuitManagerAddView(circuitId: self.circuitId, currentmgrs: managerArray.managers), label: {
Image(systemName: "plus")
}): nil) //.disabled(!isMgr))
}
}
My Observable Class:
class ManagerList: ObservableObject {
#Published var managers = [Manager]()
var listener: ListenerRegistration?
func getData(forcircuitid: String, completion: #escaping (Error?)->Void) {
let db = Firestore.firestore().collection("circuits").document(forcircuitid).collection("managers")
self.managers.removeAll()
listener = db.addSnapshotListener { (snapshot, error) in
if error != nil {
print(error!.localizedDescription)
completion(error)
}
else {
for i in snapshot!.documentChanges {
if i.type == .added {
let email = i.document.get("email") as! String
let lastName = i.document.get("lastname") as! String
let firstName = i.document.get("firstname") as! String
let phone = i.document.get("phonenum") as! String
let manager = Manager(id: email, lastName: lastName, firstName: firstName, phone: phone)
self.managers.append(manager)
//TODO: Changed
}
}
completion(nil)
}
}
}
For my lists that are working, I am using basically the exact same code except changing the Firestore documents, data elements, variable names, etc.. They even run the same way in the debugger, only difference is some work and display the list and others don't.
The list items are disappearing because ManagerListView is re-initialised every time your ManagerList data changes. When that happens ManagerList is also re-initialised but the code you have executing in onAppear is only called the first time the view appears and not on subsequent data changes.
SwiftUI makes no guarantees about the lifetime of #ObservedObject variables. You have to manage that yourself. In iOS 14 they introduced #StateObject to make this easier. You can read more here.
As a side note, onDisappear is only called when your view is no longer being displayed. It would be better to remove the Firebase listener in ManagerList from within the classes de-initialiser function.