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: ", "))
}
}