SwiftUI: View does not update after image changed asynchronous - swift

As mentioned in the headline, I try to load images to a custom object
I’ve got the custom object “User” that contains the property “imageLink” that stores the location within the Firebase Storage.
First I load the users frome the Firestore db and then I try to load the images for these users asynchronous from the Firebase Storage and show them on the View. As long as the image has not been loaded, a placeholder shall be shown.
I tried several implementations and I always can see in the debugger that I am able to download the images (I saw the actual image and I saw the size of some 100kb), but the loaded images don’t show on the view, I still see the placeholder, it seems that the view does not update after they loaded completely.
From my perspective, the most promising solution was:
FirebaseImage
import Combine
import FirebaseStorage
import UIKit
let placeholder = UIImage(systemName: "person")!
struct FirebaseImage : View {
init(id: String) {
self.imageLoader = Loader(id)
}
#ObservedObject private var imageLoader : Loader
var image: UIImage? {
imageLoader.data.flatMap(UIImage.init)
}
var body: some View {
Image(uiImage: image ?? placeholder)
}
}
Loader
import SwiftUI
import Combine
import FirebaseStorage
final class Loader : ObservableObject {
let didChange = PassthroughSubject<Data?, Never>()
var data: Data? = nil {
didSet { didChange.send(data) }
}
init(_ id: String){
// the path to the image
let url = "profilepics/\(id)"
print("load image with id: \(id)")
let storage = Storage.storage()
let ref = storage.reference().child(url)
ref.getData(maxSize: 1 * 1024 * 1024) { data, error in
if let error = error {
print("\(error)")
}
DispatchQueue.main.async {
self.data = data
}
}
}
}
User
import Foundation
import Firebase
import CoreLocation
import SwiftUI
struct User: Codable, Identifiable, Hashable {
var id: String?
var name: String
var imageLink: String
var imagedata: Data = .init(count: 0)
init(name: String, imageLink: String, lang: Double) {
self.id = id
self.name = name
self.imageLink = imageLink
}
init?(document: QueryDocumentSnapshot) {
let data = document.data()
guard let name = data["name"] as? String else {
return nil
}
guard let imageLink = data["imageLink"] as? String else {
return nil
}
id = document.documentID
self.name = name
self.imageLink = imageLink
}
}
extension User {
var image: Image {
Image(uiImage: UIImage())
}
}
extension User: DatabaseRepresentation {
var representation: [String : Any] {
var rep = ["name": name, "imageLink": imageLink] as [String : Any]
if let id = id {
rep["id"] = id
}
return rep
}
}
extension User: Comparable {
static func == (lhs: User, rhs: User) -> Bool {
return lhs.id == rhs.id
}
static func < (lhs: User, rhs: User) -> Bool {
return lhs.name < rhs.name
}
}
UserViewModel
import Foundation
import FirebaseFirestore
import Firebase
class UsersViewModel: ObservableObject {
let db = Firestore.firestore()
let storage = Storage.storage()
#Published var users = [User]()
#Published var showNewUserName: Bool = UserDefaults.standard.bool(forKey: "showNewUserName"){
didSet {
UserDefaults.standard.set(self.showNewUserName, forKey: "showNewUserName")
NotificationCenter.default.post(name: NSNotification.Name("showNewUserNameChange"), object: nil)
}
}
#Published var showLogin: Bool = UserDefaults.standard.bool(forKey: "showLogin"){
didSet {
UserDefaults.standard.set(self.showLogin, forKey: "showLogin")
NotificationCenter.default.post(name: NSNotification.Name("showLoginChange"), object: nil)
}
}
#Published var isLoggedIn: Bool = UserDefaults.standard.bool(forKey: "isLoggedIn"){
didSet {
UserDefaults.standard.set(self.isLoggedIn, forKey: "isLoggedIn")
NotificationCenter.default.post(name: NSNotification.Name("isLoggedInChange"), object: nil)
}
}
func addNewUserFromData(_ name: String, _ imageLing: String, _ id: String) {
do {
let uid = Auth.auth().currentUser?.uid
let newUser = User(name: name, imageLink: imageLing, lang: 0, long: 0, id: uid)
try db.collection("users").document(newUser.id!).setData(newUser.representation) { _ in
self.showNewUserName = false
self.showLogin = false
self.isLoggedIn = true
}
} catch let error {
print("Error writing city to Firestore: \(error)")
}
}
func fetchData() {
db.collection("users").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.users = documents.map { queryDocumentSnapshot -> User in
let data = queryDocumentSnapshot.data()
let id = data["id"] as? String ?? ""
let name = data["name"] as? String ?? ""
let imageLink = data["imageLink"] as? String ?? ""
let location = data["location"] as? GeoPoint
let lang = location?.latitude ?? 0
let long = location?.longitude ?? 0
Return User(name: name, imageLink: imageLink, lang: lang, long: long, id: id)
}
}
}
}
UsersCollectionView
import SwiftUI
struct UsersCollectionView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#EnvironmentObject var usersViewModel: UsersViewModel
let itemWidth: CGFloat = (screenWidth-30)/4.2
let itemHeight: CGFloat = (screenWidth-30)/4.2
var fixedLayout: [GridItem] {
[
.init(.fixed((screenWidth-30)/4.2)),
.init(.fixed((screenWidth-30)/4.2)),
.init(.fixed((screenWidth-30)/4.2)),
.init(.fixed((screenWidth-30)/4.2))
]
}
func debugUserValues() {
for user in usersViewModel.users {
print("ID: \(user.id), Name: \(user.name), ImageLink: \(user.imageLink)")
}
}
var body: some View {
VStack() {
ScrollView(showsIndicators: false) {
LazyVGrid(columns: fixedLayout, spacing: 15) {
ForEach(usersViewModel.users, id: \.self) { user in
VStack() {
FirebaseImage(id: user.imageLink)
HStack(alignment: .center) {
Text(user.name)
.font(.system(size: 16))
.fontWeight(.bold)
.foregroundColor(Color.black)
.lineLimit(1)
}
}
}
}
.padding(.top, 20)
Rectangle()
.fill(Color .clear)
.frame(height: 100)
}
}
.navigationTitle("Find Others")
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
HStack {
Image(systemName: "xmark")
.foregroundColor(.black)
.padding()
.offset(x: -15)
}
})
}
}

You're using an old syntax from BindableObject by using didChange -- that system changed before SwiftUI 1.0 was out of beta.
A much easier approach would be to use #Published, which your view will listen to automatically:
final class Loader : ObservableObject {
#Published var data : Data?
init(_ id: String){
// the path to the image
let url = "profilepics/\(id)"
print("load image with id: \(id)")
let storage = Storage.storage()
let ref = storage.reference().child(url)
ref.getData(maxSize: 1 * 1024 * 1024) { data, error in
if let error = error {
print("\(error)")
}
DispatchQueue.main.async {
self.data = data
}
}
}
}

Related

How to change the value of a var with a TextField SwiftUI

I was trying to make a weather api call, the api call needs to have a location. The location that I pass is a variable, but now I want to change the location value based on a TextField's input.
I made the apiKey shorter just for safety measures. There's more code, but it's not relevant.
I just need to know how to change the city variable that is on the WeatherClass using the TextField that is in the cityTextField View.
Thanks.
class WeatherClass: ObservableObject {
#Published var weatherAddress: String = ""
#Published var weatherDays: [WeatherDays] = []
var city: String = ""
func fetch() {
let location = city
let apiKey = "AP8LUYMSTHZ"
let url = URL(string: "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/\(location)?key=\(apiKey)")!
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
if let weather = try? JSONDecoder().decode(WeatherData.self, from: data) {
DispatchQueue.main.async {
self.weatherAddress = weather.resolvedAddress
self.weatherDays = weather.days
}
} else {
print("City?")
}
}.resume()
}//----------------------------------- End of fetch()
}
struct WeatherData: Decodable {
let resolvedAddress: String
let days: [WeatherDays]
}
struct WeatherDays: Hashable, Decodable {
let datetime: String
let tempmax: Double
let tempmin: Double
let description: String
}
struct cityTextField: View {
#State var city: String = ""
var body: some View {
TextField("Search city", text: $city).frame(height:30).multilineTextAlignment(.center).background().cornerRadius(25).padding(.horizontal)
}
}
I already watched a lot of tutorials for similar things buts none of them really helped me.
Try this approach using minor modifications to
func fetch(_ city: String){...} to fetch the weather for the city in your
TextField using .onSubmit{...}
struct ContentView: View {
#StateObject var weatherModel = WeatherClass()
var body: some View {
VStack {
cityTextField(weatherModel: weatherModel)
}
}
}
struct cityTextField: View {
#ObservedObject var weatherModel: WeatherClass // <-- here
#State var city: String = ""
var body: some View {
TextField("Search city", text: $city)
.frame(height:30)
.multilineTextAlignment(.center)
.background()
.cornerRadius(25)
.padding(.horizontal)
.onSubmit {
weatherModel.fetch(city) // <-- here
}
}
}
class WeatherClass: ObservableObject {
#Published var weatherAddress: String = ""
#Published var weatherDays: [WeatherDays] = []
func fetch(_ city: String) { // <-- here
let apiKey = "AP8LUYMSTHZ"
// -- here
let url = URL(string: "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/\(city)?key=\(apiKey)")!
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
if let weather = try? JSONDecoder().decode(WeatherData.self, from: data) {
DispatchQueue.main.async {
self.weatherAddress = weather.resolvedAddress
self.weatherDays = weather.days
}
} else {
print("City?")
}
}.resume()
}
}
Alternatively, as suggested by synapticloop, you could use this approach:
struct cityTextField: View {
#ObservedObject var weatherModel: WeatherClass // <-- here
var body: some View {
TextField("Search city", text: $weatherModel.city) // <-- here
.frame(height:30)
.multilineTextAlignment(.center)
.background()
.cornerRadius(25)
.padding(.horizontal)
.onSubmit {
weatherModel.fetch() // <-- here
}
}
}
class WeatherClass: ObservableObject {
#Published var weatherAddress: String = ""
#Published var weatherDays: [WeatherDays] = []
#Published var city: String = "" // <-- here
func fetch() {
let apiKey = "AP8LUYMSTHZ"
// -- here
let url = URL(string: "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/\(city)?key=\(apiKey)")!
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
if let weather = try? JSONDecoder().decode(WeatherData.self, from: data) {
DispatchQueue.main.async {
self.weatherAddress = weather.resolvedAddress
self.weatherDays = weather.days
}
} else {
print("City?")
}
}.resume()
}
}

SwiftUI, Core Data, and PhotoKit: Views not updating when state changes (state management hell)

I'm learning Swift/SwiftUI by building a photo organizer app. It displays a user's photo library in a grid like the built-in photos app, and there's a detail view where you can do things like favorite a photo or add it to the trash.
My app loads all the data and displays it fine, but the UI doesn't update when things change. I've debugged enough to confirm that my edits are applied to the underlying PHAssets and Core Data assets. It feels like the problem is that my views aren't re-rendering.
I used Dave DeLong's approach to create an abstraction layer that separates Core Data from SwiftUI. I have a singleton environment object called DataStore that handles all interaction with Core Data and the PHPhotoLibrary. When the app runs, the DataStore is created. It makes an AssetFetcher that grabs all assets from the photo library (and implements PHPhotoLibraryChangeObserver). DataStore iterates over the assets to create an index in Core Data. My views' viewmodels query core data for the index items and display them using the #Query property wrapper from Dave's post.
App.swift
#main
struct LbPhotos2App: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.dataStore, DataStore.shared)
}
}
}
PhotoGridView.swift (this is what contentview presents)
struct PhotoGridView: View {
#Environment(\.dataStore) private var dataStore : DataStore
#Query(.all) var indexAssets: QueryResults<IndexAsset>
#StateObject var vm = PhotoGridViewModel()
func updateVm() {
vm.createIndex(indexAssets)
}
var body: some View {
GeometryReader { geo in
VStack {
HStack {
Text("\(indexAssets.count) assets")
Spacer()
TrashView()
}.padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
ScrollView {
ForEach(vm.sortedKeys, id: \.self) { key in
let indexAssets = vm.index[key]
let date = indexAssets?.first?.creationDate
GridSectionView(titleDate:date, indexAssets:indexAssets!, geoSize: geo.size)
}
}.onTapGesture {
updateVm()
}
}.onAppear {
updateVm()
}
.navigationDestination(for: IndexAsset.self) { indexAsset in
AssetDetailView(indexAsset: indexAsset)
}
}
}
}
PhotoGridViewModel.swift
class PhotoGridViewModel: ObservableObject {
#Published var index: [String:[IndexAsset]] = [:]
var indexAssets: QueryResults<IndexAsset>?
func createIndex() {
guard let assets = self.indexAssets else {return}
self.createIndex(assets)
}
func createIndex(_ queryResults: QueryResults<IndexAsset>) {
indexAssets = queryResults
if queryResults.count > 0 {
var lastDate = Date.distantFuture
for i in 0..<queryResults.count {
let item = queryResults[i]
let isSameDay = isSameDay(firstDate: lastDate, secondDate: item.creationDate!)
if isSameDay {
self.index[item.creationDateKey!]?.append(item)
} else {
self.index[item.creationDateKey!] = [item]
}
lastDate = item.creationDate!
}
}
self.objectWillChange.send()
}
var sortedKeys: [String] {
return index.keys.sorted().reversed()
}
private func isSameDay(firstDate:Date, secondDate:Date) -> Bool {
return Calendar.current.isDate(
firstDate,
equalTo: secondDate,
toGranularity: .day
)
}
}
Here's where I actually display the asset in GridSectionView.swift
LazyVGrid(columns: gridLayout, spacing: 2) {
let size = geoSize.width/4
ForEach(indexAssets, id:\.self) { indexAsset in
NavigationLink(
value: indexAsset,
label: {
AssetCellView(indexAsset: indexAsset, geoSize:geoSize)
}
).frame(width: size, height: size)
.buttonStyle(.borderless)
}
}
AssetCellView.swift
struct AssetCellView: View {
#StateObject var vm : AssetCellViewModel
var indexAsset : IndexAsset
var geoSize : CGSize
init(indexAsset: IndexAsset, geoSize: CGSize) {
self.indexAsset = indexAsset
self.geoSize = geoSize
_vm = StateObject(wrappedValue: AssetCellViewModel(indexAsset: indexAsset, geoSize: geoSize))
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
if (vm.indexAsset != nil && vm.image != nil) {
vm.image?
.resizable()
.aspectRatio(contentMode: .fit)
.border(.blue, width: vm.indexAsset!.isSelected ? 4 : 0)
}
if (vm.indexAsset != nil && vm.indexAsset!.isFavorite) {
Image(systemName:"heart.fill")
.resizable()
.frame(width: 20, height: 20)
.foregroundStyle(.ultraThickMaterial)
.shadow(color: .black, radius: 12)
.offset(x:-8, y:-8)
}
}
}
}
AssetCellViewModel.swift
class AssetCellViewModel: ObservableObject{
#Environment(\.dataStore) private var dataStore
#Published var image : Image?
var indexAsset : IndexAsset?
var geoSize : CGSize
init(indexAsset: IndexAsset? = nil, geoSize:CGSize) {
self.indexAsset = indexAsset
self.geoSize = geoSize
self.requestImage(targetSize: CGSize(width: geoSize.width/4, height: geoSize.width/4))
}
func setIndexAsset(_ indexAsset:IndexAsset, targetSize: CGSize) {
self.indexAsset = indexAsset
self.requestImage(targetSize: targetSize)
}
func requestImage(targetSize: CGSize? = nil) {
if (self.indexAsset != nil) {
dataStore.fetchImageForLocalIdentifier(
id: indexAsset!.localIdentifier!,
targetSize: targetSize,
completionHandler: { image in
withAnimation(Animation.easeInOut (duration:0.15)) {
self.image = image
}
}
)
}
}
}
some of DataStore.swift
public class DataStore : ObservableObject {
static let shared = DataStore()
let persistenceController = PersistenceController.shared
#ObservedObject var assetFetcher = AssetFetcher()
let dateFormatter = DateFormatter()
var imageManager = PHCachingImageManager()
let id = UUID().uuidString
init() {
print("🔶 init dataStore: \(self.id)")
dateFormatter.dateFormat = "yyyy-MM-dd"
assetFetcher.iterateResults{ asset in
do {
try self.registerAsset(
localIdentifier: asset.localIdentifier,
creationDate: asset.creationDate!,
isFavorite: asset.isFavorite
)
} catch {
print("Error registering asset \(asset)")
}
}
}
func registerAsset(localIdentifier:String, creationDate:Date, isFavorite:Bool) throws {
let alreadyExists = indexAssetEntityWithLocalIdentifier(localIdentifier)
if alreadyExists != nil {
// print("🔶 Asset already registered: \(localIdentifier)")
// print(alreadyExists![0])
return
}
let iae = IndexAssetEntity(context: self.viewContext)
iae.localIdentifier = localIdentifier
iae.creationDate = creationDate
iae.creationDateKey = dateFormatter.string(from: creationDate)
iae.isFavorite = isFavorite
iae.isSelected = false
iae.isTrashed = false
self.viewContext.insert(iae)
try self.viewContext.save()
print("🔶 Registered asset: \(localIdentifier)")
}
And AssetFetcher.swift
class AssetFetcher:NSObject, PHPhotoLibraryChangeObserver, ObservableObject {
#Published var fetchResults : PHFetchResult<PHAsset>? = nil
let id = UUID().uuidString
override init() {
super.init()
print("🔶 init assetfetcher: \(id)")
self.startFetchingAllPhotos()
}
deinit {
PHPhotoLibrary.shared().unregisterChangeObserver(self)
}
func startFetchingAllPhotos() {
getPermissionIfNecessary(completionHandler: {result in
print(result)
})
let fetchOptions = PHFetchOptions()
var datecomponents = DateComponents()
datecomponents.month = -3
//TODO: request assets dynamically
let threeMonthsAgo = Calendar.current.date(byAdding: datecomponents, to:Date())
fetchOptions.predicate = NSPredicate(format: "creationDate > %# AND creationDate < %#", threeMonthsAgo! as NSDate, Date() as NSDate)
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
fetchOptions.wantsIncrementalChangeDetails = true
// fetchOptions.fetchLimit = 1000
let results = PHAsset.fetchAssets(with: .image, options: fetchOptions)
PHPhotoLibrary.shared().register(self)
print("🔶 \(PHPhotoLibrary.shared())")
self.fetchResults = results
}
func iterateResults(_ callback:(_ asset: PHAsset) -> Void) {
print("iterateResults")
guard let unwrapped = self.fetchResults else {
return
}
for i in 0..<unwrapped.count {
callback(unwrapped.object(at: i))
}
}
func photoLibraryDidChange(_ changeInstance: PHChange) {
print("🔶 photoLibraryDidChange")
DispatchQueue.main.async {
if let changeResults = changeInstance.changeDetails(for: self.fetchResults!) {
self.fetchResults = changeResults.fetchResultAfterChanges
// self.dataStore.photoLibraryDidChange(changeInstance)
// self.updateImages()
self.objectWillChange.send()
}
}
}
}

Displaying State of an Async Api call in SwiftUI

This question builds on my previous question. Basically Im making an async call to the Google Books Api when a certain button is pressed. While I got the call working when its a method of the View however I want to overlay an activity indicator while it's loading. Hence I tried making an ObservableObject to make the call instead but Im not sure how to do it.
Here's what I have so far:
class GoogleBooksApi: ObservableObject {
enum LoadingState<Value> {
case loading(Double)
case loaded(Value)
}
#Published var state: LoadingState<GoogleBook> = .loading(0.0)
enum URLError : Error {
case badURL
}
func fetchBook(id identifier: String) async throws {
var components = URLComponents(string: "https://www.googleapis.com/books/v1/volumes")
components?.queryItems = [URLQueryItem(name: "q", value: "isbn=\(identifier)")]
guard let url = components?.url else { throw URLError.badURL }
self.state = .loading(0.25)
let (data, _) = try await URLSession.shared.data(from: url)
self.state = .loading(0.75)
self.state = .loaded(try JSONDecoder().decode(GoogleBook.self, from: data))
}
}
struct ContentView: View {
#State var name: String = ""
#State var author: String = ""
#State var total: String = ""
#State var code = "ISBN"
#ObservedObject var api: GoogleBooksApi
var body: some View {
VStack {
Text("Name: \(name)")
Text("Author: \(author)")
Text("total: \(total)")
Button(action: {
code = "978-0441013593"
Task {
do {
try await api.fetchBook(id: code)
let fetchedBooks = api.state
let book = fetchedBooks.items[0].volumeInfo
name = book.title
author = book.authors?[0] ?? ""
total = String(book.pageCount!)
} catch {
print(error)
}
}
}, label: {
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.blue)
})
}
}
}
// MARK: - GoogleBook
struct GoogleBook: Codable {
let kind: String
let totalItems: Int
let items: [Item]
}
// MARK: - Item
struct Item: Codable {
let id, etag: String
let selfLink: String
let volumeInfo: VolumeInfo
}
// MARK: - VolumeInfo
struct VolumeInfo: Codable {
let title: String
let authors: [String]?
let pageCount: Int?
let categories: [String]?
enum CodingKeys: String, CodingKey {
case title, authors
case pageCount, categories
}
}
and this is what works without the loading states:
struct ContentView: View {
#State var name: String = ""
#State var author: String = ""
#State var total: String = ""
#State var code = "ISBN"
enum URLError : Error {
case badURL
}
private func fetchBook(id identifier: String) async throws -> GoogleBook {
guard let encodedString = "https://www.googleapis.com/books/v1/volumes?q={\(identifier)}"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: encodedString) else { throw URLError.badURL}
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(GoogleBook.self, from: data)
}
var body: some View {
VStack {
Text("Name: \(name)")
Text("Author: \(author)")
Text("total: \(total)")
Button(action: {
code = "978-0441013593"
Task {
do {
let fetchedBooks = try await fetchBook(id: code)
let book = fetchedBooks.items[0].volumeInfo
name = book.title
author = book.authors?[0] ?? ""
total = String(book.pageCount!)
} catch {
print(error)
}
}
}, label: {
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.blue)
})
}
}
}
// MARK: - GoogleBook
struct GoogleBook: Codable {
let kind: String
let totalItems: Int
let items: [Item]
}
// MARK: - Item
struct Item: Codable {
let id, etag: String
let selfLink: String
let volumeInfo: VolumeInfo
}
// MARK: - VolumeInfo
struct VolumeInfo: Codable {
let title: String
let authors: [String]?
let pageCount: Int?
let categories: [String]?
enum CodingKeys: String, CodingKey {
case title, authors
case pageCount, categories
}
}
I would go a step further and add idle and failed states.
Then instead of throwing an error change the state to failed and pass the error description. I removed the Double value from the loading state to just show a spinning ProgressView
#MainActor
class GoogleBooksApi: ObservableObject {
enum LoadingState {
case idle
case loading
case loaded(GoogleBook)
case failed(Error)
}
#Published var state: LoadingState = .idle
func fetchBook(id identifier: String) async {
var components = URLComponents(string: "https://www.googleapis.com/books/v1/volumes")
components?.queryItems = [URLQueryItem(name: "q", value: "isbn=\(identifier)")]
guard let url = components?.url else { state = .failed(URLError(.badURL)); return }
self.state = .loading
do {
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(GoogleBook.self, from: data)
self.state = .loaded(response)
} catch {
state = .failed(error)
}
}
}
In the view you have to switch on the state and show different views.
And – very important – you have to declare the observable object as #StateObject. This is a very simple implementation
struct ContentView: View {
#State var code = "ISBN"
#StateObject var api = GoogleBooksApi()
var body: some View {
VStack {
switch api.state {
case .idle: EmptyView()
case .loading: ProgressView()
case .loaded(let books):
if let info = books.items.first?.volumeInfo {
Text("Name: \(info.title)")
Text("Author: \(info.authors?.joined(separator: ", ") ?? "")")
Text("total: \(books.totalItems)")
}
case .failed(let error):
if error is DecodingError {
Text(error.description)
} else {
Text(error.localizedDescription)
}
}
Button(action: {
code = "978-0441013593"
Task {
await api.fetchBook(id: code)
}
}, label: {
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.blue)
})
}
}
}
It seems like you're not initializing the GoogleBooksApi.
#ObservedObject var api: GoogleBooksApi
neither any init where it can be modified.
Other than that - I'd suggest using #StateObject (provided you deployment target is minimum iOS 14.0). Using ObservableObject might lead to multiple initializations of the GoogleBooksApi (whereas you need only once)
You should use #StateObject for any observable properties that you
initialize in the view that uses it. If the ObservableObject instance
is created externally and passed to the view that uses it mark your
property with #ObservedObject.

Can't add data fetched from Firebase to new Array

I'm am fetching data from firebase from 3 different collections. Once I have the data fetched I would like to append the data from the 3 functions to 1 new array so I have all the data stored one place. But once I append it comes out empty like the fetch functions didn't work. I've tested and debugged and the data is there but I can't seem to add the fetched data to a new Array.
Model
import Foundation
import SwiftUI
struct GameModel: Identifiable {
var id = UUID()
var title: String
var descriptionMenu: String
var imageNameMenu: String
}
Fetch Data Class
import Foundation
import SwiftUI
import Firebase
class SearchController: ObservableObject {
#Published var allGames = [GameModel]()
#Published var cardsMenu = [GameModel]()
#Published var diceMenu = [GameModel]()
#Published var miscellaneuosMenu = [GameModel]()
private var db = Firestore.firestore()
func fetchCardGamesData() {...}
func fetchDiceGamesData() {...}
func fetchMiscGamesData() {...}
func combineGames() {
for i in cardsMenu {
allGames.append(i)
}
for n in diceMenu {
allGames.append(n)
}
for x in miscellaneuosMenu {
allGames.append(x)
}
}
}
Fetch data Functions
func fetchCardGamesData() {
db.collection("cardsMenu").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.cardsMenu = documents.map { (queryDocumentSnapshot) -> GameModel in
let data = queryDocumentSnapshot.data()
let title = data["title"] as? String ?? ""
let descriptionMenuRecieved = data["descriptionMenu"] as? String ?? ""
let descriptionMenu = descriptionMenuRecieved.replacingOccurrences(of: "\\n", with: "\n")
let imageNameMenu = data["imageNameMenu"] as? String ?? ""
let allGameInfo = GameModel(title: title, descriptionMenu: descriptionMenu, imageNameMenu: imageNameMenu)
return allGameInfo
}
}
}
func fetchDiceGamesData() {
db.collection("diceMenu").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.diceMenu = documents.map { (queryDocumentSnapshot) -> GameModel in
let data = queryDocumentSnapshot.data()
let title = data["title"] as? String ?? ""
let descriptionMenuRecieved = data["descriptionMenu"] as? String ?? ""
let descriptionMenu = descriptionMenuRecieved.replacingOccurrences(of: "\\n", with: "\n")
let imageNameMenu = data["imageNameMenu"] as? String ?? ""
let allGameInfo = GameModel(title: title, descriptionMenu: descriptionMenu, imageNameMenu: imageNameMenu)
return allGameInfo
}
}
}
func fetchMiscGamesData() {
db.collection("miscellaneuosMenu").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.miscellaneuosMenu = documents.map { (queryDocumentSnapshot) -> GameModel in
let data = queryDocumentSnapshot.data()
let title = data["title"] as? String ?? ""
let descriptionMenuRecieved = data["descriptionMenu"] as? String ?? ""
let descriptionMenu = descriptionMenuRecieved.replacingOccurrences(of: "\\n", with: "\n")
let imageNameMenu = data["imageNameMenu"] as? String ?? ""
let miscellaneousGames = GameModel(title: title, descriptionMenu: descriptionMenu, imageNameMenu: imageNameMenu)
return miscellaneousGames
}
}
}
View
import SwiftUI
import Foundation
struct SearchView: View {
#ObservedObject var allGames = SearchController()
var body: some View {
NavigationView{
ZStack(alignment: .top) {
GeometryReader{_ in
//Text("Home")
}
.background(Color("Color").edgesIgnoringSafeArea(.all))
SearchBar(data: self.$allGames.allGames)
.padding(.top)
}
.navigationBarTitle("Search")
.padding(.top, -20)
.onAppear(){
self.allGames.fetchCardGamesData()
self.allGames.fetchDiceGamesData()
self.allGames.fetchMiscGamesData()
self.allGames.combineGames()
}
}
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(PlainListStyle())
}
}
SearchBar
struct SearchBar: View {
#State var txt = ""
#Binding var data: [GameModel]
var body: some View {
VStack(spacing: 0){
HStack{
TextField("Search", text: self.$txt)
if self.txt != "" {
Button(action: {
self.txt = ""
}, label: {
Image(systemName: "xmark.circle.fill")
})
.foregroundColor(.gray)
}
}.padding()
if self.txt != ""{
if self.data.filter({$0.title.lowercased().contains(self.txt.lowercased())}).count == 0 {
Text("No Results Found")
.foregroundColor(Color.black.opacity(0.5))
.padding()
}
else {
List(self.data.filter{$0.title.lowercased().contains(self.txt.lowercased())}){
i in
NavigationLink(destination: i.view.navigationBarTitleDisplayMode(.inline).onAppear(){
// Clear searchfield when return
txt = ""
}) {
Text(i.title)
}
}
.frame(height: UIScreen.main.bounds.height / 3)
.padding(.trailing)
}
}
}
.background(Color.white)
.cornerRadius(10)
.padding()
}
}
Hope you guys can help me.
All of your fetchCardGamesData, fetchDiceGamesData, and fetchMiscGamesData functions have asynchronous requests in them. That means that when you call combineGames, none of them have completed, so you're just appending empty arrays.
In your situation, the easiest would probably be to make allGames a computed property. Then, whenever one of the other #Published properties updates after their fetch methods, the computed property will be re-computed and represented in your SearchBar:
class SearchController: ObservableObject {
#Published var cardsMenu = [GameModel]()
#Published var diceMenu = [GameModel]()
#Published var miscellaneuosMenu = [GameModel]()
var allGames: [GameModel] {
cardsMenu + diceMenu + miscellaneuosMenu
}
}
Note there's no longer a combineGames function, so you won't call that anymore.

SwiftUI - Dynamically add #State for UI Toggle

I am currently getting a list of sites from a Firebase Firestore and then returning them to a list in SwiftUI. Each list item has a label and Toggle. The list of sites is dynamic so could be anywhere from 1-30+. How can I create an #State or similar bindable to observe each toggle's state.
I am currently rendering to UI with the following
#State private var SiteA = false
Form {
Section (header: Text("Select Sites")) {
ForEach(siteData.sites) { site in
HStack {
Toggle(isOn: self.$SiteA) {
Text(site.name)
Spacer()
}
}
}
}
}
Sites are retrieved using a Bindable object
import SwiftUI
import Combine
import Firebase
import FirebaseFirestore
struct Message: Identifiable {
var title: String
var messageBody: String
var sentBy: String
var targetSite: String
var expired: Bool
var timeStamp: Timestamp
var emergency: Bool
var id: String
}
struct Site: Identifiable {
var id: String
var name: String
}
class FirestoreMessages : ObservableObject {
var db = Firestore.firestore()
var didChange = PassthroughSubject<FirestoreMessages, Never>()
#Published var messages: [Message] = [] {
didSet{ didChange.send(self) }
}
#Published var sites: [Site] = [] {
didSet { didChange.send(self) }
}
func listen() {
db.collection("messages")
.whereField("expired", isEqualTo: false)
.addSnapshotListener { (snap, error) in
if error != nil {
print("Firebase Snapshot Error: \(error?.localizedDescription ?? "")")
} else {
self.messages.removeAll()
for doc in snap!.documents {
let title = doc["title"] as! String
let messageBody = doc["body"] as! String
let sentBy = doc["sentBy"] as! String
let targetSite = doc["targetSite"] as! String
let expired = doc["expired"] as! Bool
let timeStamp = doc["timeStamp"] as! Timestamp
let emergency = doc["emergency"] as! Bool
let id = doc.documentID
let message = Message(
title: title,
messageBody: messageBody,
sentBy: sentBy,
targetSite: targetSite,
expired: expired,
timeStamp: timeStamp,
emergency: emergency,
id: id)
self.messages.append(message)
}
}
}
}
func getSites() {
db.collection("sites")
.order(by: "name", descending: false)
.getDocuments() { (querySnapshot, err) in
if let err = err {
print("Error getting docs: \(err)")
} else {
self.sites.removeAll()
for document in querySnapshot!.documents {
let doc = document.data()
let id = document.documentID
let name = doc["name"] as! String
let site = Site(id: id, name: name)
self.sites.append(site)
}
}
}
}
}
How can I create an #State unique to each list item to monitor their states individually?
The answer to your problem is composition. Move the HStack and enclosed Toggle to a SiteRow view where each row has its own State.
struct SiteRow: View {
#State private var state: Bool = false
private let site: Site
init(_ site: Site) {
self.site = site
self.state = site.isOn
}
var body: some View {
HStack {
Toggle(isOn: self.$state) {
Text(site.name)
Spacer()
}
}
}
}
Then...
ForEach(siteData.sites) { site in SiteRow(site) }