How to make an additional API call once inside a detail view? - swift

I'm fetching books from an endpoint as such:
class APIManager: ObservableObject {
#Published var books = [Book]()
func fetchBooks() {
if let url = URL(string: urlEndpoint) {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) { (data, response, error) in
if error == nil {
if let safeData = data {
do {
let response = try JSONDecoder().decode([Book].self, from: safeData)
DispatchQueue.main.async {
self.books = response
}
} catch {
print(error)
}
}
}
}
task.resume()
}
}
}
My BookView looks like this:
struct BookView: View {
#ObservedObject var apiManager = APIManager()
var body: some View {
NavigationView {
List {
ForEach(apiManager.books) { book in
NavigationLink(
destination: BookDetailView(id: book.id, chapter: book.chapters),
label: {
HStack {
Text(book.name)
Spacer()
Text(book.testament)
}
})
}.navigationBarTitle("Book Title Here")
}.onAppear {
self.apiManager.fetchBooks()
}
}
}
}
When navigating to BookDetailView - I need to make another API call to fetch additional details about the book (such as chapters), given the book id that is passed here:
...
destination: BookDetailView(id: book.id, chapter: book.chapters)
...
Do I simply repeat the process and make another function in my APIManager class and add another #Published var chapters = [Chapter]()
And inside BookDetailView go
// Loop through each chapter here
// I want to display chapter details in this view
Text("You are viewing book id \(id). Chapter: \(chapter)").onAppear {
self.apiManager.fetchChapterDetails()
}
Doing so returns UIScrollView does not support multiple observers implementing
Whats the procedure here?

I would suggest making a separate ViewModel for the Detail Page. In that ViewModel you can pass in the id of the Book and make a separate API function. Also think about extracting the API Calls into a Service class, which being called from the ViewModel.
The Detail View and ViewModel could look like that...
class BookDetailViewModel: ObservableObject {
let bookId: Int
init(withBookId bookId: Int) {
self.bookId = bookId
}
func fetchBookInfos() {
// ...
}
}
struct BookDetailView: View {
#ObservedObject var viewModel: BookDetailViewModel
var body: some View {
NavigationView {
List {
Text("Book id \(viewModel.bookId)")
}.onAppear {
self.viewModel.fetchBookInfos()
}
}
}
}
When creating the Detail View, pass in the ViewModel.

Related

Redirecting after task w/ Await completes

In a view, I want to wait for a series of async calls to finish loading, then redirect to another screen. Unfortunately, I see the code running in the back (The JSON data gets loaded) but once it completes it does not redirect to the new view.
Here is my view:
struct loadingView: View {
#ObservedObject var dataLoader: DataLoader = DataLoader()
#State var isLoaded: Bool = false
var body: some View {
VStack {
Text("Loading \(isLoaded)")
}
}
.task {
await self.dataloader.loadJSONData(isLoaded: $isLoaded)
MainScreen()
}
}
...and the DataLoader class:
#MainActor DataLoader: NSObject, ObservableObject {
func loadJSONData(isLoaded: Binding<Bool>) {
await doLoadData()
isLoaded.wrappedValue = True
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
"Redirecting" here doesn't really make sense. Do you really want the user to be able to navigate back to the loading screen? Perhaps you're thinking of this like a web page, but SwiftUI is nothing like that. What you really want to do is display one thing when loading, and a different thing when loaded. That's just if, not "redirection."
Instead, consider the following pattern. Create this kind of LoadingView (extracted from some personal code of mine):
struct LoadingView<Content: View, Model>: View {
enum LoadState {
case loading
case loaded(Model)
case error(Error)
}
#ViewBuilder let content: (Model) -> Content
let loader: () async throws -> Model
#State var loadState = LoadState.loading
var body: some View {
ZStack {
Color.white
switch loadState {
case .loading: Text("Loading")
case .loaded(let model): content(model)
case .error(let error): Text(verbatim: "Error: \(error)")
}
}
.task {
do {
loadState = .loaded(try await loader())
} catch {
loadState = .error(error)
}
}
}
}
It require no redirection. It just displays different things when in different states (obviously the Text view can be replaced by something more interesting).
Then to use this, embed it in another View. In my personal code, that includes a view like this:
struct DailyView: View {
var body: some View {
LoadingView() { model in
LoadedDailyView(model: model)
} loader: {
try await DailyModel()
}
}
}
Then LoadedDailyView is the "real" view. It is handled a fully populated model that is created by DailyModel.init (a throwing, async init).
You could try this approach, using NavigationStack and NavigationPath to Redirecting after task w/ Await completes.
Here is the code I use to test my answer:
struct ContentView: View {
var body: some View {
loadingView()
}
}
#MainActor
class DataLoader: NSObject, ObservableObject {
func loadJSONData() async {
await doLoadData()
// for testing, wait for 1 second
try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State private var navPath = NavigationPath()
var body: some View {
NavigationStack(path: $navPath) {
VStack (spacing: 44) {
Text("Loading....")
}
.navigationDestination(for: Bool.self) { _ in
MainScreen()
}
}
.task {
await dataLoader.loadJSONData()
navPath.append(true)
}
}
}
struct MainScreen: View {
var body: some View {
Text("---> MainScreen here <---")
}
}
If you need ios 15 or earlier, then use NavigationView:
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State var isLoaded: Bool?
var body: some View {
NavigationView {
VStack {
Text(isLoaded == nil ? "Loading..." : "Finished loading")
NavigationLink("", destination: MainScreen(), tag: true, selection: $isLoaded)
}
}.navigationViewStyle(.stack)
.task {
await dataLoader.loadJSONData()
isLoaded = true
}
}
}
If your loadingView has the only purpose of showing the "loading" message, then
display the MainScreen after the data is loaded, you could use the following approach using a simple swicth:
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State private var isLoaded = false
var body: some View {
VStack {
if isLoaded {
MainScreen()
} else {
ProgressView("Loading")
}
}
.task {
await dataLoader.loadJSONData()
isLoaded = true
}
}
}
Use #StateObject instead of #ObservedObject. Use #Published instead of trying to pass a binding to the object (that is a mistake because a binding is just a pair of get and set closures that will expire if LoadingView is re-init), use Group with an if to conditionally show a View e.g.
struct LoadingView: View {
#StateObject var dataLoader: DataLoader = DataLoader()
var body: some View {
Group {
if dataLoader.isLoaded {
LoadedView(data: dataLoader.data)
} else {
Text("Loading...")
}
}
.task {
await dataloader.loadJSONData()
}
}
The DataLoader should not be #MainActor because you want it to run on a background thread. Use #MainActor instead on a sub-task once the async work has finished e.g.
class DataLoader: ObservableObject {
#Published var isLoaded = false
#Published var data: [Data] = []
func loadJSONData async {
let d = await doLoadData()
Task { #MainActor in
isLoaded = true
data = d
}
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
This pattern is shown in Apple's tutorial here, PandaCollectionFetcher.swift copied below:
import SwiftUI
class PandaCollectionFetcher: ObservableObject {
#Published var imageData = PandaCollection(sample: [Panda.defaultPanda])
#Published var currentPanda = Panda.defaultPanda
let urlString = "http://playgrounds-cdn.apple.com/assets/pandaData.json"
enum FetchError: Error {
case badRequest
case badJSON
}
func fetchData() async
throws {
guard let url = URL(string: urlString) else { return }
let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badRequest }
Task { #MainActor in
imageData = try JSONDecoder().decode(PandaCollection.self, from: data)
}
}
}

Swift: Error converting type 'Binding<Subject>' when passing Observed object's property to child view

I want to load data from an API, then pass that data to several child views.
Here's a minimal example with one child view (DetailsView). I am getting this error:
Cannot convert value of type 'Binding<Subject>' to expected argument type 'BusinessDetails'
import Foundation
import SwiftUI
import Alamofire
struct BusinessView: View {
var shop: Business
class Observer : ObservableObject{
#Published public var shop = BusinessDetails()
#Published public var loading = false
init(){ shop = await getDetails(id: shop.id) }
func getDetails(id: String) async -> (BusinessDetails) {
let params = [
id: id
]
self.loading = true
self.shop = try await AF.request("https://api.com/details", parameters: params).serializingDecodable(BusinessDetails.self).value
self.loading = false
return self.shop
}
}
#StateObject var observed = Observer()
var body: some View {
if !observed.loading {
TabView {
DetailsView(shop: $observed.shop)
.tabItem {
Label("Details", systemImage: "")
}
}
}
}
}
This has worked before when the Observed object's property wasn't an object itself (like how the loading property doesn't cause an error).
When using async/await you should use the .task modifier and remove the object. The task will be started when the view appears, cancelled when it disappears and restarted when the id changes. This saves you a lot of effort trying to link async task lifecycle to object lifecycle. e.g.
struct BusinessView: View {
let shop: Business
#State var shopDetails = BusinessDetails()
#State var loading = false
var body: some View {
if loading {
Text("Loading")
}
else {
TabView {
DetailsView(shop: shopDetails)
.tabItem {
Label("Details", systemImage: "")
}
}
}
.task(id: shop.id) {
loading = true
shopDetails = await Self.getDetails(id: shop.id) // usually we have a try catch around this so we can show an error message
loading = false
}
}
// you can move this func somewhere else if you like
static func getDetails(id: String) async -> BusinessDetails{
let params = [
id: id
]
let result = try await AF.request("https://api.com/details", parameters: params).serializingDecodable(BusinessDetails.self).value
return result
}
}
}

Updating a #State property from within a SwiftUI View

I have an AsyncContentView that handles the loading of data when the view appears and handles the switching of a loading view and the content (Taken from here swiftbysundell):
struct AsyncContentView<P:Parsable, Source:Loader<P>, Content: View>: View {
#ObservedObject private var source: Source
private var content: (P.ReturnType) -> Content
init?(source: Source, reloadAfter reloadTime:UInt64 = 0, #ViewBuilder content: #escaping (P.ReturnType) -> Content) {
self.source = source
self.content = content
}
func loadInfo() {
Task {
await source.loadData()
}
}
var body: some View {
switch source.state {
case .idle:
return AnyView(Color.clear.onAppear(perform: loadInfo))
case .loading:
return AnyView(ProgressView("Loading..."))
case .loaded(let output):
return AnyView(content(output))
}
}
}
For completeness, here's the Parsable protocol:
protocol Parsable: ObservableObject {
associatedtype ReturnType
init()
var result: ReturnType { get }
}
And the LoadingState and Loader
enum LoadingState<Value> {
case idle
case loading
case loaded(Value)
}
#MainActor
class Loader<P:Parsable>: ObservableObject {
#Published public var state: LoadingState<P.ReturnType> = .idle
func loadData() async {
self.state = .loading
await Task.sleep(2_000_000_000)
self.state = .loaded(P().result)
}
}
Here is some dummy data I am using:
struct Interface: Hashable {
let name:String
}
struct Interfaces {
let interfaces: [Interface] = [
Interface(name: "test1"),
Interface(name: "test2"),
Interface(name: "test3")
]
var selectedInterface: Interface { interfaces.randomElement()! }
}
Now I put it all together like this which does it's job. It processes the async function which shows the loading view for 2 seconds, then produces the content view using the supplied data:
struct ContentView: View {
class SomeParsableData: Parsable {
typealias ReturnType = Interfaces
required init() { }
var result = Interfaces()
}
#StateObject var pageLoader: Loader<SomeParsableData> = Loader()
#State private var selectedInterface: Interface?
var body: some View {
AsyncContentView(source: pageLoader) { result in
Picker(selection: $selectedInterface, label: Text("Selected radio")) {
ForEach(result.interfaces, id: \.self) {
Text($0.name)
}
}
.pickerStyle(.segmented)
}
}
}
Now the problem I am having, is this data contains which segment should be selected. In my real app, this is a web request to fetch data that includes which segment is selected.
So how can I have this view update the selectedInterface #state property?
If I simply add the line
self.selectedInterface = result.selectedInterface
into my AsyncContentView I get this error
Type '()' cannot conform to 'View'
You can do it in onAppear of generated content, but I suppose it is better to do it not directly but via binding (which is like a reference to state's external storage), like
var body: some View {
let selected = self.$selectedInterface
AsyncContentView(source: pageLoader) { result in
Picker(selection: selected, label: Text("Selected radio")) {
ForEach(result.interfaces, id: \.self) {
Text($0.name).tag(Optional($0)) // << here !!
}
}
.pickerStyle(.segmented)
.onAppear {
selected.wrappedValue = result.selectedInterface // << here !!
}
}
}

How to using the fetched data in swiftUI

I am currently working on an IOS app, I am trying to design a carousel with the fetched json data. This is how I get the targeted json data.
class loadDate: ObservableObject {
#Published var todos = [Result]()
init() {
let url = URL(string: "https://api.themoviedb.org/3/movie/now_playing?api_key=<api_key>&language=en-US&page=1")!
URLSession.shared.dataTask(with: url) { data, response, error in
// step 4
if let data = data {
if let decodedResponse = try? JSONDecoder().decode(Response.self, from: data) {
DispatchQueue.main.async {
self.todos = decodedResponse.results
}
// everything is good, so we can exit
return
}
}
print("Fetch failed: \(error?.localizedDescription ?? "Unknown error")")
}.resume()
}
}
It looks like I can only use the data in a list view, for example,
struct ContentView: View {
#ObservedObject var fetch = loadDate()
var body: some View {
HStack {
List(fetch.todos) { item in
HStack {
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.poster_path)
//
}
}
}
}
}
}
Within this list view, I can access all elements like using fetch.todos[0].title. However, if outside the list view, fetch.todos[0].title would fail, can someone give some advice on how to use the data outside the list view .
You should provide a working example. (https://stackoverflow.com/help/minimal-reproducible-example)
But if todos is an empty array, fetch.todos[0].title will always fail. So whenever you need to access your todos directly you can use something like this
Group {
if fetch.todos.count > 0 {
Text("Title of first todo \(fetch.todos[0].title)")
} else {
Text("Todos are empty")
}
}
Try this:
class loadDate: ObservableObject {
#Published var todos : Result? = nil // Step 1
// ...
}
struct ContentView: View {
#ObservedObject var fetch = loadDate()
var body: some View {
HStack {
if let todos = fetch.todos {
VStack{
Text(todos.title)
.font(.headline)
Text(todos.poster_path)
}
}
}
}
}
https://stackoverflow.com/a/66695683/14287797

#Published variable doesn't reload my view after API call?

I believe that I have set up my view and view model correctly. I have also confirmed that the network request returns data (via the console). I am confused on why my published property isn't updating my view with the fetched data.
Here is my view model:
class ProductViewModel: ObservableObject {
var didChange = PassthroughSubject<ProductViewModel, Never>()
#Published var mensProducts = [StripeProduct]()
init() {
}
func getMenItems() {
// hit the URL and
guard let url = URL(string: "http://127.0.0.1:3000/bags") else {
return
}
URLSession.shared.dataTask(with: url) { (data, response, err) in
// return the data asynchronously so that the call doesn't have to complete before loading the UI
DispatchQueue.main.async {
print("Decode data")
self.mensProducts = try! JSONDecoder().decode([StripeProduct].self, from: data!)
}
}
.resume()
}
}
Here is my view:
struct MenProducts: View {
#ObservedObject var productVM = ProductViewModel()
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack {
ForEach(self.productVM.mensProducts) { item in
ProductView(productID: item.productID, photo: "menMerch", price: item.price, name: item.productName, height: geometry.size.height/2, width: geometry.size.width)
}
}
}
}
.onAppear(perform: self.productVM.getMenItems)
}
}
First, I advise you to move your getMenItems() call to the init() method of ProductViewModel.
Then, you can remove the .onAppear(perform: self.productVM.getMenItems) in your MenProducts view and mark the method private in the ProductViewModel as no outside class/struct will be calling it.
I would also recommend you to not explicitly call the background queue with DispatchQueue.main.async as URLSession data task operations are asynchronous already.
You can read more about JSON decoding to Models in this great article: https://www.avanderlee.com/swift/json-parsing-decoding/