List = Row task / onAppear not called each time despite it's on screen

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 onAppear for the last row 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
       let text = "\(section)-\(row)"

       Text(text)
        .onAppear {
         print("ROW ON APPEAR \(text)")
        }
      }
      .onAppear {
       print("ON APPEAR \(section)")
      }
     } header: {
      Text("Section \(section)")
     }
    }
  }
 }
}

Does anyone have an explanation ? Am I missing something ? As is my understanding of the documentation, onAppear should be called every time the view appears on screen.

The interesting thing is that is seems to work fine when they're no sections.... Could it just be a bug in SwiftUI?

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.

Replies

The problem is in this line:

    ForEach(Array(dataSource.keys), id: \.self) { section in

This can result in the array being recreated multiple times, as you can see if you put a print statement inside your dataSource computed property. That in itself would just be a waste of effort without side effects (probably), but the larger problem is that the data item for each section or row no longer has a stable identity. That makes it impossible for SwiftUI to track the row view lifecycles properly.

You can "fix" this by creating the array once before starting the loop:

    let sections = Array(dataSource.keys)
    ForEach(sections, id: \.self) { section in

However, I recommend you don't do that. Instead of using a simple Int or String as the data item backing each section or row, use a custom section type and row type, and make those types conform to Identifiable. That has a number of advantages with SwiftUI, such as better View usage inside the list (because the rows are more safely tracked by id rather than by position), and avoiding the id: \.self boilerplate that you are currently forced to use.

  • @Polyphonic datasource isn't a computed property. As you can see the =, so it's only called once.

    I don't really get why having custom section and row type should solve the problem. It comes to the same than having \.self because the Int and the String are unique in my example. I tried your solution anyway, but it's the same result.

    I wonder if under the hood List just works like UITableView, which would mean that it will reuse each row when it can and call onAppear only once.

Add a Comment