I'm encountering a strange behaviour with List when using section and either task or onAppear.
Let's say I have a list with sections and rows in each section. When I put a task to run a async task when each row appears, it doesn't call it even though it's displayed on screen. The same problem applies when using onAppear instead of task.
It's easily reproducible with the given example. You can just run and scroll down to the bottom. You'll notice that the last task isn't called despite the row and the section is on screen.
struct ContentView: View {
private var dataSource: [Int: [String]] = (0..<30).reduce([Int: [String]]()) { result, key in
var result = result
let items = (0..<4).map { "Item \($0)" }
result[key] = items
return result
}
var body: some View {
List {
ForEach(Array(dataSource.keys), id: \.self) { section in
let rows = dataSource[section]
Section {
ForEach(rows ?? [], id: \.self) { row in
Text("\(row)")
}
.task {
print("TASK \(section)")
}
} header: {
Text("Section \(section)")
}
}
}
}
}
Does anyone has an explanation ? Am I missing something ?
I managed to fix this problem by using a ScrollView which embeds a LazyVStack, but by doing so I'm loosing some of the features from List, such as swipe to delete.
.task is when the underlying UIView appears, which in the case of List is a UICollectionViewCell and those only appear once and are reused when scrolling so have already appeared so .task won't run again.
Btw ForEach is not a for loop.
I was found this solution on this way. May be help for you.
private var dataSource: [Int: [String]] = (0..<30).reduce([Int: [String]]()) { result, key in
var result = result
let items = (0..<4).map { "Item \($0)" }
result[key] = items
return result
}
var body: some View {
let arrData = dataSource.keys.sorted()
List {
ForEach(Array(arrData), id: \.self) { section in
let rows = dataSource[section]
Section {
ForEach(rows ?? [], id: \.self) { row in
Text("\(row)")
}
.task {
print("TASK \(section)")
}
} header: {
Text("Section \(section)")
}
}
}
}
}
Related
I have a LazyVStack which I would like to only update one view and not have all others on screen reload. With more complex cells this causes a big performance hit. I have included sample code
import SwiftUI
struct ContentView: View {
#State var items = [String]()
var body: some View {
ScrollView {
LazyVStack {
ForEach(self.items, id: \.self) { item in
Button {
if let index = self.items.firstIndex(where: {$0 == item}) {
self.items[index] = "changed \(index)"
}
} label: {
cell(text: item)
}
}
}
}
.onAppear {
for _ in 0...200 {
self.items.append(NSUUID().uuidString)
}
}
}
}
struct cell: View {
let text: String
init(text: String) {
self.text = text
print("init cell", text)
}
var body: some View {
Text(text)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
As you can see even when only changing 1 cell the init gets called for every cell. Is there anyway to avoid this?
Here is a working code, there is some points to mention, View in SwiftUI would get initialized here and there or anytime SwiftUI thinks it needed! But the body of View would get computed if really some value in body changed. It is planed to work like that, there is some exceptions as well. Like body get computed even the values that used in the body were as before with no change, I do not want inter to that topic! But in your example and in your issue, we want SwiftUI renders only the changed View, for this goal the down code works well without issue, but as you can see I used VStack, if we change VStack to LazyVStack, SwiftUI would renders some extra view due its undercover codes, and if you scroll to down and then to up, it would forget all rendered view and data in memory and it will try to render the old rendered views, so it is the nature of LazyVStack, we cannot do much about it. Apple want LazyVStack be Lazy. But you can see that LazyVStack would not render all views, but some of them that needs to works. we cannot say or know how much views get rendered in Lazy way, but for sure not all of them.
let initializingArray: () -> [String] = {
var items: [String] = [String]()
for _ in 0...200 { items.append(UUID().uuidString) }
return items
}
struct ContentView: View {
#State var items: [String] = initializingArray()
var body: some View {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Button(action: {
if let index = self.items.firstIndex(where: { $0 == item }) {
items[index] = "changed \(index)"
}
}, label: {
ItemView(item: item)
})
}
}
}
}
}
struct ItemView: View {
let item: String
var body: some View {
print("Rendering row for:", item)
return Text(item)
}
}
i am a newbie in IOS development, so I wanted to do something like when the user scroll and reaches the bottom of the list, the list updates and append it data. basically an infinite scrolling.
But I have no idea other than setting it via engineering the offset, it would take a lot of time and effort + might not be that clean/brilliant solution. Are there any other solution other than using a 3rd person library?
Thank you
here's my code :
ForEach(homeContent.MainContent){data in
homeContentItem(name: data.name ?? "", image: data.image ?? "", released: data.released ?? "")
.padding(.horizontal)
.onAppear{
homeContent.appendData(currentItem: data)
}
}
What this code does is well infinite scrolling, but it'll always update and that is something that I don't want to, I want it when user scroll to the bottom first in order to update the list
Note : if there any similar question, please do comment it so that I could delete this one I guess
You were close with onAppear, you only missing checking index of item. You need to append new data only when last element is visible, not for anyone. I suggest you using ForEachIndexed:
struct ContentView: View {
#State
var items = (0...100).map { _ in Item() }
var body: some View {
ScrollView {
LazyVStack {
ForEachIndexed(items) { i, item in
Text("\(i) \(item.id)")
.onAppear {
if i == items.indices.last {
items += (0...10).map { _ in Item() }
print("new items added", i, items.count)
}
}
}
}
}
}
}
struct ForEachIndexed<Data: RandomAccessCollection, Content: View, ID: Hashable>: View {
let data: [EnumeratedSequence<Data>.Element]
let content: (Int, Data.Element) -> Content
let id: KeyPath<EnumeratedSequence<Data>.Element, ID>
init(_ data: Data, #ViewBuilder content: #escaping (Int, Data.Element) -> Content) where Data.Element: Identifiable, ID == Data.Element.ID {
self.data = Array(data.enumerated())
self.content = content
self.id = \.element.id
}
var body: some View {
ForEach(data, id: id) { element in
content(element.offset, element.element)
}
}
}
struct Item: Identifiable {
let id: String
init() {
id = UUID().uuidString
}
}
I have a SwiftUI List like in the example code below.
struct ContentView: View {
#State var numbers = ["1", "2", "3"]
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(numbers, id: \.self) { number in
Text(number)
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
}
When I enter edit mode and move the item one position up the strange animation happens after I drop the item (see the gif below). It looks like the dragged item comes back to its original position and then moves to the destination again (with animation)
What's interesting it doesn't happen if you drag the item down the list or more than one position up.
I guess it's because the List performs animation when the items in the state get reordered even though they were already reordered on the view side by drag and drop. But apparently it handles it well in all the cases other than moving item one position up.
Any ideas on how to solve this issue? Or maybe it's a known bug?
I'm using XCode 11.4.1 and the build target is iOS 13.4
(Please also note that in the "real world" app I'm using Core Data and when moving items their order is updated in the DB and then the state is updated, but the problem with the animation looks exactly the same.)
Here is solution (tested with Xcode 11.4 / iOS 13.4)
var body: some View {
NavigationView {
List {
ForEach(numbers, id: \.self) { number in
HStack {
Text(number)
}.id(UUID()) // << here !!
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
Here's a solution based on Mateusz K's comment in the accepted answer. I combined the hashing of order and number. I'm using a complex object in place of number which gets dynamically updated. This way ensures the list item refreshes if the underlying object changes.
class HashNumber : Hashable{
var order : Int
var number : String
init(_ order: Int, _ number:String){
self.order = order
self.number = number
}
static func == (lhs: HashNumber, rhs: HashNumber) -> Bool {
return lhs.number == rhs.number && lhs.order == rhs.order
}
//
func hash(into hasher: inout Hasher) {
hasher.combine(number)
hasher.combine(order)
}
}
func createHashList(_ input : [String]) -> [HashNumber]{
var r : [HashNumber] = []
var order = 0
for i in input{
let h = HashNumber(order, i)
r.append(h)
order += 1
}
return r
}
struct ContentView: View {
#State var numbers = ["1", "2", "3"]
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(createHashList(numbers), id: \.self) { number in
Text(number.number)
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
}
I know that this wasn't what caused issues for the Author of this Question, but I identified what caused the animation glitch for my list.
I was setting the id of each View produced by the ForEach View within my List View, to one that would get assigned to another one of those Views after dragging and dropping a row to re-order it, as the id's were set based on a common substring, paired with the index with which the given ForEach-iteration's product corresponded.
Here's a simple code snippet to demonstrate the mistake I (but not the Author of this Question) made:
struct ContentView: View {
private let shoppingListName: String
#FetchRequest
private var products: FetchedResults<Product>
init(shoppingList: ShoppingList) {
self.shoppingListName = shoppingList.name?.capitalized ?? "Unknown"
self._products = FetchRequest(
sortDescriptors: [
.init(keyPath: \Product.orderNumber, ascending: true)
],
predicate: .init(format: "shoppingList == %#", shoppingList.objectID)
)
var body: some View {
NavigationView {
List {
ForEach(Array(products.enumerated()), id: \element.objectID) { index, product in
Text(product.name ?? "Unknown")
.id("product-\(index)") // Problematic
}
.onMove {
var products = Array(products)
products.move(fromOffsets: $0, toOffset: $1)
for (index, product) in products.enumerated() {
product.orderNumber = Int64(index)
}
}
}
.toolbar {
ToolbarItem {
EditButton()
}
}
.navigationBarTitle("\(shoppingListName) Shopping List")
}
}
}
If, when running the above code, I were to reorder item at index 2 (i.e. row with id "product-2") to index 0, then the row I'd reordered would start having the id which the row that was previously at index 0 had. And the row that was previously at index 0 would start having the id of the row directly below it, and so on and so forth.
This re-assignment of existing id's to other Views within the same list in response to a row being reordered within that list, would confuse SwiftUI, and cause there to be an animation glitch whilst the row being reordered moved into its correct new position after having been dropped.
P.S. I recommend that readers who investigate their code to see if they've made this same mistake, do the following:
Check to see if you're setting the id's of the "row" Views based on any value that can get "shifted around" amongst the rows within the list, in response to a row being reordered. Such a value could be an index, but it could also be something else, such as the value of an orderNumber property that you store in the NSManagedObject-Subclass instances over which you're looping in the ForEach View.
Also, check to see if you're calling any custom View methods on the "row" Views, and if so, investigate to see whether or not any of those custom View methods are setting id's for the View's on which they're being called. <-- This was the case in my real code, which made my mistake a bit harder for me to spot :P!
I have same problem. I do not know bug or not, but I found workaround solution.
class Number: ObservableObject, Identifiable {
var id = UUID()
#Published var number: String
init(_ number: String) {
self.number = number
}
}
class ObservedNumbers: ObservableObject {
#Published var numbers = [ Number("1"), Number("2"), Number("3") ]
func onMove(fromOffsets: IndexSet, toOffset: Int) {
var newNumbers = numbers.map { $0.number }
newNumbers.move(fromOffsets: fromOffsets, toOffset: toOffset)
for (newNumber, number) in zip(newNumbers, numbers) {
number.number = newNumber
}
self.objectWillChange.send()
}
}
struct ContentView: View {
#ObservedObject var observedNumbers = ObservedNumbers()
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(observedNumbers.numbers) { number in
Text(number.number)
}
.onMove(perform: observedNumbers.onMove)
}
.navigationBarItems(trailing: EditButton())
}
}
}
In CoreData case I just use NSFetchedResultsController. Implementation of ObservedNumbers.onMove() method looks like:
guard var hosts = frc.fetchedObjects else {
return
}
hosts.move(fromOffsets: set, toOffset: to)
for (order, host) in hosts.enumerated() {
host.orderPosition = Int32(order)
}
try? viewContext.save()
And in delegate:
internal func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any,
at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)
{
switch type {
case .delete:
hosts[indexPath!.row].stopTrack()
hosts.remove(at: indexPath!.row)
case .insert:
let hostViewModel = HostViewModel(host: frc.object(at: newIndexPath!))
hosts.insert(hostViewModel, at: newIndexPath!.row)
hostViewModel.startTrack()
case .update:
hosts[indexPath!.row].update(host: frc.object(at: indexPath!))
case .move:
hosts[indexPath!.row].update(host: frc.object(at: indexPath!))
hosts[newIndexPath!.row].update(host: frc.object(at: newIndexPath!))
default:
return
}
}
In my case (again, cannot explain why but it could maybe help someone)
I changed the ForEach(self.houses, id: \.id) { house in ...
into
ForEach(self.houses.indices, id: \.self) { i in
Text(self.houses[i].name)
.id(self.houses[i].id)
}
I've implemented a List with a search bar in SwiftUI. Now I want to implement paging for this list. When the user scrolls to the bottom of the list, new elements should be loaded. My problem is, how can I detect that the user scrolled to the end? When this happens I want to load new elements, append them and show them to the user.
My code looks like this:
import Foundation
import SwiftUI
struct MyList: View {
#EnvironmentObject var webService: GetRequestsWebService
#ObservedObject var viewModelMyList: MyListViewModel
#State private var query = ""
var body: some View {
let binding = Binding<String>(
get: { self.query },
set: { self.query = $0; self.textFieldChanged($0) }
)
return NavigationView {
// how to detect here when end of the list is reached by scrolling?
List {
// searchbar here inside the list element
TextField("Search...", text: binding) {
self.fetchResults()
}
ForEach(viewModelMyList.items, id: \.id) { item in
MyRow(itemToProcess: item)
}
}
.navigationBarTitle("Title")
}.onAppear(perform: fetchResults)
}
private func textFieldChanged(_ text: String) {
text.isEmpty ? viewModelMyList.fetchResultsThrottelt(for: nil) : viewModelMyList.fetchResultsThrottelt(for: text)
}
private func fetchResults() {
query.isEmpty ? viewModelMyList.fetchResults(for: nil) : viewModelMyList.fetchResults(for: query)
}
}
Also a little bit special this case, because the list contains the search bar. I would be thankful for any advice because with this :).
As you have already a List with an artificial row for the search bar, you can simply add another view to the list which will trigger another fetch when it appears on screen (using onAppear() as suggested by Josh). By doing this you do not have to do any "complicated" calculations to know whether a row is the last row... the artificial row is always the last one!
I already used this in one of my projects and I've never seen this element on the screen, as the loading was triggered so quickly before it appeared on the screen. (You surely can use a transparent/invisible element, or perhaps even use a spinner ;-))
List {
TextField("Search...", text: binding) {
/* ... */
}
ForEach(viewModelMyList.items, id: \.id) { item in
// ...
}
if self.viewModelMyList.hasMoreRows {
Text("Fetching more...")
.onAppear(perform: {
self.viewModelMyList.fetchMore()
})
}
}
Add a .onAppear() to the MyRow and have it call the viewModel with the item that just appears. You can then check if its equal to the last item in the list or if its n items away from the end of the list and trigger your pagination.
This one worked for me:
You can add pagination with two different approaches to your List: Last item approach and Threshold item approach.
That's way this package adds two functions to RandomAccessCollection:
isLastItem
Use this function to check if the item in the current List item iteration is the last item of your collection.
isThresholdItem
With this function you can find out if the item of the current List item iteration is the item at your defined threshold. Pass an offset (distance to the last item) to the function so the threshold item can be determined.
import SwiftUI
extension RandomAccessCollection where Self.Element: Identifiable {
public func isLastItem<Item: Identifiable>(_ item: Item) -> Bool {
guard !isEmpty else {
return false
}
guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
return false
}
let distance = self.distance(from: itemIndex, to: endIndex)
return distance == 1
}
public func isThresholdItem<Item: Identifiable>(
offset: Int,
item: Item
) -> Bool {
guard !isEmpty else {
return false
}
guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
return false
}
let distance = self.distance(from: itemIndex, to: endIndex)
let offset = offset < count ? offset : count - 1
return offset == (distance - 1)
}
}
Examples
Last item approach:
struct ListPaginationExampleView: View {
#State private var items: [String] = Array(0...24).map { "Item \($0)" }
#State private var isLoading: Bool = false
#State private var page: Int = 0
private let pageSize: Int = 25
var body: some View {
NavigationView {
List(items) { item in
VStack(alignment: .leading) {
Text(item)
if self.isLoading && self.items.isLastItem(item) {
Divider()
Text("Loading ...")
.padding(.vertical)
}
}.onAppear {
self.listItemAppears(item)
}
}
.navigationBarTitle("List of items")
.navigationBarItems(trailing: Text("Page index: \(page)"))
}
}
}
extension ListPaginationExampleView {
private func listItemAppears<Item: Identifiable>(_ item: Item) {
if items.isLastItem(item) {
isLoading = true
/*
Simulated async behaviour:
Creates items for the next page and
appends them to the list after a short delay
*/
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
self.page += 1
let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
self.items.append(contentsOf: moreItems)
self.isLoading = false
}
}
}
}
Threshold item approach:
struct ListPaginationThresholdExampleView: View {
#State private var items: [String] = Array(0...24).map { "Item \($0)" }
#State private var isLoading: Bool = false
#State private var page: Int = 0
private let pageSize: Int = 25
private let offset: Int = 10
var body: some View {
NavigationView {
List(items) { item in
VStack(alignment: .leading) {
Text(item)
if self.isLoading && self.items.isLastItem(item) {
Divider()
Text("Loading ...")
.padding(.vertical)
}
}.onAppear {
self.listItemAppears(item)
}
}
.navigationBarTitle("List of items")
.navigationBarItems(trailing: Text("Page index: \(page)"))
}
}
}
extension ListPaginationThresholdExampleView {
private func listItemAppears<Item: Identifiable>(_ item: Item) {
if items.isThresholdItem(offset: offset,
item: item) {
isLoading = true
/*
Simulated async behaviour:
Creates items for the next page and
appends them to the list after a short delay
*/
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
self.page += 1
let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
self.items.append(contentsOf: moreItems)
self.isLoading = false
}
}
}
}
String Extension:
/*
If you want to display an array of strings
in the List view you have to specify a key path,
so each string can be uniquely identified.
With this extension you don't have to do that anymore.
*/
extension String: Identifiable {
public var id: String {
return self
}
}
Christian Elies, code reference
struct MasterView: View {
#ObservedObject var store: ModelStore
var body: some View {
List {
Section(header: Text("Black Items")) {
ForEach(store.bag.blackItems, id: \.self) { item in
BlackItemView(model: item)
}
}
Section(header: Text("Blue Items")) {
ForEach(store.bag.blueItems, id: \.self) { item in
BlueItemView(model: item)
}
}
}.navigationBarItems(trailing: Button(action: { self.store.swapItems() }, label: {
Text("Swap items")
}))
}
}
Here is the implementation of swapItems
func swapItems() {
var bagCopy = bag
bagCopy.blackItems = bag.blueItems
bagCopy.blueItems = bag.blackItems
self.bag = bagCopy
}
struct Item: Hashable, Identifiable {
var id: String
}
struct Bag {
var blackItems: [Item] = [Item(id: "item1")]
var blueItems: [Item] = [Item(id: "item2")]
}
class ModelStore: ObservableObject {
var objectWillChange = PassthroughSubject<Void, Never>()
var bag: Bag = Bag() {
didSet {
objectWillChange.send()
}
}
func swapItems() {
var bagCopy = bag
bagCopy.blackItems = bag.blueItems
bagCopy.blueItems = bag.blackItems
self.bag = bagCopy
}
}
Launch the application, the color and item names are what they read.
After tapping "Swap items" button, I expect the items to be swapped between sections, but the color of the labels in the sections should remain the same.
The actual result and the expected output is described in screenshot.
This might be a bug in ForEach, but I would like to get a perspective from the community if there is something wrong in my code snippet.
I did file a bug report to Apple, but in the meanwhile I found a workaround. Not the best one, but it works. The solution is to trick the ForEach that there are new items altogether instead of telling it that the items got swapped. Here is the update to ForEach
List {
Section(header: Text("Black Items")) {
ForEach(store.bag.blackItems, id: \.updatedDate) { item in
BlackItemView(model: item)
}
}
Section(header: Text("Blue Items")) {
ForEach(store.bag.blueItems, id: \.updatedDate) { item in
BlueItemView(model: item)
}
}
}
The trick lies at id parameter. I updated Item to have the second identifier in the form of updatedDate. This will have altogether a new value each time I swap items. This forces ForEach to relinquish the views it is holding and recreate the views afresh. Here is change to Item structure
struct Item: Hashable, Identifiable {
var id: String
var updatedDate: Date = Date()
}
Here is the change to swapItems function in ModelStore
func swapItems() {
var bagCopy = bag
bagCopy.blackItems = bag.blueItems.map { item in
var copy = item
copy.updatedDate = Date()
return copy
}
bagCopy.blueItems = bag.blackItems.map { item in
var copy = item
copy.updatedDate = Date()
return copy
}
self.bag = bagCopy
}
I found a direct answer to your situation. Instead of using value enumaration, you can use INDEX. This will guarantee the result is right.
List {
Section(header: Text("Black Items")) {
ForEach(0..<(store.bag.blackItems.count), id: \.self) { index in
BlackItemView(model: self.store.bag.blackItems[index])
}
}
Section(header: Text("Blue Items")) {
ForEach(0..<(store.bag.blueItems.count), id: \.self) { index in
BlueItemView(model: self.store.bag.blueItems[index])
}
}
}