ObservableObject loading all items in ForEach with same data - swift

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.

Related

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

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.

What is the best way to show data from 2 different network requests using swift?

I currently have two network requests, one of which needs input from the other to start to fetch for data. What is the best way to fetch and show data (in this case the data is an array for each network request) from both network requests in the same list.
Here is my code:
VStack {
List(viewModel.work, id: \.id) { work in
VStack {
Text("(work.name)")
}.onAppear {
fetchMoneyAmount(workType: work.type)
}
ForEach(viewModel.moneyAmount, id: \.id) { moneyAmt in
Text("maxMoney: (moneyAmt.maxAmount)")
}
}
}
.onAppear {
fetchWork()
}
My problem right now is the network request (fetchMoneyAmount) seems to be fetching the data as each list item is displayed from the viewModel.work array. Each work list tile has the same MoneyAmount value which should not be the case. There should be different MoneyAmounts for each work tile.
I am not really sure how else to make the fetchMoneyAmount network request because it needs the the value generated from the first network request.
Any suggestions would be greatly appreciated.
Each 'work' item has one 'moneyAmount' item in the JSON. I need to get the moneyAmount from each of the work items and then display each of the moneyAmounts for the corresponding 'work' item.
I would suggest you to simply create a final class DataProvider: ObservableObject class which executes both requests dependent on each oter. For this you have several options:
Combine to chain multiple requests.
Just nest both requests if they depend on each other.
In your SwiftUI view you declare the following property #ObservedObject var dataProvider = DataProvider().
Now whenever your data provider publishes new data, your view gets redrawn automatically.
Sample Code
It shows you how to link a data provider observble object to SwiftUI code displaying the result. Think about it like this:
"Whenever your view is re-drawn assume your data to be just ready to be consumed"
With this in mind you just need to code your view as if your data is already available in a local property. But the property is served by the data provider which tells the view when to update.
DataProvider
With a few more lines you can extend the DataProvider to load a second request when the first one is finished.
class DataProvider: ObservableObject {
#Published private(set) var emojis: [Emoji] = []
private var cancellable: Any?
private var urlSession = URLSession.shared
func readEmojiData(url: URL) {
cancellable = urlSession
.dataTaskPublisher(for: url)
.map { self.emojisFromRawData($0.data) }
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
.assign(to: \.emojis, on: self)
}
}
View
import SwiftUI
struct EmojiGrid : View {
#ObservedObject var dataProvider = DataProvider()
#ScaledMetric(relativeTo: .largeTitle) var spacing: CGFloat = 12
#ScaledMetric(relativeTo: .largeTitle) var size: CGFloat = 50
private var columns: [GridItem] {
[GridItem(.adaptive(minimum: size))]
}
private var versions: [Emoji.Version] {
Emoji.Version.allCases.map { $0 }.sorted().reversed()
}
init() {
dataProvider.readEmojiData(url: URL("https://..."))
}
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: columns, spacing: spacing, content: {
ForEach(dataProvider.emojis, id: \.id) { emoji in
Button(emoji.emoji, action: { print(emoji) })
.font(.largeTitle)
.frame(width: size, height: size, alignment: .center)
}
})
.padding([.trailing, .leading], spacing)
}
}
}
}

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

In SwiftUI, where are the control events, i.e. scrollViewDidScroll to detect the bottom of list data

In SwiftUI, does anyone know where are the control events such as scrollViewDidScroll to detect when a user reaches the bottom of a list causing an event to retrieve additional chunks of data? Or is there a new way to do this?
Seems like UIRefreshControl() is not there either...
Plenty of features are missing from SwiftUI - it doesn't seem to be possible at the moment.
But here's a workaround.
TL;DR skip directly at the bottom of the answer
An interesting finding whilst doing some comparisons between ScrollView and List:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(1...100) { item in
Text("\(item)")
}
Rectangle()
.onAppear { print("Reached end of scroll view") }
}
}
}
I appended a Rectangle at the end of 100 Text items inside a ScrollView, with a print in onDidAppear.
It fired when the ScrollView appeared, even if it showed the first 20 items.
All views inside a Scrollview are rendered immediately, even if they are offscreen.
I tried the same with List, and the behaviour is different.
struct ContentView: View {
var body: some View {
List {
ForEach(1...100) { item in
Text("\(item)")
}
Rectangle()
.onAppear { print("Reached end of scroll view") }
}
}
}
The print gets executed only when the bottom of the List is reached!
So this is a temporary solution, until SwiftUI API gets better.
Use a List and place a "fake" view at the end of it, and put fetching logic inside onAppear { }
You can to check that the latest element is appeared inside onAppear.
struct ContentView: View {
#State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if let last == self.items.last {
print("last item")
self.items += last+1...last+30
}
}
}
}
}
}
In case you need more precise info on how for the scrollView or list has been scrolled, you could use the following extension as a workaround:
extension View {
func onFrameChange(_ frameHandler: #escaping (CGRect)->(),
enabled isEnabled: Bool = true) -> some View {
guard isEnabled else { return AnyView(self) }
return AnyView(self.background(GeometryReader { (geometry: GeometryProxy) in
Color.clear.beforeReturn {
frameHandler(geometry.frame(in: .global))
}
}))
}
private func beforeReturn(_ onBeforeReturn: ()->()) -> Self {
onBeforeReturn()
return self
}
}
The way you can leverage the changed frame like this:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(0..<100) { number in
Text("\(number)").onFrameChange({ (frame) in
print("Origin is now \(frame.origin)")
}, enabled: number == 0)
}
}
}
}
The onFrameChange closure will be called while scrolling. Using a different color than clear might result in better performance.
edit: I've improved the code a little bit by getting the frame outside of the beforeReturn closure. This helps in the cases where the geometryProxy is not available within that closure.
I tried the answer for this question and was getting the error Pattern matching in a condition requires the 'case' keyword like #C.Aglar .
I changed the code to check if the item that appears is the last of the list, it'll print/execute the clause. This condition will be true once you scroll and reach the last element of the list.
struct ContentView: View {
#State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if item == self.items.last {
print("last item")
fetchStuff()
}
}
}
}
}
}
The OnAppear workaround works fine on a LazyVStack nested inside of a ScrollView, e.g.:
ScrollView {
LazyVStack (alignment: .leading) {
TextField("comida", text: $controller.searchedText)
switch controller.dataStatus {
case DataRequestStatus.notYetRequested:
typeSomethingView
case DataRequestStatus.done:
bunchOfItems
case DataRequestStatus.waiting:
loadingView
case DataRequestStatus.error:
errorView
}
bottomInvisibleView
.onAppear {
controller.loadNextPage()
}
}
.padding()
}
The LazyVStack is, well, lazy, and so only create the bottom when it's almost on the screen
I've extracted the LazyVStack plus invisible view in a view modifier for ScrollView that can be used like:
ScrollView {
Text("Some long long text")
}.onScrolledToBottom {
...
}
The implementation:
extension ScrollView {
func onScrolledToBottom(perform action: #escaping() -> Void) -> some View {
return ScrollView<LazyVStack> {
LazyVStack {
self.content
Rectangle().size(.zero).onAppear {
action()
}
}
}
}
}