SwiftUI: Deleting an item from a ForEach results in Index Out of Range - swift

I've built a horizontal scrolling ForEach UI within my ContentView component that displays an array of custom objects (struct form). When I try to delete an item, I get an "Fatal Error: Index out of range" error.
The issue is when I delete an item, the actual array itself updates, but specific AssetView (component below) component is not updating and so it ultimately iterates to an index that no longer exists. Any idea what the issue can be? Below is my code:
ContentView
struct ContentView: View {
#ObservedObject var assetStore: AssetStore
var body: some View {
ScrollView (.horizontal) {
ForEach(assetStore.assets.indices, id: \.self) { index in
AssetView(
asset: $assetStore.assets[index],
assetStore: assetStore,
smallSize: geo.size.height <= 667
)
.padding(.bottom)
}
}
}
}
AssetView
struct AssetView: View {
#Binding var asset: Asset
#ObservedObject var assetStore: AssetStore
var smallSize: Bool
#State var editAsset: Bool = false
var body: some View {
VStack(alignment: .center, spacing: smallSize ? -10 : 0) {
HStack {
TagText(tagName: asset.name)
.onTapGesture {
editAsset.toggle()
}
Spacer()
DisplayCurrentValues(
forCurrentValueText: asset.getCurrentValueString,
forCurrentValueLabel: "Current Value"
)
.onTapGesture {
editAsset.toggle()
}
}
DisplayStepper(asset: $asset, title: "YoY Growth", type: .growth)
DisplayStepper(asset: $asset, title: "Recurring Deposit", type: .recurring)
}
.sheet(isPresented: $editAsset, content: {
EditAsset(assetStore: assetStore, currentValue: String(asset.currentValue), name: asset.name, asset: $asset)
})
}
}
AssetStore
This is where I read/write all of the asset objects to my App's Documents folder.
class AssetStore: ObservableObject {
let assetsJSONURL = URL(fileURLWithPath: "Assets",
relativeTo: FileManager.documentsDirectoryURL).appendingPathExtension("json")
#Published var assets: [Asset] = [] {
didSet {
saveJSONAssets()
}
}
init() {
print(assetsJSONURL)
loadJSONAssets()
}
private func loadJSONAssets() {
guard FileManager.default.fileExists(atPath: assetsJSONURL.path) else {
return
}
let decoder = JSONDecoder()
do {
let assetsData = try Data(contentsOf: assetsJSONURL)
assets = try decoder.decode([Asset].self, from: assetsData)
} catch let error {
print(error)
}
}
private func saveJSONAssets() {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
let assetsData = try encoder.encode(assets)
try assetsData.write(to: assetsJSONURL, options: .atomicWrite)
} catch let error {
print(error)
}
}
public func deleteAsset(atIndex id: Asset) {
let index = assets.firstIndex(where: { $0.id == id.id})
assets.remove(at: index!)
}
}
Asset Object
struct Asset: Identifiable, Codable, Hashable {
// currently set for 10 years
let id = UUID()
enum Frequency: String, Codable, CaseIterable {
case month = "Month"
case year = "Year"
case none = "None"
}
let years: Int
var name: String
var currentValue: Int
var growth: Int
var recurringDeposit: Int
var recurringFrequency: Frequency
enum CodingKeys: CodingKey {
case id
case years
case name
case currentValue
case growth
case recurringDeposit
case recurringFrequency
}
init(
name: String = "AssetName",
currentValue: Int = 1000,
growth: Int = 10,
years: Int = 10,
recurringDeposit: Int = 100,
recurringFrequency: Frequency = .month
) {
self.name = name
self.currentValue = currentValue
self.growth = growth
self.recurringDeposit = recurringDeposit
self.recurringFrequency = recurringFrequency
self.years = years
}
}

I think it's because of your ForEach relying on the indices, rather than the Assets themselves. But, if you get rid of the indices, you'll have to write a new binding for the Asset. Here's what I think it could look like:
ForEach(assetStore.assets, id: \.id) { asset in
AssetView(
asset: assetStore.bindingForId(id: asset.id),
assetStore: assetStore,
smallSize: geo.size.height <= 667
)
.padding(.bottom)
}
And then in your AssetStore:
func bindingForId(id: UUID) -> Binding<Asset> {
Binding<Asset> { () -> Asset in
self.assets.first(where: { $0.id == id }) ?? Asset()
} set: { (newValue) in
self.assets = self.assets.map { asset in
if asset.id == id {
return newValue
} else {
return asset
}
}
}
}

Related

Problem trying to add Search bar for simple SwiftUI app retrieving web data

I have a small project which is an extension of a Swift UI exercise making a web call to Github from Greg Lim's book Beginning Swift UI:
https://github.com/ethamoos/GitProbe
I’ve been using this to practise basic skills and to try and add other features that could be useful in a realworld app.
My main change from the initial exercise was to add the option to choose which user to lookup (this was previously hardcoded) and allow the user to enter this. Because this can return a lot of data I would now like to make the resulting List .searchable so that the user can filter the results.
I’ve been following this tutorial here:
https://www.hackingwithswift.com/quick-start/swiftui/how-to-add-a-search-bar-to-filter-your-data
but I’ve realised that this is based upon the data being returned being Strings, and therefore the search is a string.
I am returning JSON decoded into a list of User data objects so a straight search does not work. I am assuming that I can adjust this to match a string search against my custom objects but I'm not sure how to do this.
To give you an idea of what I mean here is the code:
import SwiftUI
import URLImage
struct Result: Codable {
let totalCount: Int
let incompleteResults: Bool
let items: [User]
enum CodingKeys: String, CodingKey {
case totalCount = "total_count"
case incompleteResults = "incomplete_results"
case items
}
}
struct User: Codable, Hashable {
let login: String
let id: Int
let nodeID: String
let avatarURL: String
let gravatarID: String
enum CodingKeys: String, CodingKey {
case login, id
case nodeID = "node_id"
case avatarURL = "avatar_url"
case gravatarID = "gravatar_id"
}
}
class FetchUsers: ObservableObject {
#Published var users = [User]()
func search(for user:String) {
var urlComponents = URLComponents(string: "https://api.github.com/search/users")!
urlComponents.queryItems = [URLQueryItem(name: "q", value: user)]
guard let url = urlComponents.url else {
return
}
URLSession.shared.dataTask(with: url) {(data, response, error) in
do {
if let data = data {
let decodedData = try JSONDecoder().decode(Result.self, from: data)
DispatchQueue.main.async {
self.users = decodedData.items
}
} else {
print("No data")
}
} catch {
print("Error: \(error)")
}
}.resume()
}
}
struct ContentView: View {
#State var username: String = ""
var body: some View {
NavigationView {
Form {
Section {
Text("Enter user to search for")
TextField("Enter your username", text: $username).disableAutocorrection(true)
.autocapitalization(.none)
}
NavigationLink(destination: UserView(username: username)) {
Text("Show detail for \(username)")
}
}
}
}
}
struct UserView: View {
#State var username: String
#ObservedObject var fetchUsers = FetchUsers()
#State var searchText = ""
var body: some View {
List {
ForEach(fetchUsers.users, id:\.self) { user in
NavigationLink(user.login, destination: UserDetailView(user:user))
}
}.onAppear {
self.fetchUsers.search(for: username)
}
.searchable(text: $searchText)
.navigationTitle("Users")
}
/// With suggestion added
/// The search results
private var searchResults: [User] {
if searchText.isEmpty {
return fetchUsers.users // your entire list of users if no search input
} else {
return fetchUsers.search(for: searchText) // calls your search method passing your search text
}
}
}
struct UserDetailView: View {
var user: User
var body: some View {
Form {
Text(user.login).font(.headline)
Text("Git iD = \(user.id)")
URLImage(URL(string:user.avatarURL)!){ image in
image.resizable().frame(width: 50, height: 50)
}
}
}
}
Any help with this would be much appreciated.
Your UserListView is not properly constructed. I don't see why you would need a ScrollView with an empty text inside? I removed that.
So I removed searchText from the View to the FetchUsers class so we can delay the server requests thus avoiding unnecessary multiple calls. Please adjust it to your needs (check Apple's Debounce documentation. Everything should work as expected now.
import Combine
class FetchUsers: ObservableObject {
#Published var users = [User]()
#Published var searchText = ""
var subscription: Set<AnyCancellable> = []
init() {
$searchText
.debounce(for: .milliseconds(500), scheduler: RunLoop.main) // debounces the string publisher, delaying requests and avoiding unnecessary calls.
.removeDuplicates()
.map({ (string) -> String? in
if string.count < 1 {
self.users = [] // cleans the list results when empty search
return nil
}
return string
}) // prevents sending numerous requests and sends nil if the count of the characters is less than 1.
.compactMap{ $0 } // removes the nil values
.sink { (_) in
//
} receiveValue: { [self] text in
search(for: text)
}.store(in: &subscription)
}
func search(for user:String) {
var urlComponents = URLComponents(string: "https://api.github.com/search/users")!
urlComponents.queryItems = [URLQueryItem(name: "q", value: user.lowercased())]
guard let url = urlComponents.url else {
return
}
URLSession.shared.dataTask(with: url) {(data, response, error) in
guard error == nil else {
print("Error: \(error!.localizedDescription)")
return
}
guard let data = data else {
print("No data received")
return
}
do {
let decodedData = try JSONDecoder().decode(Result.self, from: data)
DispatchQueue.main.async {
self.users = decodedData.items
}
} catch {
print("Error: \(error)")
}
}.resume()
}
}
struct UserListView: View {
#State var username: String
#ObservedObject var fetchUsers = FetchUsers()
var body: some View {
NavigationView {
List {
ForEach(fetchUsers.users, id:\.self) { user in
NavigationLink(user.login, destination: UserDetailView(user:user))
}
}
.searchable(text: $fetchUsers.searchText) // we move the searchText to fetchUsers
.navigationTitle("Users")
}
}
}
I hope this helps! :)
In the end, I think I've figured this out - thanks to the suggestions from Andre.
I need to correctly filter my data and then return the remainder.
Here's the corrected (abridged) version:
import SwiftUI
import URLImage
struct Result: Codable {
let totalCount: Int
let incompleteResults: Bool
let items: [User]
enum CodingKeys: String, CodingKey {
case totalCount = "total_count"
case incompleteResults = "incomplete_results"
case items
}
}
struct User: Codable, Hashable {
let login: String
let id: Int
let nodeID: String
let avatarURL: String
let gravatarID: String
enum CodingKeys: String, CodingKey {
case login, id
case nodeID = "node_id"
case avatarURL = "avatar_url"
case gravatarID = "gravatar_id"
}
}
class FetchUsers: ObservableObject {
#Published var users = [User]()
func search(for user:String) {
var urlComponents = URLComponents(string: "https://api.github.com/search/users")!
urlComponents.queryItems = [URLQueryItem(name: "q", value: user)]
guard let url = urlComponents.url else {
return
// print("error")
}
URLSession.shared.dataTask(with: url) {(data, response, error) in
do {
if let data = data {
let decodedData = try JSONDecoder().decode(Result.self, from: data)
DispatchQueue.main.async {
self.users = decodedData.items
}
} else {
print("No data")
}
} catch {
print("Error: \(error)")
}
}.resume()
}
}
struct ContentView: View {
#State var username: String = ""
var body: some View {
NavigationView {
Form {
Section {
Text("Enter user to search for")
TextField("Enter your username", text: $username).disableAutocorrection(true)
.autocapitalization(.none)
}
NavigationLink(destination: UserView(username: username)) {
Text("Show detail for \(username)")
}
}
}
}
}
struct UserView: View {
#State var username: String
#ObservedObject var fetchUsers = FetchUsers()
#State var searchText = ""
var body: some View {
List {
ForEach(searchResults, id:\.self) { user in
NavigationLink(user.login, destination: UserDetailView(user:user))
}
}.onAppear {
self.fetchUsers.search(for: username)
}
.searchable(text: $searchText)
.navigationTitle("Users")
}
var searchResults: [User] {
if searchText.isEmpty {
print("Search is empty")
return fetchUsers.users
} else {
print("Search has a value - is filtering")
return fetchUsers.users.filter { $0.login.contains(searchText) }
}
}
}
struct UserDetailView: View {
var user: User
var body: some View {
Form {
Text(user.login).font(.headline)
Text("Git iD = \(user.id)")
URLImage(URL(string:user.avatarURL)!){ image in
image.resizable().frame(width: 50, height: 50)
}
}
}
}

Blank Screen on Loading List in SwiftUI

I am trying to display a list in a view after a condition. If data is recieved from API than screen will load new view having list showing multiple fields. Here I am showing only one field in the list. The navigation code is working fine and data is also decoded however blank screen appears when clicking list button screen moves to next view but blank screen.
Here is the view in which I am showing list :
import SwiftUI
struct MyPriceList: View {
#StateObject var road = ListAPI()
List
{
ForEach(road.priceRoad)
{
road in
HStack{
Text(road.packageName)
.font(.system(size: 15))
.foregroundColor(.black)
}
}
}
}
struct MyPriceList_Previews: PreviewProvider {
static var previews: some View {
MyPriceList()
}
}
}
The following is the viewmodel in which I have decoded the JSON data and applied the navigation
import Foundation
class ListAPI : ObservableObject
{
#Published var priceRoad = [ResponseList]()
func getList()
{
// url building code //
let list = URLSession.shared.dataTask(with: urlRequest)
{
(data, response, error) in
if let error = error {
print("Error \(error)")
}
if let data = data
{
do
{
let jsonList = try JSONDecoder().decode(PriceList.self, from: data)
let panama = jsonList.response
for pan in panama
{
print(pan.packageName) //successfully printing
}
if jsonList.success==true
{
DispatchQueue.main.async
{
self.navigate = true
self.priceRoad = jsonList.response
}
}
else
{
DispatchQueue.main.async
{
self.errorMessage = jsonList.message
}
}
}
catch
{
print("error \(error)")
}
}
}
list.resume()
} }
This is the data model for Json
struct PriceList : Codable
{
let success: Bool
let message: String
let response: [ResponseList]
enum CodingKeys:String, CodingKey{
case response = "ResponseData"
case success = "IsSuccess"
case message = "Message"
}
}
struct ResponseList:Codable
{
let packageId: Int
let packageName: String
let price: Double
let discountedPrice: Double
let testType: String
let testPackageGroupId: Int?
let SampleType: [SampleTypeList]?
enum CodingKeys:String, CodingKey{
case packageId = "PackageId"
case packageName = "PackageName"
case price = "Price"
case discountedPrice = "DiscountedPrice"
case testType = "Type"
case testPackageGroupId = "TestPackageGroupId"
case SampleType = "SampleTypeList"
}}
struct SampleTypeList:Codable
{
let testSampleTypeId: String
let sampleName: String
let colourCode: String
enum CodingKeys:String, CodingKey{
case testSampleTypeId = "TestSampleTypeId"
case sampleName = "SampleName"
case colourCode = "ColourCode"
}
}
I need to display TestName as packageName, MRP as price, B2B as discountedPrice, and TestType as testType.
try this approach, calling road.getList() in .onAppear{}:
struct MyPriceList: View {
#StateObject var road = ListAPI()
var body: some View { // <-- here need a body
List
{
ForEach(road.priceRoad)
{
road in
HStack{
Text(road.packageName)
.font(.system(size: 15))
.foregroundColor(.black)
}
}
}
.onAppear {
road.getList() // <-- here load your data
}
}
}
and make PriceList and ResponseList Identifiable, like this:
struct PriceList : Identifiable, Codable {
let id = UUID()
// ...
}
struct ResponseList: Identifiable, Codable {
let id = UUID()
// ...
}
struct SampleTypeList: Identifiable, Codable {
let id = UUID()
// ...
}
Alternatively, in ListAPI, you could have init() { getList() }, instead of using .onAppear {road.getList()}

MacOS swiftUI table select a single item

I followed the example from here: SwiftUI 3 MacOs Table single selection and double click open sheet
But it's not working well for me.
I have a structure like this:
struct Response: Codable {
var items: [Repository]
}
struct Repository: Codable, Identifiable {
var number = UUID()
var id: Int = 0
let name: String
let updated_at: String
let owner: Owner
let stargazers_count: Int
let forks_count: Int
let language: String?
let description: String?
struct Owner: Codable {
let login: String
}
enum CodingKeys: String, CodingKey {
case name, updated_at, owner, stargazers_count, forks_count, language, description
}
}
class Api : ObservableObject{
func loadData(query: String = "javascript", completion:#escaping ([Repository]) -> ()) {
guard let url = URL(string: "https://api.github.com/search/repositories?q=\(query)") else {
print("Invalid url...")
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let decodedResponse = try? JSONDecoder().decode(Response.self, from: data!) {
var id = 0
let results = decodedResponse.items.map { (repo: Repository) -> Repository in
var copyRepo = repo
copyRepo.id = id
id += 1
return copyRepo
}
print(results)
DispatchQueue.main.async {
completion(results)
}
}
}.resume()
}
}
...
#State var repositories = [Repository]()
#State private var sortOrder = [KeyPathComparator(\Repository.name)]
#State private var selectedRepository: Repository.ID?
public var body: some View {
if(selectedRepository != nil)//null??
Text(repositories[selectedRepository!].name)
Table(repositories, selection: $selectedRepository, sortOrder: $sortOrder) {
TableColumn("Name") {
Text($0.name)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .leading
)
.contentShape(Rectangle())
.contextMenu {
Button(action: {}) { Text("Action") }
}
/*.gesture(TapGesture(count: 2).onEnded {
print("pr", selectedRepository )
})*/
}
TableColumn("Last updated"){
Text($0.updated_at)
}
TableColumn("Owner", value: \.owner.login)
}
.onAppear() {
Api().loadData { (repositories) in
self.repositories = repositories
self.isLoading = false
}
}
.onChange(of: sortOrder) {
print($0)
repositories.sort(using: $0)
}
}
I would like to make sure that when the user clicks on an element of the table, then activates the selection to show the data of the selected element in a Text field, I have tried to have the name printed but it is not working.
It gives me the following error after starting the app: Thread 1: Fatal error: Unexpectedly found nil while unwrapping an Optional value
Can you give me a hand?
May be some missing character :
if(selectedRepository != nil)//null?? <- missing '{'
Text(repositories[selectedRepository!].name)
Also be writing like :
if let selectedRepository = selectedRepository {
Text(repositories[selectedRepository!].name)
...
}
Even if not necessary I always prefer to initialise properties (It is more an habit that something useful) :
#State private var selectedRepository: Repository.ID? = nil

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.

SwiftUI: View does not update after image changed asynchronous

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
}
}
}
}