ObservedObject not working when getting data async for view - swift

Using StateObject allows my view to rerender correctly after getting data asynchronously but ObservedObject does not, why?
I have two swiftui views. First is the root App view and the second is the one consuming an ObservedObject view model.
From the root view I create a ViewModel with a firebase object ID for the second view and pass it in. When the second view appears I'd like to then send a request to Firebase to retrieve some data. While the data is being retrieved I show a 'loading' screen.
When I use ObservedObject my second view never rerenders after the data is retrieved. Using StateObject though fixes the issue but I'm not sure why.
I think I understand the differences between ObservedObject and StateObject having read the docs. But not sure how the differences apply to my case since I don't think the root view should be rerendering and recreating the ViewModel I created there and passed to the second view.
There is a Login view which forces the RootView to rerender after login but from what I know, the SecondView still shouldn't be rendered more than once.
https://www.avanderlee.com/swiftui/stateobject-observedobject-differences/
RootView
#main
struct MyApp: App {
#ObservedObject private var userService = UserService()
init() {
FirebaseApp.configure()
}
var body: some Scene {
WindowGroup {
NavigationView {
Group {
if (userService.currentUser != nil) {
SecondView(secondViewModel: SecondViewModel(somethingId: "someIdGoesHere"))
} else {
LoginView()
}
}
.environmentObject(userService)
}
}
}
}
View Model
class SecondViewModel: ObservableObject {
#Published private(set) var thing: Thing? = nil
private let thingService = ThingService()
private let thingId: String
init(thingId: String) {
self.thingId = thingId
}
func load() async {
self.thing = await thingService.GetThing(thingId: thingId) // Async call to firebase
}
}
Second View
struct SecondView: View {
// Task runs to completion but 'Loading...' never goes away.
#ObservedObject var secondViewModel: SecondViewModel
var body: some View {
VStack(spacing: 5) {
if let thingText = secondViewModel.thing {
Text(thingText)
} else {
Text("Loading...")
}
}
.onAppear {
Task {
await secondViewModel.load()
}
}
}
}

Related

How can I call a function of a child view from the parent view in swiftUI to change a #state variable?

I'm trying to get into swift/swiftui but I'm really struggling with this one:
I have a MainView containing a ChildView. The ChildView has a function update to fetch the data to display from an external source and assign it to a #State data variable.
I'd like to be able to trigger update from MainView in order to update data.
I've experienced that update is in fact called, however, data is reset to the initial value upon this call.
The summary of what I have:
struct ChildView: View {
#State var data: Int = 0
var body: some View {
Text("\(data)")
Button(action: update) {
Text("update") // works as expected
}
}
func update() {
// fetch data from external source
data = 42
}
}
struct MainView: View {
var child = ChildView()
var body: some View {
VStack {
child
Button(action: {
child.update()
}) {
Text("update") // In fact calls the function, but doesn't set the data variable to the new value
}
}
}
}
When googling for a solution, I only came across people suggesting to move update and data to MainView and then pass a binding of data to ChildView.
However, following this logic I'd have to blow up MainView by adding all the data access logic in there. My point of having ChildView at all is to break up code into smaller chunks and to reuse ChildView including the data access methods in other parent views, too.
I just cannot believe there's no way of doing this in SwiftUI.
Is completely understandable to be confused at first with how to deal with state on SwiftUI, but hang on there, you will find your way soon enough.
What you want to do can be achieved in many different ways, depending on the requirements and limitations of your project.
I will mention a few options, but I'm sure there are more, and all of them have pros and cons, but hopefully one can suit your needs.
Binding
Probably the easiest would be to use a #Binding, here a good tutorial/explanation of it.
An example would be to have data declared on your MainView and pass it as a #Binding to your ChildView. When you need to change the data, you change it directly on the MainView and will be reflected on both.
This solutions leads to having the logic on both parts, probably not ideal, but is up to what you need.
Also notice how the initialiser for ChildView is directly on the body of MainView now.
Example
struct ChildView: View {
#Binding var data: Int
var body: some View {
Text("\(data)")
Button(action: update) {
Text("update") // works as expected
}
}
func update() {
// fetch data from external source
data = 42
}
}
struct MainView: View {
#State var data: Int = 0
var body: some View {
VStack {
ChildView(data: $data)
Button(action: {
data = 42
}) {
Text("update") // In fact calls the function, but doesn't set the data variable to the new value
}
}
}
}
ObservableObject
Another alternative would be to remove state and logic from your views, using an ObservableObject, here an explanation of it.
Example
class ViewModel: ObservableObject {
#Published var data: Int = 0
func update() {
// fetch data from external source
data = 42
}
}
struct ChildView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
Text("\(viewModel.data)")
Button(action: viewModel.update) {
Text("update") // works as expected
}
}
}
struct MainView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack {
ChildView(viewModel: viewModel)
Button(action: {
viewModel.update()
}) {
Text("update") // In fact calls the function, but doesn't set the data variable to the new value
}
}
}
}

SwiftUI does not show ProgressView until after task is complete

I have a swift UI view that when tapped should show a progress view:
struct ProjectItem: View {
#EnvironmentObject var controller: ProjectController
#State var showLoadingIcon: Bool = false
let document: Document
var body: some View {
VStack {
ZStack {
Text(document.name).font(Interface.Text.PopoverDialogLabel)
Text(document.editTime.toString(true)).font(.caption2).foregroundColor(.gray)
if showLoadingIcon {
ProgressView()
}
}
.padding(Interface.Sizes.StandardPadding)
.if(controller.editedDocumentID == nil) { $0.onTapGesture(count: 1, perform: {
// Open Project
showLoadingIcon = true //This occours after TransitionView
controller.openDocument(document: document)
TransitionView() //this happens before the progressView is shown
})}
}
}
When tapped it can take a couple of seconds to open the document and we would like to show a progressView to the user to display something is happening. However the progressView will only show to the user after the document has loaded.
In the view controller the openDoucment simply calls part of an app:
func openDocument(document: Document) {
app.setProject(document.id) //this takes a few seconds
}
app.setProject(document.id) is on the main thread and ideally, this will be moved to its own thread in the future but we cannot for now.
How can the progress view be displayed before the loadDocument call is made?
I have tried to wrap the following into a Task{}
controller.openDocument(document: document)
TransitionView()
I have also made the openDocument call async and sync which did not fix the issue.
I have also disabled the transitionView call and can see from my breakpoints that controller.openDocument call occurs before the
if showLoadingIcon {
ProgressView()
}
switches to showLoadingicon is switched - meaning that showLoadingIcons is checked by the app after controller.openDocument is completed and is shown.
The problem is that you are doing too much controller stuff in the view. Move the logic to control the view into the project controller.
In the controller – assuming it conforms to ObservableObject – add an enum and a state variable
enum ProjectState {
case idle, loading, loaded
}
#Published var state : ProjectState = .idle
and you have to make setProject really asynchronous to indicate when loading the data has been finished
func openDocument(document: Document) {
Task { #MainActor in
state = .loading
await app.setProject(document.id)
state = .loaded
}
}
Otherwise use a completion handler. As the code of setDocument is not part of the question you have to change it yourself.
In the view the conditional view modifier .if is very bad practice because it's not needed at all. You can disable the tap gesture much simpler with .allowsHitTesting. To show the appropriate view switch on controller.state
struct ProjectItem: View {
#EnvironmentObject var controller: ProjectController
let document: Document
var body: some View {
VStack {
ZStack {
Text(document.name).font(Interface.Text.PopoverDialogLabel)
Text(document.editTime.toString(true)).font(.caption2).foregroundColor(.gray)
switch controller.state {
case .idle: EmptyView()
case .loading: ProgressView()
case .loaded: TransitionView()
}
}
}
.padding(Interface.Sizes.StandardPadding)
.allowsHitTesting(controller.editedDocumentID == nil)
.onTapGesture {
controller.openDocument(document: document)
}
}
}

Issue with SwiftUI Segmented Picker

I am relatively new to SwiftUI and I'm trying to work on my first app. I am trying to use a segmented picker to give the user an option of changing between a DayView and a Week View. In each on of those Views, there would be specific user data that whould be shown as a graph. The issue I am having is loading the data. I posted the code below, but from what I can see, the issue comes down to the following:
When the view loads in, it starts with loading the dayView, since the selectedTimeInterval = 0. Which is fine, but then when the users presses on the "Week" in the segmented Picker, the data does not display. This due to the rest of the View loading prior to the .onChange() function from the segmented picker running. Since the .onChange is what puts the call into the viewModel to load the new data, there is no data. You can see this in the print statements if you run the code below.
I would have thought that the view load order would have been
load segmented picker
run the .onChange if the value changed
load the rest of the view
but the order actual is
load segmented picker,
load the rest of the view (graph loads with no data here!!!!!)
run the .onChange if the value has changed.
I am pretty lost so any help would be great! Thank you so much!
import SwiftUI
import OrderedCollections
class ViewModel: ObservableObject{
#Published var testDictionary: OrderedDictionary<String, Int> = ["":0]
public func daySelected() {
testDictionary = ["Day View Data": 100]
}
public func weekSelected() {
testDictionary = ["Week View Data": 200]
}
}
struct ContentView: View {
#State private var selectedTimeInterval = 0
#StateObject private var vm = ViewModel()
var body: some View {
VStack {
Picker("Selected Date", selection: $selectedTimeInterval) {
Text("Day").tag(0)
Text("Week").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
.onChange(of: selectedTimeInterval) { _ in
let _ = print("In on change")
//Logic to handle different presses of the selector
switch selectedTimeInterval {
case 0:
vm.daySelected()
case 1:
vm.weekSelected()
default:
print("Unknown Selected Case")
}
}
switch selectedTimeInterval {
case 0:
let _ = print("In view change")
Day_View()
case 1:
let _ = print("In view change")
Week_View(inputDictionary: vm.testDictionary)
default:
Text("Whoops")
}
}
}
}
struct Day_View: View {
var body: some View {
Text("Day View!")
}
}
struct Week_View: View {
#State private var inputDictionary: OrderedDictionary<String,Int>
init(inputDictionary: OrderedDictionary<String,Int>) {
self.inputDictionary = inputDictionary
}
var body: some View {
let keys = Array(inputDictionary.keys)
let values = Array(inputDictionary.values)
VStack {
Text(keys[0])
Text(String(values[0]))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
In your WeekView, change
#State private var inputDictionary: OrderedDictionary<String,Int>
to
private let inputDictionary: OrderedDictionary<String,Int>
#State is for the local state of the view. The idea is that you are initing it with initial state and from then on the view itself will change it and cause re-renders. When the WeekView is re-rendered, SwiftUI is ignoring the parameter you pass into the init and copying it from the previous WeekView to maintain state.
But, you want to keep passing in the dictionary from ContentView and cause re-renders from the parent view.
The main issue is that the initialization of the #State property wrapper is wrong.
You must use this syntax
#State private var inputDictionary: OrderedDictionary<String,Int>
init(inputDictionary: OrderedDictionary<String,Int>) {
_inputDictionary = State(wrappedValue: inputDictionary)
}
Or – if inputDictionary is not going to be modified – just declare it as (non-private) constant and remove the init method
let inputDictionary: OrderedDictionary<String,Int>

How to send an event from a parent view to a child view in SwiftUI

I'm looking for a way to trigger a function in the StateObjects of child components from their parent view.
Basically, I have a screen in my app that has multiple child view structs that each have a StateObject which performs queries relevant to that component. I'm trying to find a way so that when I pull down on the scrollview parent component, I can trigger a refetch function in the StateObject of each one of the children.
Here is the structure of what I am talking about :
struct ParentView : View {
func triggerChildRefetchFunctions() -> {
// some logic that calls ChildView1.vm.refetchData() , ChildView2.vm.refetchData() ...
}
var body : some View {
VStack {
Button { triggerChildRefetchFunctions()
ChildView1()
ChildView2()
ChildView3()
}
}
}
struct ChildView : View {
#StateObject var vm = ChildVM()
var body : some View ...
}
class ChildVM : ObservableObject {
init() { // fetch data }
func refetchData() { // refetches data }
}
I want in action in the parent view to be able to call vm.refetch() on all of its children.
Depending on the screen, there could be any number of child views each with their own state object.
All the ways I have thought of seem pretty messy. Thanks!
you could try this broad approach, where the #StateObject var vm = ChildVM() is
declared at the top level (the one source of truth), and the ChildViews all observe
the changes using the passed in ChildVM, through an environmentObject.
struct ParentView : View {
#StateObject var vm = ChildVM() // <-- here at top level
var body : some View {
VStack {
Button( action: { vm.refetchData() }) { // <-- here, all ChildViews will know about it
ChildView1()
ChildView2()
ChildView3()
}
}.environmentObject(vm) // <-- here, will pass it to all ChildViews
}
}
// similarly for ChildView2, ChildView3
struct ChildView1 : View {
#EnvironmentObject var vm: ChildVM // <-- here
var body: some View {
Text("ChildView1")
}
}
class ChildVM: ObservableObject {
init() {
// fetch data
}
func refetchData() {
// refetches data
}
}
See also: https://developer.apple.com/documentation/swiftui/stateobject

NavigationLink pushes twice, then pops once

I have a login screen on which I programmatically push to the next screen using a hidden NavigationLink tied to a state variable. The push works, but it seems to push twice and pop once, as you can see on this screen recording:
This is my view hierarchy:
App
NavigationView
LaunchView
LoginView
HomeView
App:
var body: some Scene {
WindowGroup {
NavigationView {
LaunchView()
}
.navigationBarBackButtonHidden(true)
.navigationBarHidden(true)
.environmentObject(cache)
}
}
LaunchView:
struct LaunchView: View {
#EnvironmentObject var cache: API.Cache
#State private var shouldPush = API.shared.accessToken == nil
func getUser() {
[API call to get user, if already logged in](completion: { user in
if let user = user {
// in our example, this is NOT called
// thus `cache.user.hasData` remains `false`
cache.user = user
}
shouldPush = true
}
}
private var destinationView: AnyView {
cache.user.hasData
? AnyView(HomeView())
: AnyView(LoginView())
}
var body: some View {
if API.shared.accessToken != nil {
getUser()
}
return VStack {
ActivityIndicator(style: .medium)
NavigationLink(destination: destinationView, isActive: self.$shouldPush) {
EmptyView()
}.hidden()
}
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
This is a cleaned version of my LoginView:
struct LoginView: View {
#EnvironmentObject var cache: API.Cache
#State private var shouldPushToHome = false
func login() {
[API call to get user](completion: { user in
self.cache.user = user
self.shouldPushToHome = true
})
}
var body: some View {
VStack {
ScrollView(showsIndicators: false) {
// labels
// textfields
// ...
PrimaryButton(title: "Anmelden", action: login)
NavigationLink(destination: HomeView(), isActive: self.$shouldPushToHome) {
EmptyView()
}.hidden()
}
// label
// signup button
}
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
The LoginView itself is child of a NavigationView.
The HomeView is really simple:
struct HomeView: View {
#EnvironmentObject var cache: API.Cache
var body: some View {
let user = cache.user
return Text("Hello, \(user.contactFirstname ?? "") \(user.contactLastname ?? "")!")
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
What's going wrong here?
Update:
I've realized that the issue does not occur, when I replace LaunchView() in App with LoginView() directly. Not sure how this is related...
Update 2:
As Tushar pointed out below, replacing destination: destinationView with destination: LoginView() fixes the problem – but obviously lacks required functionality.
So I played around with that and now understand what's going on:
LaunchView is rendered
LaunchView finds there's no user data yet, so pushes to LoginView
upon user interaction, LoginView pushes to HomeView
at this point, the NavigationLink inside LaunchView is called again (idk why but a breakpoint showed this), and since there is user data now, it renders the HomeView instead of the LoginView.
That's why we see only one push animation, and the LoginView becoming the HomeView w/o any push animation, b/c it's replaced, essentially.
So now the objective is preventing LaunchView's NavigationLink to re-render its destination view.
I was finally able to resolve the issue thanks to Tushar's help in the comments.
Problem
The main problem lies in the fact I didn't understand how the environment object triggers re-renders. Here's what was going on:
LaunchView has the environment object cache, which is changed in LoginView, when we set cache.user = user.
That triggers the LaunchView to re-render its body.
since the access token is not nil after login, on each re-render, the user would be fetched from the API via getUser().
disregarding the fact whether that api call yields a valid user, shouldPush is set to true
LaunchView's body is rendered again and the destinationView is computed again
since now the user does have data, the computed view becomes HomeView
This is why we see the LoginView becoming the HomeView w/o any push – it's being replaced.
at the same time, the LoginView pushes to HomeView, but since that view is already presented, it pops back to its first instance
Solution
To fix this, we need to make the property not computed, so that it only changes when we want it to. To do so, we can make it a state-managed variable and set it manually in the response of the getUser api call:
Excerpt from LaunchView:
// default value is `LoginView`, we could also
// set that in the guard statement in `getUser`
#State private var destinationView = AnyView(LoginView())
func getUser() {
// only fetch if we have an access token
guard API.shared.accessToken != nil else {
return
}
API.shared.request(User.self, for: .user(action: .get)) { user, _, _ in
cache.user = user ?? cache.user
shouldPush = true
// manually assign the destination view based on the api response
destinationView = cache.user.hasData
? AnyView(HomeView())
: AnyView(LoginView())
}
}
var body: some View {
// only fetch if user hasn't been fetched
if cache.user.hasData.not {
getUser()
}
return [all the views]
}