Changes to a struct not being published in SwiftUI - ios

I'm loading data from an API, and expecting my app to show the data once it's loaded.
In my View Model file, here's the code:
It calls a WeatherService to get the data, and populates the weather property. Weather is a struct in this case.
class WeatherViewModel: ObservableObject {
let webService = WeatherService.shared
#Published var weather:Weather?
init() {
}
func getWeather() {
webService.getWeather { weather in
if let weather = weather {
self.weather = weather
}
}
}
}
In my SwiftUI view, here's the code:
I instantiate an instance of the View Model as an ObservedObject
In the inAppear, I call the method in the view model to get the data
The first time the screen launches (using a tab bar), I see "Loading weather..." and it never goes away
If I navigate to a different tab and back, I see the weather. I can't tell if this is data from the old API call, or from the new one.
struct WeatherView: View {
#ObservedObject var weatherViewModel = WeatherViewModel()
#State var areDetailsHidden = true
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if(weatherViewModel.weather == nil) {
Text("Loading weather...")
} else {
Text("Display the weather here")
}
}
.onAppear{
self.weatherViewModel.getWeather()
}
}
}
The weird thing is, if I remove the getWeather() from the onAppear and add it to the init() of the View Model, it works (although for some reason getWeather() gets called twice...). However, I want the weather info to be refreshed every time the screen is loaded.

This is caused by the:
#ObservedObject var weatherViewModel = WeatherViewModel()
being owned by the WeatherView itself.
So what happens is the weather view model changes which forces a re-render of the view which creates a new copy of the weather view model, which changes forces a re-render...
So you end up with an endless loop.
To fix it you need to move the weather view model out of the view itself so either use an #Binding and pass it in or an #EnvironmentObject and access it that way.

Related

SwiftUI list item not updated if model is wrapped in #State

Given
a View with a simple List
an ItemView for each element of the list
a Model for the app
a model value (Deck)
Tapping on a button in the main view, is expected the model to change and propagate the changes to the ItemView.
The problem is that the changes only propagate if the model struct is stored in the ItemView as a normal variable; but if i add the #State property wrapper these do not happen. The view will update but not change (like if the data has been cached).
Question 1: is this an expected behaviour? If so, why? I was expecting to have the ItemView to only update when the model change by observing it throw #State, this way instead the view will always refresh whenever the list commands it, even if the data is not updated?
Question 2: Is it normal otherwise to have the items of a list using plain structs properties as models? Using observable classes would create much more complexity when handling the array in the view model and also make more complicated the List refreshing/identifying mechanism seems to me.
In the example the model does not need the #State, since changes are only coming from outside, in real world i would need it when it's the view itself to trigger the changes?
This is a stripped down version to reproduce the issue (create a project and replace ContentView with following):
import SwiftUI
struct Deck: Identifiable {
let id: Int
var name: String
init(_ name: String, _ id: Int) {
self.name = name
self.id = id
}
}
struct ItemView: View {
// #State var deck: Deck // DOES NOT WORK !!! <-------------------
let deck: Deck // WORKS (first element is updated)
var body: some View { Text(deck.name) }
}
class Model: ObservableObject {
#Published var decks: [Deck] = getData()
static func getData(changed: Bool = false) -> [Deck] {
let firstElement = changed ? "CHANGED ELEMENT" : "0"
return [Deck(firstElement, 0), Deck("1", 1), Deck("2", 2)]
}
func changeFirst() { self.decks = Self.getData(changed: true) }
}
struct ContentView: View {
#StateObject var model = Model()
var body: some View {
List {
ForEach(model.decks) { deck in
ItemView(deck: deck)
}
Button(action: model.changeFirst) {
Text("Change first item")
}
}
}
}
Tested with Xcode 13 / iPhone13 Simulator (iOS 15)
Question 1
Yes, it is expected because #State and #Published are sources of truth. #State breaks the connection with #Published and makes a copy.
Question 2
If all the changes are outside (one-way connection) you don't need wrappers of any kind for the children when dealing with value types.
If you need a two-way connection you use #Binding when dealing with a struct/value type.
https://developer.apple.com/wwdc21/10022
https://developer.apple.com/documentation/swiftui/managing-user-interface-state
https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

Can't show view in Swift UI with layers of views and onAppear

There is a strange case where if you show a view through another view the contents (list of 3 items) of the second view won't show when values are set using onAppear. I'm guessing SwiftUI gets confused since the second views onAppear is called prior to the first views onAppear, but I still think this is weird since both of the views data are only used in their own views. Also, there is no problem if I don't use view models and instead have the data being set using state directly in the view, but then there is yet another problem that the view model declaration must be commented out otherwise I get "Thread 1: EXC_BAD_ACCESS (code=1, address=0x400000008)". Furthermore, if I check for nil in the first view on the data that is set there before showing the second one, then the second view will be shown the first time you navigate (to the first containing the second), but no other times. I also tried removing content view and starting directly at FirstView and then the screen is just black. I want to understand why these problems happen, setting data through init works but then the init will be called before it's navigated to since that's how NavigationView works, which in turn I guess I could work around by using a deferred view, but there are cases where I would like to do stuff in the background with .task as well and it has the same problem as .onAppear. In any case I would like to avoid work arounds and understand the problem. See comments for better explanation:
struct ContentView: View {
var body: some View {
NavigationView {
// If I directly go to SecondView instead the list shows
NavigationLink(destination: FirstView()) {
Text("Go to first view")
}
}
}
}
class FirstViewViewModel: ObservableObject {
#Published var listOfItems: [Int]?
func updateList() {
listOfItems = []
}
}
struct FirstView: View {
#ObservedObject var viewModel = FirstViewViewModel()
// If I have the state in the view instead of the view model there is no problem.
// Also need to comment out the view model when using the state otherwise I get Thread 1: EXC_BAD_ACCESS runtime exception
//#State private var listOfItems: [Int]?
var body: some View {
// Showing SecondView without check for nil and it will never show
SecondView()
// If I check for nil then the second view will show the first time its navigated to, but no other times.
/*Group {
if viewModel.listOfItems != nil {
SecondView()
} else {
Text("Loading").hidden() // Needed in order for onAppear to trigger, EmptyView don't work
}
}*/
// If I comment out onAppear there is no problem
.onAppear {
print("onAppear called for first view after onAppear in second view")
viewModel.updateList()
// listOfItems = []
}
}
}
class SecondViewViewModel: ObservableObject {
#Published var listOfItems = [String]()
func updateList() {
listOfItems = ["first", "second", "third"]
}
}
struct SecondView: View {
#ObservedObject var viewModel = SecondViewViewModel()
// If I set the items through init instead of onAppear the list shows every time
init() {
// viewModel.updateList()
}
var body: some View {
Group {
List {
ForEach(viewModel.listOfItems, id: \.self) { itemValue in
VStack(alignment: .leading, spacing: 8) {
Text(itemValue)
}
}
}
}
.navigationTitle("Second View")
.onAppear {
viewModel.updateList()
// The items are printed even though the view don't show
print("items: \(viewModel.listOfItems)")
}
}
}
We don't use view model objects in SwiftUI. For data transient to a View we use #State and #Binding to make the View data struct behave like an object.
And FYI initing an object using #ObservedObject is an error causing a memory leak, it will be discarded every time the View struct is init. When we are creating a Combine loader/fetcher object that we want to have a lifetime tied to the view we init the object using #StateObject.
Also you must not do id: \.self with ForEach for an array of value types cause it'll crash when the data changes. You have to make a struct for your data that conforms to Identifiable to be used with ForEach. Or if you really do want a static ForEach you can do ForEach(0..<5) {

How do I stop a view from refreshing each time I return from a detail view in SwiftUI?

I have a view which displays a list of posts. I have implemented infinite scrolling, and it is functioning properly. however, there is one small problem I am running into, and attempts to solve it have me going round in circles.
Main view
struct PostsHomeView: View {
#EnvironmentObject var viewModel: ViewModel
#State var dataInitiallyFetched = false
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 0) {
if self.viewModel.posts.count > 0 {
PostsListView(posts: self.viewModel.posts,
isLoading: self.viewModel.canFetchMorePosts,
onScrolledAtBottom: self.viewModel.fetchMorePosts
)
} else {
VStack {
Text("You have no posts!")
}
}
}
.onAppear() {
if !self.dataInitiallyFetched {
self.viewModel.fetchMostRecentPosts()
self.dataInitiallyFetched = true
}
}
.navigationBarTitle("Posts", displayMode: .inline)
}
}
}
List view
struct PostsListView: View {
#EnvironmentObject var viewModel: ViewModel
let posts: [Post]
let isLoading: Bool
let onScrolledAtBottom: () -> Void
var body: some View {
List {
postsList
if isLoading {
loadingIndicator
}
}
}
private var postsList: some View {
ForEach(posts, id: \.self) { post in
PostsCellView(post: post)
.onAppear {
if self.posts.last == post {
self.onScrolledAtBottom()
}
}
}
.id(UUID())
}
}
Problem
Upon tapping one of the posts in the list, I am taken to a detail view. When I tap the nav bar's back button in order go back to the posts list, the whole view is reloaded and my post fetch methods are fired again.
In order to stop the fetch method that fetches most recent posts from firing, I have added a flag that I set to true after the initial load. This stops the fetch method that grabs the initial set of posts from firing when I go back and forth between the details view and posts home screen.
I have tried various things to stop the fetchMorePosts function from firing, but I keep going in circles. I added a guard statement to the top of the fetchMorePosts function in my view model. It checks to see if string is equal to "homeview", if not, then the fetch is not done. I set this string to "detailview" whenever the detail view is visited, then I reset it back to "homeview" in the guard statement.
guard self.lastView == "homeview" else {
self.lastView = "homeview"
return
}
This works to an extent, but I keep finding scenarios where it doesn't work as expected. There must be a straight-forward way to tell SwiftUI not to reload a view. The problem is the method sits in the onAppear closure which is vital for the infinite scrolling to work. I'm not using iOS 14 yet, so I can't use #StateObject.
Is there a way to tell SwiftUI not to fire onAppear everytime I return from a detail view?
Thanks in advance
The culprit was .id(UUID()). I removed it from my list and everything worked again.
Thanks Asperi. Your help is much appreciated.

Observing Binding or State variables

I'm looking for a way of observing #State or #Binding value changes in onReceive. I can't make it work, and I suspect it's not possible, but maybe there's a way of transforming them to Publisher or something while at the same time keeping the source updating value as it's doing right now?
Below you can find some context why I need this:
I have a parent view which is supposed to display half modal based on this library: https://github.com/AndreaMiotto/PartialSheet
For this purpose, I've created a #State private var modalPresented: Bool = false and I'm using it to show and hide this modal view. This works fine, but my parent initializes this modal immediately after initializing self, so I completely loose the onAppear and onDisappear modifiers. The problem is that I need onAppear to perform some data fetching every time this modal is being presented (ideally I'd also cancel network task when modal is being dismissed).
use ObservableObject / ObservedObject instead.
an example
import SwiftUI
class Model: ObservableObject {
#Published var txt = ""
#Published var editing = false
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
TextField("Email", text: self.$model.txt, onEditingChanged: { edit in
self.model.editing = edit
}).onReceive(model.$txt) { (output) in
print("txt:", output)
}.onReceive(model.$editing) { (output) in
print("editing:", output)
}.padding().border(Color.red)
}
}

SwiftUI - ObservableObject performance issues

When a SwiftUI View binds to an ObservableObject, the view is automatically reloaded when any change occurs within the observed object - regardless of whether the change directly affects the view.
This seems to cause big performance issues for non-trivial apps. See this simple example:
// Our observed model
class User: ObservableObject {
#Published var name = "Bob"
#Published var imageResource = "IMAGE_RESOURCE"
}
// Name view
struct NameView: View {
#EnvironmentObject var user: User
var body: some View {
print("Redrawing name")
return TextField("Name", text: $user.name)
}
}
// Image view - elsewhere in the app
struct ImageView: View {
#EnvironmentObject var user: User
var body: some View {
print("Redrawing image")
return Image(user.imageResource)
}
}
Here we have two unrelated views, residing in different parts of the app. They both observe changes to a shared User supplied by the environment. NameView allows you to edit User's name via a TextField. ImageView displays the user's profile image.
The problem: With each keystroke inside NameView, all views observing this User are forced to reload their entire body content. This includes ImageView, which might involve some expensive operations - like downloading/resizing a large image.
This can easily be proven in the example above, because "Redrawing name" and "Redrawing image" are logged each time you enter a new character in the TextField.
The question: How can we improve our usage of Observable/Environment objects, to avoid unnecessary redrawing of views? Is there a better way to structure our data models?
Edit:
To better illustrate why this can be a problem, suppose ImageView does more than just display a static image. For example, it might:
Asynchronously load an image, trigged by a subview's init or onAppear method
Contain running animations
Support a drag-and-drop interface, requiring local state management
There's plenty more examples, but these are what I've encountered in my current project. In each of these cases, the view's body being recomputed results in discarded state and some expensive operations being cancelled/restarted.
Not to say this is a "bug" in SwiftUI - but if there's a better way to architect our apps, I have yet to see it mentioned by Apple or any tutorials. Most examples seem to favor liberal usage of EnvironmentObject without addressing the side effects.
Why does ImageView need the entire User object?
Answer: it doesn't.
Change it to take only what it needs:
struct ImageView: View {
var imageName: String
var body: some View {
print("Redrawing image")
return Image(imageName)
}
}
struct ContentView: View {
#EnvironmentObject var user: User
var body: some View {
VStack {
NameView()
ImageView(imageName: user.imageResource)
}
}
}
Output as I tap keyboard keys:
Redrawing name
Redrawing image
Redrawing name
Redrawing name
Redrawing name
Redrawing name
A quick solution is using debounce(for:scheduler:options:)
Use this operator when you want to wait for a pause in the delivery of events from the upstream publisher. For example, call debounce on the publisher from a text field to only receive elements when the user pauses or stops typing. When they start typing again, the debounce holds event delivery until the next pause.
I have done this little example quickly to show a way to use it.
// UserViewModel
import Foundation
import Combine
class UserViewModel: ObservableObject {
// input
#Published var temporaryUsername = ""
// output
#Published var username = ""
private var temporaryUsernamePublisher: AnyPublisher<Bool, Never> {
$temporaryUsername
.debounce(for: 0.5, scheduler: RunLoop.main)
.removeDuplicates()
.eraseToAnyPublisher()
}
init() {
temporaryUsernamePublisher
.receive(on: RunLoop.main)
.assign(to: \.username, on: self)
}
}
// View
import SwiftUI
struct ContentView: View {
#ObservedObject private var userViewModel = UserViewModel()
var body: some View {
TextField("Username", text: $userViewModel.temporaryUsername)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I hope that it helps.

Resources