SwiftUI load very long list in async way - ios

I've a navigation and detail view that is sending a dictionary of date (key) and array of an struct (but it's not important the struct, it contains array of string and other stuff.
If I send a very very long dictionary, the app is freezing in the selected row and the detail appears once the List finished to load each record.
struct DetailView: View {
var selectedChat: [Date: [TextStruct]]? // you can try with [Date: [String]]?
var body: some View {
List
{
ForEach(self.selectedChat.keys.sorted(), id: \.self)
{ key in //section data
Section(header:
Text("\(self.selectedChat[key]![0].date)
{
ForEach(self.selectedChat[key]!, id:\.self) {sText in
// my ChatView(sText) ....
}
}
}
}
I've tried to load some rows at the start by adding this var
#State private var dateAndText: [Date: [TextStruct]] = [:]
substitute the code above (self.selectedChat) whit self.dateAndText and on .onAppear:
.onAppear {
if let chat = self.selectedChat {
let keysDateSorted = chat.allText.keys.sorted()
self.chatLeader = chat.chatLeader
for key in keysDateSorted.prefix(30) {
self.dateAndText[key] = chat.allText[key]
}
DispatchQueue.global(qos: .utility).async {
self.dateAndText = chat.allText
self.progressBarValue = 1
}
}
}
With this solution, once I push the row, immediately I can see the first 30 records, and it is ok, but I can't scroll until all the records are loaded. I know there is a way to load the array only if the user is scrolling at the end of the list, but I want to load all the list also if the user don't scroll at the end.
So, there is a way to load the list partially (like send and update the array each 100 records) and in async way (in order to don't freeze the display for bad user experience)?

You are almost certainly running into the issues described and fixed here by Paul Hudson.
SwiftUI is trying to animate all of the changes so if you use his hack around the issue it should work but you will lost all animations between changes of the list.
Apple devs responded to him and Dave DeLong who were discussing it on Twitter, they said that it is definitely an issue on their end that they hope to fix.
tldr of the article:
Add .id(UUID()) to the end of your List's initializer.

Related

SwiftUI 4: navigationDestination()'s destination view isn't updated when state changes

While experiments with the new NavigationStack in SwiftUI 4, I find that when state changes, the destination view returned by navigationDestination() doesn't get updated. See code below.
struct ContentView: View {
#State var data: [Int: String] = [
1: "One",
2: "Two",
3: "Three",
4: "Four"
]
var body: some View {
NavigationStack {
List {
ForEach(Array(data.keys).sorted(), id: \.self) { key in
NavigationLink("\(key)", value: key)
}
}
.navigationDestination(for: Int.self) { key in
if let value = data[key] {
VStack {
Text("This is \(value)").padding()
Button("Modify It") {
data[key] = "X"
}
}
}
}
}
}
}
Steps to reproduce the issue:
Run the code and click on the first item in the list. That would bring you to the detail view of that item.
The detail view shows the value of the item. It also has a button to modify the value. Click on that button. You'll observe that the value in the detail view doesn't change.
I debugged the issue by setting breakpoints at different place. My observations:
When I clicked the button, the code in List get executed. That's as expected.
But the closure passed to navigationDestination() doesn't get executed, which explains why the detail view doesn't get updated.
Does anyone know if this is a bug or expected behavior? If it's not a bug, how can I program to get the value in detail view updated?
BTW, if I go back to root view and click on the first item to go to its detail view again, the closure passed to navigationDestination() get executed and the detail view shows the modified value correctly.
#NoeOnJupiter's solution and #Asperi's comment are very helpful. But as you see in my comments above, there were a few details I wasn't sure about. Below is a summary of my final understanding, which hopefully clarifies the confusion.
navigationDestination() takes a closure parameter. That closure captures an immutable copy of self.
BTW, SwiftUI takes advantage of property wrapper to make it possible to "modify" an immutable value, but we won't discuss the details here.
Take my above code as an example, due to the use of #State wrapper, different versions of ContentView (that is, the self captured in the closure) share the same data value.
The key point here is I think the closure actually has access to the up-to-date data value.
When an user clicks on the "Modify it" button, the data state changes, which causes body re-evaluted. Since navigationDestination() is a function in body, it get called too. But a modifier function is just shortcut to modifier(SomeModifier()). The actual work of a Modifier is in its body. Just because a modifier function is called doesn't necessarilly means the corresponding Modifier's body gets called. The latter is a mystery (an implementation detail that Apple don't disclose and is hard to guess). See this post for example (the author is a high reputation user in Apple Developer Forum):
In my opinion, it definitely is a bug, but not sure if Apple will fix it soon.
One workaround, pass a Binding instead of a value of #State variables.
BTW, I have a hypothesis on this. Maybe this is based on a similar approach as how SwiftUI determines if it recalls a child view's body? My guess is that it might be a design, instead of a bug. For some reason (performance?) the SwiftUI team decided to cache the view returned by navigationDestination() until the NavigationStack is re-constructed. As a user I find this behavior is confusing, but it's not the only example of the inconsistent behaviors in SwiftUI.
So, unlike what I had thought, this is not an issue with closure, but one with how modifier works. Fortunately there is a well known and robust workaround, as suggested by #NoeOnJupiter and #Asperi.
Update: an alternative solution is to use EnvironmentObject to cause the placeholder view's body get re-called whenever data model changes. I ended up using this approach and it's reliable. The binding approach worked in my simple experiments but didn't work in my app (the placeholder view's body didn't get re-called when data model changed. I spent more than one day on this but unfortunately I can't find any way to debug it when binding stopped working mysteriously).
The button is correctly changing the value. By default navigationDestination does't create a Binding relation between the parent & child making the passed values immutable.
So you should create a separate struct for the child in order to achieve Bindable behavior:
struct ContentView: View {
#State var data: [Int: String] = [
1: "One",
2: "Two",
3: "Three",
4: "Four"
]
var body: some View {
NavigationStack {
List {
ForEach(Array(data.keys).sorted(), id: \.self) { key in
NavigationLink("\(key)", value: key)
}
}
.navigationDestination(for: Int.self) { key in
SubContentView(key: key, data: $data)
}
}
}
}
struct SubContentView: View {
let key: Int
#Binding var data: [Int: String]
var body: some View {
if let value = data[key] {
VStack {
Text("This is \(value)").padding()
Button("Modify It") {
data[key] = "X"
}
}
}
}
}

SwiftUI dismisses the detail view when an NSManagedObject subclass instance changes in a way that moves it to a different section of a fetch request?

The sample app is a default SwiftUI + Core Data template with two modifications. Detail for Item is a separate view where the user can change the timestamp. And sectioned fetch request is used as opposed to a regular one.
#SectionedFetchRequest(
sectionIdentifier: \.monthAndYear, sortDescriptors: [SortDescriptor(\.timestamp)]
) private var items: SectionedFetchResults<String, Item>
var body: some View {
NavigationView {
List(items) { section in
Section(header: Text(section.id)) {
ForEach(section) { item in
NavigationLink {
ItemDetail(item: item)
} label: {
Text(item.timestamp!.formatted())
}
}
}
}
}
}
struct ItemDetail: View {
#ObservedObject var item: Item
#State private var isPresentingDateEditor = false
var body: some View {
Text("Item at \(item.timestamp!.formatted())")
.toolbar {
ToolbarItem(placement: .principal) {
Button(item.timestamp!.formatted()) {
isPresentingDateEditor = true
}
}
}
.sheet(isPresented: $isPresentingDateEditor) {
if let date = Binding($item.timestamp) {
DateEditor(date: date)
}
}
}
}
The problem arises when the user changes the timestamp on an Item to a different month. The detail view dismisses behind the model view arbitrarily. However, the issue is not present if the user changes the timestamp to a day within the same month. It does not matter whether we use a sheet or a full-screen cover. When I was debugging this I noticed that any change of the NSManagedObject subclass instance that will change the section in which it is displayed will dismiss the detail view arbitrarily. I’m expecting to stay on the detail view even if I change the timestamp to a different month.
What is the root cause of this issue and how to fix it?
I think it's because the NavigationLink is no longer active because it has moved to a different section so it has a different structural identity and has defaulted back to inactive. Structural identity is explained in WWDC 2021 Demystify SwiftUI. I reported this as bug FB9977102 hopefully they fix it.
Another major problem with NavigationLink that it doesn't work when offscreen. E.g. in your sample code add lots of rows to fill up more than the screen. Scroll to top, wait one minute (so the picked updates), select first row, adjust the time to current time which will move the row to last in the list and off screen. You'll notice the NavigationLink has deactivated. People face this problem when trying to implement deep links and they can't activate a NavigationLink that is in a row that is off screen.
Update 15th Nov 2022: Apple replied to my feedback FB9977102 "this can be resolved by using the new Navigation APIs to avoid the view identity issues described." NavigationStack seems ok but NavigationSplitView has a row selection bug, see my screen-capture.

SwiftUI: Run code whenever FetchedResults changes

I'm new to Core Data and struggling to find a simple solution to the below scenario.
I have a view like this:
struct SomeView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
List {
ForEach(items) { item in
// Render item view
}
}
}
}
And I want to run an arbitrary handler whenever Items has changed. The list view updates as I expect, but I'm not sure how I should trigger non-ui code (say to send an API request every time Items changes). What is the simplest way to do this?
Searching around I'm seeing answers to similar questions point to NSFetchedResultsController and articles like this, but I haven't seen a clear example of how to implement exactly what I want. If the answer truly is NSFetchedResultsController, could someone show me complete code to get this working?
EDIT: I seem to be able to achieve what I want by adding something like let _ = handleItemsChanged(items: items) inside the list view, but this seems hacky. Any cleaner mechanisms that are equally simple?
For network calls tied to view lifetime and data we use task and the async code within is cancelled and restarted when the id param changes, it is also cancelled when view disappears. To run the task when the number of items changes, you could do:
var body: some View {
List {
ForEach(items) { item in
// Render item view
}
}
.task(id: items.count) {
print("perform async network task")
// e.g. stateHoldingResult = await something
}
}
For updated items, you could create a computed var using data from the items you'd like to compare (i.e. check for changes) and supply that as the id param.

Reusability support in List in SwiftUI

I'm currently working on a project that uses SwiftUI. I was trying to use List to display a list of let's say 10 items.
Does List supports reusability just like the UITableview?
I went through multiple posts and all of them says that List supports reusability: Does the List in SwiftUI reuse cells similar to UITableView?
But the memory map of the project says something else. All the Views in the List are created at once and not reused.
Edit
Here is how I created the List:
List {
Section(header: TableHeader()) {
ForEach(0..<10) {_ in
TableRow()
}
}
}
TableHeader and TableRow are the custom views created.
List actually provides same technique as UITableView for reusable identifier. Your code make it like a scroll view.
The proper way to do is providing items as iterating data.
struct Item: Identifiable {
var id = UUID().uuidString
var name: String
}
#State private var items = (1...1000).map { Item(name: "Item \($0)") }
...
List(items) {
Text($0.name)
}
View hierarchy debugger shows only 17 rows in memory

SwiftUI: NavigationLink pops immediately if used within ForEach

I'm using a NavigationLink inside of a ForEach in a List to build a basic list of buttons each leading to a separate detail screen.
When I tap on any of the list cells, it transitions to the detail view of that cell but then immediately pops back to the main menu screen.
Not using the ForEach helps to avoid this behavior, but not desired.
Here is the relevant code:
struct MainMenuView: View {
...
private let menuItems: [MainMenuItem] = [
MainMenuItem(type: .type1),
MainMenuItem(type: .type2),
MainMenuItem(type: .typeN),
]
var body: some View {
List {
ForEach(menuItems) { item in
NavigationLink(destination: self.destination(item.destination)) {
MainMenuCell(menuItem: item)
}
}
}
}
// Constructs destination views for the navigation link
private func destination(_ destination: ScreenDestination) -> AnyView {
switch destination {
case .type1:
return factory.makeType1Screen()
case .type2:
return factory.makeType2Screen()
case .typeN:
return factory.makeTypeNScreen()
}
}
If you have a #State, #Binding or #ObservedObject in MainMenuView, the body itself is regenerated (menuItems get computed again) which causes the NavigationLink to invalidate (actually the id change does that). So you must not modify the menuItems arrays id-s from the detail view.
If they are generated every time consider setting a constant id or store in a non modifying part, like in a viewmodel.
Maybe I found the reason of this bug...
if you use iOS 15 (not found iOS 14),
and you write the code NavigationLink to go to same View in different locations in your projects, then this bug appear.
So I simply made another View that has different destination View name but the same contents... then it works..
you can try....
sorry for my poor English...

Resources