How do I make the data persistent in SwiftUI? - ios

So, this is my View Model
import Foundation
import SwiftUI
import Combine
import Alamofire
class AllStatsViewModel: ObservableObject {
#Published var isLoading: Bool = true
#Published var stats = [CountryStats]()
func fetchGlobalStats() {
let request = AF.request("https://projectcovid.deadpool.wtf/all")
request.responseDecodable(of: AllCountryStats.self) { (response) in
guard let globalStats = response.value else { return }
DispatchQueue.main.async {
self.stats = globalStats.data
}
self.isLoading = false
}
}
}
And this is my view where I subscribe to change:
struct CardView: View {
#ObservedObject var allStatsVM = AllStatsViewModel()
var body: some View {
VStack {
if self.allStatsVM.stats.count > 0 {
Text(self.allStatsVM.stats[0].country)
} else {
Text("data loading")
}
}
.onAppear {
self.allStatsVM.fetchGlobalStats()
}
}
}
So, when I open the app for the first time, I get the data and then when I go home and reopen the app, all I can see is data loading.
Is there a way to persist data? I know #State helps but, I'm a beginner in SwiftUI and not sure how it works

every time you open CardView you create a new:
#ObservedObject var allStatsVM = AllStatsViewModel()
what you probably want is to create that in the home view, and pass in the ObservedObject from the home view to the CarView, where you declare:
#ObservedObject var allStatsVM: AllStatsViewModel
The data will then persist, and when CardView appear again it will show it.

Related

Issue passing data from API call in SwiftUI MVVM pattern

been going back and forth for 2 days trying to figure this out before posting and still hitting a wall.
Created an API specific class, a ViewModel, and a View and trying to shuttle data back and forth and while I see the API call is successful and I decode it without issue on logs, it never reflects on the UI or View.
As far as I see I appear to be trying to access the data before it's actually available. All help greatly appreciated!
API Class:
import Combine
import Foundation
class CrunchbaseApi:ObservableObject
{
#Published var companies:[Company] = [Company]()
#Published var singleCompany:Company?
func retrieve(company:String) async
{
let SingleEntityURL:URL = URL(string:"https://api.crunchbase.com/api/v4/entities/organizations/\(company)?card_ids=fields&user_key=**********REMOVED FOR SECURITY*****************")!
let task = URLSession.shared.dataTask(with:SingleEntityURL){ data, response, error in
let decoder = JSONDecoder()
if let data = data{
do {
self.singleCompany = try decoder.decode(Company.self, from: data)
} catch {
print(error.localizedDescription)
}
}
}
task.resume()
}
func retrieveCompanyList()
{
//declare
}
}
ViewModel:
import Combine
import Foundation
class CompanyViewModel: ObservableObject
{
var crunchbase:CrunchbaseApi = CrunchbaseApi()
#Published var singleCompany:Company?
func retrieveCompany(company:String) async
{
await self.crunchbase.retrieve(company: company)
self.singleCompany = crunchbase.singleCompany
}
}
View:
import SwiftUI
struct CompanyView: View
{
#State var companyViewModel:CompanyViewModel = CompanyViewModel()
var body: some View
{
NavigationView
{
VStack
{
Text("Company ID: \(companyViewModel.singleCompany?.id ?? "NOTHING")")
// Text("Company Name: \(companyViewModel.companyName)")
// Text("Company Summary: \(companyViewModel.companyDescription)")
// Text("Logo URL: \(companyViewModel.companyLogoURL)")
}.navigationTitle("Company")
}
}
}
Your assumption about accessing the data to early is correct. But there are more things going on here.
just declaring a function async like your retrieve func doesn´t make it async.
using a nested Observable class with #Published will not update the view
Observable classes should have either an #StateObject or an #ObservableObject property wrapper. Depending on if the class is injected or created in the view
Possible solution:
Move the function into the viewmodel:
class CompanyViewModel: ObservableObject
{
#Published var singleCompany:Company?
func retrieve(company:String)
{
let SingleEntityURL:URL = URL(string:"https://api.crunchbase.com/api/v4/entities/organizations/\(company)?card_ids=fields&user_key=**********REMOVED FOR SECURITY*****************")!
let task = URLSession.shared.dataTask(with:SingleEntityURL){ data, response, error in
let decoder = JSONDecoder()
if let data = data{
do {
self.singleCompany = try decoder.decode(Company.self, from: data)
} catch {
print(error.localizedDescription)
}
}
}
task.resume()
}
}
Change the View to hold the viewmodel as #StateObject, also add an .onApear modifier to load the data:
struct CompanyView: View
{
#StateObject var companyViewModel:CompanyViewModel = CompanyViewModel()
var body: some View
{
NavigationView
{
VStack
{
Text("Company ID: \(companyViewModel.singleCompany?.id ?? "NOTHING")")
// Text("Company Name: \(companyViewModel.companyName)")
// Text("Company Summary: \(companyViewModel.companyDescription)")
// Text("Logo URL: \(companyViewModel.companyLogoURL)")
}.navigationTitle("Company")
.onAppear {
companyViewModel.retrieve(company: "whatever")
}
}
}
}

.onReceive firing twice | SwiftUI

I have a SwiftUI view that includes a Picker. I'm using a Switch statement inside .onReceive of the Picker to call a function. The function calls an external API.
The problem is that the function is being called twice whenever the view is initialised i.e duplicating the data. I'm can't figure out why .onReceive is being called twice.
I think it might have something to do with the func being called when I init the Picker Model and then getting another notification from the Picker itself but I'm not sure how to work around it.
Here's my code:
Picker Model
import Foundation
class PickerModel: ObservableObject {
#Published var filter = 0
let pickerOptions = ["Popular", "Top Rated"]
}
View containing the Picker:
import SwiftUI
struct FilteredMoviesGridView: View {
#ObservedObject private var filteredMovieVM = FilteredMovieGridViewModel()
#ObservedObject private var pickerModel = PickerModel()
private var twoColumnGrid = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
NavigationView {
VStack {
Picker(selection: $pickerModel.filter, label: Text("Select")) {
ForEach(0 ..< pickerModel.pickerOptions.count) {
Text(pickerModel.pickerOptions[$0])
}
}.onReceive(pickerModel.$filter) { (value) in
switch value {
case 0:
filteredMovieVM.movies.removeAll()
filteredMovieVM.currentPage = 1
filteredMovieVM.fetchMovies(filter: "popularity")
case 1:
filteredMovieVM.movies.removeAll()
filteredMovieVM.currentPage = 1
filteredMovieVM.fetchMovies(filter: "vote_average")
default:
filteredMovieVM.movies.removeAll()
filteredMovieVM.currentPage = 1
filteredMovieVM.fetchMovies(filter: "popularity")
}
}.pickerStyle(SegmentedPickerStyle())
ScrollView {
LazyVGrid(columns: twoColumnGrid, spacing: 10) {
ForEach(filteredMovieVM.movies, id:\.id) { movie in
NavigationLink(destination: MovieDetailView(movie: movie)) {
MovieGridItemView(movies: movie)
}.buttonStyle(PlainButtonStyle())
.onAppear(perform: {
if movie == self.filteredMovieVM.movies.last {
switch pickerModel.filter {
case 0:
self.filteredMovieVM.checkTotalMovies(filter: "popularity")
case 1:
self.filteredMovieVM.checkTotalMovies(filter: "vote_average")
default:
self.filteredMovieVM.checkTotalMovies(filter: "popularity")
}
}
})
}
}
}
.navigationBarTitle("Movies")
}
}.accentColor(.white)
}
}
The View Model containing the function:
import Foundation
class FilteredMovieGridViewModel: ObservableObject {
#Published var movies = [Movie]()
private var filteredMovies = [MovieList]()
var currentPage = 1
func checkTotalMovies(filter: String) {
if filteredMovies.count < 20 {
fetchMovies(filter: filter)
}
}
func fetchMovies(filter: String) {
WebService().getMoviesByFilter(filter: filter, page: currentPage) { movie in
if let movie = movie {
self.filteredMovies.append(movie)
for movie in movie.movies {
self.movies.append(movie)
print(self.movies.count)
}
}
}
if let totalPages = filteredMovies.first?.totalPages {
if currentPage <= totalPages {
currentPage += 1
}
}
}
}
Any help would be greatly appreciated.
Most likely you're recreating your ObservedObjects whenever your FilteredMoviesGridView is recreated. This can happen whenever SwiftUI's runtime thinks it needs to recreate it. So your view creation should be cheap and you should make sure not to accidentally recreate resources you need. Luckily SwiftUI in iOS 14, etc. has made it much easier to fix this problem. Instead of using #ObservedObject, use #StateObject, which will keep the same instance alive as your view is recreated.

How do I access only the first element of data in SwiftUI?

So, I have the following View Model where I fetch the data:
import Foundation
import SwiftUI
import Combine
import Alamofire
class AllStatsViewModel: ObservableObject {
#Published var isLoading: Bool = true
#Published var stats = [CountryStats]()
func fetchGlobalStats() {
let request = AF.request("https://projectcovid.deadpool.wtf/all")
request.responseDecodable(of: AllCountryStats.self) { (response) in
guard let globalStats = response.value else { return }
DispatchQueue.main.async {
self.stats = globalStats.data
}
self.isLoading = false
}
}
}
And this is the view:
struct CardView: View {
#ObservedObject var allStatsVM = AllStatsViewModel()
var body: some View {
VStack {
Text(self.allStatsVM.stats[0].country)
}
.onAppear {
self.allStatsVM.fetchGlobalStats()
}
}
}
I'd like to access only the first element of the data, but the problem I face is that when the view loads, the data is not loaded, so I get an index out of range error at
Text(self.allStatsVM.stats[0].country)
Is there a way, I can access the first element?
try this:
struct CardView: View {
#ObservedObject var allStatsVM = AllStatsViewModel()
var body: some View {
VStack {
if self.allStatsVM.stats.count > 0 {
Text(self.allStatsVM.stats[0].country)
} else {
Text ("data loading")
}
}
.onAppear {
self.allStatsVM.fetchGlobalStats()
}
}
}

Pass EnvironmentObject to an ObservableObject class [duplicate]

This question already has an answer here:
Swiftui - How do I initialize an observedObject using an environmentobject as a parameter?
(1 answer)
Closed 2 years ago.
I have made a SwiftUI app that repeatedly fetches telemetry data to update custom views. The views use a variable stored in an EnvironmentObject.
struct updateEO{
#EnvironmentObject var settings:UserSettings
func pushSettingUpdate(telemetry: TelemetryData) {
settings.info = telemetry
print(settings.info)
}
}
class DownloadTimer : ObservableObject {
var timer : Timer!
let didChange = PassthroughSubject<DownloadTimer,Never>()
#Published var telemetry = TelemetryData()
func start() {
connectToClient()
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) {
_ in
guard let url = URL(string: "http://127.0.0.1:25555/api/telemetry") else {
print("Invalid URL")
return
}
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
if let decodedResponse = try? JSONDecoder().decode(TelemetryData.self, from: data) {
DispatchQueue.main.async {
updateEO().pushSettingUpdate(telemetry: decodedResponse)
}
return
}
}
}.resume()
}
}
}
At runtime, when the telemetry is passed to the pushSettingUpdate(telemetry: decodedResponse), the app crashes with an error of 'Fatal error: No ObservableObject of type UserSettings found.'.
I understand I may need to pass the struct the EnvironmentObject but I am not sure on how to do that. Any help would be much appreciated. Thanks! :)
You should use #EnvironmentObject in your view and pass it down to your model if needed.
Here, struct updateEO is not a view.
I've created a simpler example to show you how to do this :
UserSettings
class UserSettings: ObservableObject {
#Published var info: String = ""
}
DownloadTimer
class DownloadTimer: ObservableObject {
var timer : Timer?
func start(settings: UserSettings) {
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { t in
settings.info = t.fireDate.description
}
}
}
And you call start (with UserSettings as parameter) when the Text appears.
MyView
struct MyView: View {
#StateObject private let downloadTimer = DownloadTimer()
#EnvironmentObject var settings: UserSettings
var body: some View {
Text(settings.info)
.onAppear {
self.downloadTimer.start(settings: self.settings)
}
}
}
And don't forget to call .environmentObject function to inject your UserSettings in SceneDelegate.
SceneDelegate
let contentView = MyView().environmentObject(UserSettings())
You should see the text updating as time goes by.

#Published and .assign not reacting to value update

SwiftUI and Combine noob here, I isolated in a playground the problem I am having. Here is the playground.
final class ReactiveContainer<T: Equatable> {
#Published var containedValue: T?
}
class AppContainer {
static let shared = AppContainer()
let text = ReactiveContainer<String>()
}
struct TestSwiftUIView: View {
#State private var viewModel = "test"
var body: some View {
Text("\(viewModel)")
}
init(textContainer: ReactiveContainer<String>) {
textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.assign(to: \.viewModel, on: self)
}
}
AppContainer.shared.text.containedValue = "init"
var testView = TestSwiftUIView(textContainer: AppContainer.shared.text)
print(testView)
print("Executing network request")
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
AppContainer.shared.text.containedValue = "Hello world"
print(testView)
}
When I run the playground this is what's happening:
compact map Optional("init")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil))
Executing network request
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil))
So as you can see, two problems there:
The compact map closure is only called once, on subscription but not when the dispatch is ran
The assign operator is never called
I have been trying to solve this these past few hours without any success. Maybe someone with a top knowledge in SwiftUI/Combine could help me, thx !
EDIT
Here is the working solution:
struct ContentView: View {
#State private var viewModel = "test"
let textContainer: ReactiveContainer<String>
var body: some View {
Text(viewModel).onReceive(textContainer.$containedValue) { (newContainedValue) in
self.viewModel = newContainedValue ?? ""
}
}
init(textContainer: ReactiveContainer<String>) {
self.textContainer = textContainer
}
}
I would prefer to use ObservableObject/ObservedObject pattern, right below, but other variants also possible (as provided further)
All tested with Xcode 11.2 / iOS 13.2
final class ReactiveContainer<T: Equatable>: ObservableObject {
#Published var containedValue: T?
}
struct TestSwiftUIView: View {
#ObservedObject var vm: ReactiveContainer<String>
var body: some View {
Text("\(vm.containedValue ?? "<none>")")
}
init(textContainer: ReactiveContainer<String>) {
self._vm = ObservedObject(initialValue: textContainer)
}
}
Alternates:
The following fixes your case (if you don't store subscriber the publisher is canceled immediately)
private var subscriber: AnyCancellable?
init(textContainer: ReactiveContainer<String>) {
subscriber = textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.assign(to: \.viewModel, on: self)
}
Please note, view's state is linked only being in view hierarchy, in Playground like you did it holds only initial value.
Another possible approach, that fits better for SwiftUI hierarchy is
struct TestSwiftUIView: View {
#State private var viewModel: String = "test"
var body: some View {
Text("\(viewModel)")
.onReceive(publisher) { value in
self.viewModel = value
}
}
let publisher: AnyPublisher<String, Never>
init(textContainer: ReactiveContainer<String>) {
publisher = textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.eraseToAnyPublisher()
}
}
I would save a reference to AppContainer.
struct TestSwiftUIView: View {
#State private var viewModel = "test"
///I just added this
var textContainer: AnyCancellable?
var body: some View {
Text("\(viewModel)")
}
init(textContainer: ReactiveContainer<String>) {
self.textContainer = textContainer.$containedValue.compactMap {
print("compact map \(String(describing: $0))")
return $0
}.assign(to: \.viewModel, on: self)
}
}
compact map Optional("init")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil), textContainer: Optional(Combine.AnyCancellable))
Executing network request
compact map Optional("Hello")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil), textContainer: Optional(Combine.AnyCancellable))
We don't use Combine for moving data between Views, SwiftUI already has built-in support for this. The main problem is you are treating the TestSwiftUIView as if it is a class but it is a struct, i.e. a value. It's best to think of the View simply as the data to be displayed. SwiftUI creates these data structs over and over again when data changes. So the solution is simply:
struct ContentView: View {
let text: String
var body: some View { // only called if text is different from last time ContentView was created in a parent View's body.
Text(text)
}
}
The parent body method can call ContentView(text:"Test") over and over again but the ContentView body method is only called by SwiftUI when the let text is different from last time, e.g. ContentView(text:"Test2"). I think this is what you tried to recreate with Combine but it is unnecessary because SwiftUI already does it.

Resources