SwiftUI does not show ProgressView until after task is complete - swift

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

Related

ObservedObject not working when getting data async for view

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

ObservableObject loading all items in ForEach with same data

I have a LazyVStack ForEach looping through metadata passed into the view. Each item has it's own button displayed in the loop.
On button tap, it should load additional API data and display it within the ForEach item via RichText views.
Right now on button tap, it's loading and displaying the same additional data for all items in the loop.
Am I missing something about creating unique, dynamic items in the loop?
struct PostDetail: View {
#State var postDetail: Post
#StateObject var chapterLoader = ChapterLoaderApi()
var body: some View {
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(postDetail.chapters) { item in
Button(action: {
Task {
await chapterLoader.fetchChapter(currentItem: item)
}
}, label: {
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.blue)
})
switch chapterLoader.state {
case .idle: EmptyView()
case .loading: ProgressView()
case .loaded(let html):
RichText(html: html.renderedContent)
.lineHeight(170)
.imageRadius(6)
.fontType(.system)
case .failed(let error):
Text(error.localizedDescription)
}
}
}
}
}
}
#MainActor class ChapterLoaderApi: ObservableObject {
enum LoadingState {
case idle
case loading
case loaded(Chapter)
case failed(Error)
}
#Published var state: LoadingState = .idle
func fetchChapter(currentItem item: ChapterMeta) async {
self.state = .loading
do {
let response = try await getChapter(id: item.id)
self.state = .loaded(response)
} catch {
state = .failed(error)
}
}
}
You're using the same instance of chapterLoader to load details for any item in the list and in turn the ChapterLoader class can only communicate the state of a last requested item.
Here's what happens:
You press a button of a certain list item.
The button press triggers chapterLoader.fetchChapter for this item.
ChapterLoader mutates its state upon loading the data.
Because state is #Published, SwiftUI understands that it's time to re-render the view entirely.
SwiftUI re-renders each row in a list. For each row, it checks for chapterLoader.state which contains the data for just one item that was requested. SwiftUI displays this data for the item in a RichText.
The simplest solution would be to make ChapterLoader return a state for a particular item, not just a state of a last requested item, that will solve your issue. There are other ways of course and I hope that having understood what's happening you'll be able to figure out the best way to fix it.

Update View on Realm Object Change

I am updating an object in my Realm Database but I am having a hard time getting it to reload any of my views as a result. R.realm is a wrapper around realm. Both it and ActiveManager are working correctly as I can look at the results in Realm Studio and see them updating. How can I trigger a refresh when returning to the Items screen after toggling active on the Item screen? Is there anyway of just adding some kind of observer to the App entry point so that whenever the Realm database is changed for any reason it invalidates all the views and causes a refresh everywhere? I'm coming from a typescript/react background so I'm having a hard time wrapping my head around the way swift is handling all this. Code below, I've truncated irrelevant parts for brevity
ManageView
struct ManageView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: ItemsView(kind: ITEM_KIND.Area)) {
Text("Areas")
}
NavigationLink(destination: ItemsView(
kind: ITEM_KIND.Scheme
)) {
Text("Schemes")
}
ItemsView
struct ItemsView: View {
#ObservedResults(Active.self) var active
#State var showAddItemModal: Bool = false
var kind: ITEM_KIND
var body: some View {
VStack {
List {
Section("Current") {
ForEach(getCurrent(), id: \._id) { item in
VStack {
NavigationLink(destination: ItemView(item: item)) {
Text("\(item.title)")
}
}
}
...
func getCurrent() -> Results<Item> {
let currentPeriod = R.realm.getByKey(Period.self, key: ActiveManager.shared.getPeriod())!
return R.realm.getWhere(Item.self) { item in item.kind == self.kind && item._id.in(currentPeriod.items) }
}
ItemView
struct ItemView: View {
#ObservedRealmObject var item: Item
#State var isActive: Bool = false
func viewWillAppear() {
print("appear")
isActive = ActiveManager.shared.getItems().contains(item._id)
}
var body: some View {
ScrollView {
VStack {
ZStack {
Toggle("Active", isOn: $isActive)
.padding(.horizontal)
.onChange(of: isActive) { value in
if value {
ActiveManager.shared.addItem(item: item)
} else {
ActiveManager.shared.removeAllItems()
}
}
Will come back and post a more thorough solution at a later date but I couldn't get Realm's property wrappers to work so in the top Manage View I created some observable objects that contained the item arrays wrapped with the #Published wrapper and then some functions for updating the arrays. I then passed these observable objects down into Items View and again into Item view. Whenever I changed my item I then used one of the update array functions on the observable object to trigger a state refresh.
item.realm != nil
is a solution wich work fine for me, but it throws a warning. I call it after dismissing a sheet.

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

Content appears from a split second then disappears all together

I'm downloading data from FireStore. Data is retrieved perfectly. I have the data and can print information. The issue is, when I tap on a text/label to push to the intended view, I perform the function using the .onAppear function. My variables, from my ObservableClass are #Published. I have the data and can even set elements based on the data retrieved. I'm using the MVVM approach and have done this a plethora of times throughout my project. However, this is the first time I have this particular issue. I've even used functions that are working in other views completely fine, yet in this particular view this problem persists. When I load/push this view, the data is shown for a split second and then the view/canvas is blank. Unless the elements are static i.e. Text("Hello World") the elements will disappear. I can't understand why the data just decides to disappear.
This is my code:
struct ProfileFollowingView: View {
#ObservedObject var profileViewModel = ProfileViewModel()
var user: UserModel
func loadFollowing() {
self.profileViewModel.loadCurrentUserFollowing(userID: self.user.uid)
}
var body: some View {
ZStack {
Color(SYSTEM_BACKGROUND_COLOUR)
.edgesIgnoringSafeArea(.all)
VStack(alignment: .leading) {
if !self.profileViewModel.isLoadingFollowing {
ForEach(self.profileViewModel.following, id: \.uid) { user in
VStack {
Text(user.username).foregroundColor(.red)
}
}
}
}
} .onAppear(perform: {
self.profileViewModel.loadCurrentUserFollowing(userID: self.user.uid)
})
}
}
This is my loadFollowers function:
func loadCurrentUserFollowing(userID: String) {
isLoadingFollowing = true
API.User.loadUserFollowing(userID: userID) { (user) in
self.following = user
self.isLoadingFollowing = false
}
}
I've looked at my code that retrieves the data, and it's exactly like other features/functions I already have. It's just happens on this view.
Change #ObservedObject to #StateObject
Update:
ObservedObject easily gets destroyed/recreated whenever a view is re-created, while StateObject stays/exists even when a view is re-created.
For more info watch this video:
https://www.youtube.com/watch?v=VLUhZbz4arg
It looks like API.User.loadUserFollowing(userID:) may be asynchronous - may run in the background. You need to update all #Published variables on the main thread:
func loadCurrentUserFollowing(userID: String) {
isLoadingFollowing = true
API.User.loadUserFollowing(userID: userID) { (user) in
DispatchQueue.main.async {
self.following = user
self.isLoadingFollowing = false
}
}
}
You might need to add like this: "DispatchQueue.main.asyncAfter"
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.profileViewModel.loadCurrentUserFollowing(userID: self.user.uid)
}
}