I have a CurrentValueSubject to hold data received from Firebase fetch request.
final class CardRepository: ObservableObject {
private let store = Firestore.firestore()
var resultSubject = CurrentValueSubject<[Card], Error>([])
init() {
}
func get() {
store.collection(StorageCollection.EnglishCard.getPath)
.addSnapshotListener { [unowned self] snapshot, err in
if let err = err {
resultSubject.send(completion: .failure(err))
}
if let snapshot = snapshot {
let cards = snapshot.documents.compactMap {
try? $0.data(as: Card.self)
}
resultSubject.send(cards)
}
}
}
}
In my ViewModel, I want whenever resultSubject sends or emits a value. It will change the state and has that value attached to the succes state.
class CardViewModel: CardViewModelProtocol, ObservableObject {
#Published var repository: CardRepository
#Published private(set) var state: CardViewModelState = .loading
private var cancellables: Set<AnyCancellable> = []
required init (_ repository: CardRepository) {
self.repository = repository
bindingCards()
}
private func bindingCards() {
let _ = repository.resultSubject
.sink { [unowned self] comp in
switch comp {
case .failure(let err):
self.state = .failed(err: err)
case .finished:
print("finised")
}
} receiveValue: { [unowned self] res in
self.state = .success(cards: res)
}
}
func add(_ card: Card) {
repository.add(card)
}
func get() {
repository.get()
}
}
On my ContentView, it will display a button that print the result.
struct ContentView: View {
#StateObject var viewModel = CardViewModel(CardRepository())
var body: some View {
Group {
switch viewModel.state {
case .loading:
ProgressView()
Text("Loading")
case .success(cards: let cards):
let data = cards
Button {
print(data)
} label: {
Text("Tap to show cards")
}
case .failed(err: let err):
Button {
print(err)
} label: {
Text("Retry")
}
}
Button {
viewModel.get()
} label: {
Text("Retry")
}
}.onAppear {viewModel.get() }
}
}
My problem is the block below only trigger once when I first bind it to the resultSubject.
} receiveValue: { [unowned self] res in
self.state = .success(cards: res)
}
I did add a debug and resultSubject.send(cards) works every time.
You need to store the Cancellable returned from the .sink in the class so it doesn't get deallocated:
Either in a set var cancellables = Set<AnyCancellable>() if you want to use multiple Publishers, or in var cancellable: AnyCancellable?.
Add .store(in &cancellables) like so:
} receiveValue: { [unowned self] res in
self.state = .success(cards: res)
}.store(in: &cancellables)
Edit:
In ObservableObject classes we don't use sink, we assign to an #Published:
let _ = repository.resultSubject
.assign(to: &$self.state)
Related
Building a crystal app. Displaying a list, showing details on click.
Been looking into ObservableObject, Binding, etc.
Tried #State in CrystalView but got lost pretty quickly.
What's the easiest way to pass data around views? Watched a few videos, still confused.
How do I pass crystals[key] into CrystalView()?
struct ContentView: View {
#State private var crystals = [String:Crystal]()
var body: some View {
Text("Crystals").font(.largeTitle)
NavigationView {
List {
ForEach(Array(crystals.keys), id:\.self) { key in
HStack {
NavigationLink(destination: CrystalView()) {
Text(key)
}
}
}
}.onAppear(perform:loadData)
}
}
func loadData() {
guard let url = URL(string: "https://lit-castle-74820.herokuapp.com/api/crystals") else { return }
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data else { return }
do {
let decoded = try JSONDecoder().decode([String:Crystal].self, from: data)
DispatchQueue.main.async {
print(decoded)
self.crystals = decoded
}
} catch let jsonError as NSError {
print("JSON decode failed: \(jsonError)")
}
}.resume()
}
}
struct Crystal: Codable, Identifiable {
var id = UUID()
let composition, formation, colour: String
let metaphysical: [String]
}
struct CrystalView: View {
var body: some View {
Text("crystal")
}
}
try this approach, works well for me:
struct Crystal: Codable, Identifiable {
var id = UUID()
let composition, formation, colour: String
let metaphysical: [String]
// -- here, no `id`
enum CodingKeys: String, CodingKey {
case composition,formation,colour,metaphysical
}
}
struct CrystalView: View {
#State var crystal: Crystal? // <-- here
var body: some View {
Text("\(crystal?.composition ?? "no data")")
}
}
struct ContentView: View {
#State private var crystals = [String:Crystal]()
var body: some View {
Text("Crystals").font(.largeTitle)
NavigationView {
List {
ForEach(Array(crystals.keys), id:\.self) { key in
HStack {
NavigationLink(destination: CrystalView(crystal: crystals[key])) { // <-- here
Text(key)
}
}
}
}.onAppear(perform: loadData)
}
}
func loadData() {
guard let url = URL(string: "https://lit-castle-74820.herokuapp.com/api/crystals") else { return }
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data else { return }
do {
let decoded = try JSONDecoder().decode([String:Crystal].self, from: data)
DispatchQueue.main.async {
print("decoded: \(decoded)")
self.crystals = decoded
}
} catch let jsonError as NSError {
print("JSON decode failed: \(jsonError)")
}
}.resume()
}
}
Something like this:
struct ContentView: View {
#State private var crystals: [Crystal] = []
var body: some View {
NavigationView {
List {
ForEach(crystals) { crystal in
NavigationLink(destination: CrystalView(crystal: crystal)) {
Text(crystal.name)
}
}
}
.navigationTitle("Crystals")
// initial detail view
Text("Select a crystal")
}
.task {
crystals = try? await fetchCrystals()
}
}
func fetchCrystals() async throws -> [Crystal] {
let (data, _) = try await URLSession.shared.data(from: "https://lit-castle-74820.herokuapp.com/api/crystals")
let decoder = JSONDecoder()
return try decoder.decode([Crystal].self, from: data) // you might want to convert this downloaded struct into a more suitable struct for the app.
}
}
struct CrystalView: View {
let crystal: Crystal
var body: some View {
Text(crystal.composition)
.navigationTitle(crystal.name)
}
}
Sorry for simple question, try to learn SwiftUI
My goal is to show alert then i can not load data from internet using .alert()
the problem is that my struct for error actually has data but it does not transfer to .alert()
debug shows that AppError struct fill in with error but then i try to check for nil or not it is always nil in .Appear()
PostData.swift
struct AppError: Identifiable {
let id = UUID().uuidString
let errorString: String
}
NetworkManager.swift
class NetworkManager: ObservableObject {
#Published var posts = [Post]()
#Published var appError: AppError? = nil
func fetchGuardData() {
if let url = URL(string: "http://hn.algolia.com/api/v1/search?tags=front_page") {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) { data, response, error in
if error == nil {
let decorder = JSONDecoder()
if let safeData = data {
do {
let results = try decorder.decode(Results.self, from: safeData)
DispatchQueue.main.sync {
self.posts = results.hits }
} catch {
self.appError = AppError(errorString: error.localizedDescription)
}
} else {
self.appError = AppError(errorString: error!.localizedDescription)
}
} else {
DispatchQueue.main.sync {
self.appError = AppError(errorString: error!.localizedDescription)
}
}
} //
task.resume()
} else {
self.appError = AppError(errorString: "No url response")
}
}
}
ContentView.swift
struct ContentView: View {
#StateObject var networkManager = NetworkManager()
#State var showAlert = false
var body: some View {
NavigationView {
List(networkManager.posts) { post in
NavigationLink(destination: DetailView(url: post.url)) {
HStack {
Text(String(post.points))
Text(post.title)
}
}
}
.navigationTitle("H4NEWS")
}
.onAppear() {
networkManager.fetchGuardData()
if networkManager.appError != nil {
showAlert = true
}
}
.alert(networkManager.appError?.errorString ?? "no data found", isPresented: $showAlert, actions: {})
}
}
Probably when doing this check, the data fetch process is not finished yet.
if networkManager.appError != nil {
showAlert = true
}
So you should wait the network request finish to check if there is error or not.
If you sure there is error and just test this try this to see error:
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if networkManager.appError != nil {
showAlert = true
}
}
To handle better this situation you can pass a closure your fetchGuardData function and handle your result and error inside it.
or you can use .onChange for the listen the changes of appError.
.onChange(of: networkManager.appError) { newValue in }
I have 2 views - which I want to navigate between, and have a viewModel object shared between them as an EnvironmentObject. I keep getting the "A View.environmentObject(_:) for TidesViewModel may be missing as an ancestor of this view." error - but none of the solutions I have found seem to work. Please find below my code. The following is the first view:
import SwiftUI
struct ContentView: View {
#ObservedObject var tidesViewModel: TidesViewModel = TidesViewModel()
var body: some View {
NavigationView
{
List
{
ForEach (tidesViewModel.stations.indices) {
stationid in
HStack
{
NavigationLink(destination: TideDataView(stationId: tidesViewModel.stations[stationid].properties.Id))
{
Text(tidesViewModel.stations[stationid].properties.Name)
}
}
}
}
}.environmentObject(tidesViewModel)
}
}
and below is the child view - which throws the error.
import SwiftUI
struct TideDataView: View {
#EnvironmentObject var tidesViewModel : TidesViewModel
var stationId: String
init(stationId: String) {
self.stationId = stationId
getTidesForStation(stationId: stationId)
}
var body: some View {
List
{
ForEach (tidesViewModel.tides.indices)
{
tideIndex in
Text(tidesViewModel.tides[tideIndex].EventType)
}
}
}
func getTidesForStation(stationId: String)
{
tidesViewModel.getTidalData(forStation: stationId)
}
}
For completeness - below is the Observable object being passed:
import Foundation
import SwiftUI
class TidesViewModel: ObservableObject
{
private var tideModel: TideModel = TideModel()
var currentStation: Feature?
init()
{
readStations()
}
var stations: [Feature]
{
tideModel.features
}
var tides: [TidalEvent]
{
tideModel.tides
}
func readStations()
{
let stationsData = readLocalFile(forName: "stations")
parseStations(jsonData: stationsData!)
}
private func readLocalFile(forName name: String) -> Data? {
do {
if let bundlePath = Bundle.main.path(forResource: name,
ofType: "json"),
let jsonData = try String(contentsOfFile: bundlePath).data(using: .utf8) {
return jsonData
}
} catch {
print(error)
}
return nil
}
private func parseStations(jsonData: Data) {
do {
let decodedData: FeatureCollection = try JSONDecoder().decode(FeatureCollection.self,
from: jsonData)
//print(decodedData)
tideModel.features = decodedData.features
} catch let jsonError as NSError{
print(jsonError.userInfo)
}
}
func getTidalData(forStation stationId: String)
{
let token = "f43c068141bb417fb88909be5f68781b"
guard let url = URL(string: "https://admiraltyapi.azure-api.net/uktidalapi/api/V1/Stations/" + stationId + "/TidalEvents") else {
fatalError("Invalid URL")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(token, forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
URLSession.shared.dataTask(with: request) { data, response, error in
guard let data = data else{ return }
do{
let decoder = JSONDecoder()
decoder.dataDecodingStrategy = .base64
let decodedData = try decoder.decode([TidalEvent].self, from: data)
DispatchQueue.main.async {
self.tideModel.tides = decodedData
}
}catch let error{
print(error)
}
}.resume()
}
}
You need to attach the modifier .environmentObject() directly to the view that will receive it. So, in your case, attach it to TideDataView, not to the NavigationView around it.
Your code would look like this:
NavigationLink {
TideDataView(stationId: tidesViewModel.stations[stationid].properties.Id)
.environmentObject(tidesViewModel)
} label: {
Text(tidesViewModel.stations[stationid].properties.Name)
}
// Delete the modifier .environmentObject() attached to the NavigationView
I have 2 tabs and the associated views are tabAView and tabBView.
On tabAView, 1 API call is there and got user object which is Published object in its ViewModel. ViewModel name is UserViewModel. UserViewModel is being observed by tabAView.
On tabBView, I have to use that user object. Because on some actions, user object value is changed, those changes should be reflected on subsequent views.
I am confused about the environment object usage here. Please suggest what will be the best approach.
Here is the code to understand better my problem.
struct ContentView: View {
enum AppPage: Int {
case TabA=0, TabB=1
}
#StateObject var settings = Settings()
var viewModel: UserViewModel
var body: some View {
NavigationView {
TabView(selection: $settings.tabItem) {
TabAView(viewModel: viewModel)
.tabItem {
Text("TabA")
}
.tag(AppPage.TabA)
AppsView()
.tabItem {
Text("Apps")
}
.tag(AppPage.TabB)
}
.accentColor(.white)
.edgesIgnoringSafeArea(.top)
.onAppear(perform: {
settings.tabItem = .TabA
})
.navigationBarTitleDisplayMode(.inline)
}
.environmentObject(settings)
}
}
This is TabAView:
struct TabAView: View {
#ObservedObject var viewModel: UserViewModel
#EnvironmentObject var settings: Settings
init(viewModel: UserViewModel) {
self.viewModel = viewModel
}
var body: some View {
Vstack {
/// code
}
.onAppear(perform: {
/// code
})
.environmentObject(settings)
}
}
This is the UserViewModel where API is hit and user object comes:
class UserViewModel: ObservableObject {
private var apiService = APIService.shared
#Published var user: EndUserData?
init () {
getUserProfile()
}
func getUserProfile() {
apiService.getUserAccount() { user in
DispatchQueue.main.async {
self.user = user
}
}
}
}
Below is the APIService function, where the user object is saved into UserDefaults for use. Which I know is incorrect.(That is why I am looking for another solution). Hiding the URL, because of its confidential.
func getUserAccount(completion: #escaping (EndUserData?) -> Void) {
self.apiManager.makeRequest(toURL: url, withHttpMethod: .get) { results in
guard let response = results.response else { return completion(nil) }
if response.httpStatusCode == 200 {
guard let data = results.data else { return completion(nil) }
do {
let str = String(decoding: data, as: UTF8.self)
print(str)
let decoder = JSONDecoder()
let responseData = try decoder.decode(ResponseData<EndUserData>.self, from: data)
UserDefaults.standard.set(data, forKey: "Account")
completion(responseData.data)
} catch let jsonError as NSError {
print(jsonError.localizedDescription)
return completion(nil)
}
}
}
}
This is another TabBView:
struct TabBView: View {
var user: EndUserData?
init() {
do {
guard let data = UserDefaults.standard.data(forKey: "Account") else {
return
}
let decoder = JSONDecoder()
let responseData = try decoder.decode(ResponseData<EndUserData>.self, from: data)
user = responseData.data
} catch let jsonError as NSError {
print(jsonError.localizedDescription)
}
}
var body: some View {
VStack (spacing: 10) {
UserSearch()
}
}
}
This is another view in TabBView, where the User object is used. Changes are not reflecting here.
struct UserSearch: View {
private var user: EndUserData?
init(comingFromAppsSection: Bool) {
do {
guard let data = UserDefaults.standard.data(forKey: "Account") else {
return
}
let decoder = JSONDecoder()
let responseData = try decoder.decode(ResponseData<EndUserData>.self, from: data)
user = responseData.data
} catch let jsonError as NSError {
print(jsonError.localizedDescription)
}
}
var body: some View {
Vstack {
Text(user.status)
}
}
}
I have removed most of the code from a confidential point of view but this code will explain the reason and error. Please look into the code and help me.
I found why the cache prompt will not be shown.
If I use the ImageView directly on the ContentView, the cache prompt will not show.
If I wrap the ImageView with a View, then use the wrapper view on the ContentView, the cache prompt will show.
Here is the working code in the ContentView.swift
struct ContentView: View {
var links =
[NewsItem(urlString: "https://www.jobyme88.com/wp-content/uploads/2020/11/50d0-kj-classroom-0.jpg"),
NewsItem(urlString: "https://www.jobyme88.com/wp-content/uploads/2020/11/50d0-kj-classroom-1.jpg"),
NewsItem(urlString: "https://www.jobyme88.com/wp-content/uploads/2020/11/50d0-kj-classroom-2.jpg"),
NewsItem(urlString: "https://www.jobyme88.com/wp-content/uploads/2020/11/50d0-kj-classroom-3.jpg"),
NewsItem(urlString: "https://www.jobyme88.com/wp-content/uploads/2020/11/50d0-kj-classroom-4.jpg"),
NewsItem(urlString: "https://www.jobyme88.com/wp-content/uploads/2020/11/50d0-kj-classroom-5.jpg"),
NewsItem(urlString: "https://www.jobyme88.com/wp-content/uploads/2020/11/50d0-kj-classroom-6.jpg")]
var body: some View {
List(links) { news in
// working
NewsListItemView(item: news)
// not working
//NewsImageView(urlString: news.urlString)
}
}
}
This is the NewsListItemView which is just a wrapper
struct NewsListItemView: View {
var item: NewsItem
var body: some View {
NewsImageView(urlString: item.urlString)
}
}
This is my cache prompt location.
NewsImageViewModel.swift
class NewsImageViewModel: ObservableObject {
static var placeholder = UIImage(named: "NewsIcon.png")
#Published var image: UIImage?
var urlString: String?
init(urlString: String) {
self.urlString = urlString
loadImage()
}
func loadImage() {
if loadImageFromCache() {
return
}
loadImageFromURL()
}
func loadImageFromCache() -> Bool {
guard let cacheIamge = TemporaryImageCache.getShared()[urlString!] else {
return false
}
print("load from cache")
self.image = cacheIamge
return true
}
func loadImageFromURL() {
print("load from url")
guard let urlString = urlString else {
return
}
let url = URL(string: urlString)!
let task = URLSession.shared.dataTask(with: url, completionHandler: getResponseFromURL(data:response:error:))
task.resume()
}
func getResponseFromURL(data: Data?, response: URLResponse?, error: Error?) {
guard error == nil else {
print("Error \(error!)")
return
}
guard data != nil else {
print("No founded data")
return
}
DispatchQueue.main.async {
guard let loadedImage = UIImage(data: data!) else {
print("Not supported data ")
return
}
self.image = loadedImage
TemporaryImageCache.getShared().cache.setObject(loadedImage, forKey: self.urlString! as NSString)
}
}
}
NewsImageView.swift
import SwiftUI
struct NewsImageView: View {
#ObservedObject var model: NewsImageViewModel
init(urlString: String) {
model = NewsImageViewModel(urlString: urlString)
}
var body: some View {
Image(uiImage: model.image ?? NewsImageViewModel.placeholder!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100, alignment: .center)
}
}
NewsListItemView.swift
struct NewsListItemView: View {
var item: NewsItem
var body: some View {
NewsImageView(urlString: item.urlString)
}
}
This is ImageCache.swift
protocol ImageCache {
subscript(_ urlString: String) -> UIImage? {get set }
}
struct TemporaryImageCache: ImageCache {
subscript(urlString: String) -> UIImage? {
get {
cache.object(forKey: urlString as NSString)
}
set {
newValue == nil ? cache.removeObject(forKey: urlString as NSString) : cache.setObject(newValue!, forKey: urlString as NSString)
}
}
var cache = NSCache<NSString, UIImage>()
}
extension TemporaryImageCache {
private static var shared = TemporaryImageCache()
static func getShared() -> TemporaryImageCache {
return shared
}
}
This is NewsItem.swift
struct NewsItem: Identifiable {
var id = UUID()
var urlString: String
}