SwiftUI onAppear called inconsistently based on the presence of listStyle

I have discovered that the onAppear method of views inside of a SwiftUI list is called inconsistently, based on the presence of a listStyle. The onAppear method is called 100% of the time when there is no listStyle applied, but it is called irregularly when there is a listStyle applied.

Here is demo code:

struct TextRow: View {
  @State private var didAppear: Bool = false
  private let title: String

  init(title: String) {
    self.title = title
  }

  var rowTitle: String {
    if didAppear {
      return title + " (didAppear)"
    } else {
      return title
    }
  }

  var body: some View {
    Text(rowTitle)
      .onAppear {
        didAppear = true
      }
  }
}

struct Section: Hashable {
  let title: String
  let rows: [String]
}

struct ContentView: View {
  var content: [Section] {
    var rows = [String]()
    for i in 0..<20 {
      rows.append("Row \(i)")
    }

    let section1 = Section(title: "Section 1", rows: rows)
    var rows2 = [String]()
    for i in 0..<20 {
      rows2.append("Row \(i)")
    }

    let section2 = Section(title: "Section 2", rows: rows2)

    return [section1, section2]
  }

  var body: some View {
    List {
      ForEach(content, id: \.self) { section in
        Text(section.title)

        ForEach(section.rows, id: \.self) { row in
          TextRow(title: row)
        }
      }
    }
//    .listStyle(.grouped) // <-- this will trigger different behavior
    .navigationBarTitle("Countries")
    .padding()
  }
}

Is this expected?

Here is the bad behavior:

Here is the proper behavior (no list style):

The short answer here is that it's behaving correctly, but your expectations about @State var didAppear don't match what SwiftUI really does.

But first, a detour with some twisty details, if you choose to take the long way through:

*** Start of Detour ***

Your didAppear state property for a TextRow will be set to true only when the .didAppear modifier is invoked. Note that SwiftUI can create a TextRow value (which is a kind of View, with an uppercase "V") many times, and those values can all refer to the same view (with a lowercase "v"). The .didAppear modifier is invoked only when a view (not a View) is first added to the view hierarchy, which is approximately when the view first becomes visible.

In other words, when you see the text "(didAppear)" in a row, it means that SwiftUI has chosen to discard the previous view for the row, and to create a new one. When you don't see that text, SwiftUI has chosen to reuse the existing (lowercase-v) view for the row. As your list shows, there are many factors affecting whether SwiftUI can reuse a view — it is a kind of optimization, and sometimes optimization isn't feasible), so you get different text display depending on details of your code.

So, what is this mysterious (lowercase-v) view that I've been talking about? It's something that SwiftUI doesn't expose directly to the programmer, but it's a logical view that uses a handful of state (including the current TextRow struct, the @State variables and other stuff) to make that row of the screen look like it should. If you have experience with UIKit, the SwiftUI view is a lot like a UIView. Views such as TextRow are very transient, but views are somewhat persistent.

The last piece of this puzzle is about whether SwiftUI can or can't reuse a row's view when the ContentView is updated. This is dependent on an "id" for each view, which SwiftUI can derive in a couple of ways. I won't make this post longer by discussing this part here, but you can find out all about it in this WWDC 2022 video:

https://developer.apple.com/videos/play/wwdc2021/10022/

Probably the first thing you should try with your code is to make your List iterate over an array of structs that are Identifiable and contain the string to use for the title, instead of a simple list of Strings. In that case, SwiftUI can use the id of the data underlying each row as a way of identifying the associated TextRow in a more stable way, so the row text is more consistent.

*** End of Detour ***

Your original question was about .onAppear. Even with your original code, .didAppear is being called 100% of the time when a new view is added to the view hierarchy. It's not being called 100% of the time when a TextRow is created, because that's not what it means. You might need to re-think your approach if you're relying of the lifetime of Views instead of views..

If you can talk more about what problem you're actually trying to solve, we can talk more about how to achieve that.

Thank you for this detailed response! The scenario I am trying to solve is simple: in my actual app, each row contains an Image that has data we need to load remotely, just like an AsyncImage. Because we support iOS 14 we cannot use AsyncImage, so in our implementation we have a load method that is invoked in an onAppear block.

It appears that Apple's implementation of AsyncImage has no such method exposed, and so the network operation must be triggered in the constructor? If we have a list of many thousands of items, but only a dozen are shown onscreen, I assume that SwiftUI is smart enough to only construct what it needs (plus a few extra above or below what is shown), vs constructing thousands all at once and expecting the code to use something akin to onAppear to figure out when to actually start initializing?

Thanks!

How did you solve it? I have the same problem, my list in some reasons causes that view model never deintialize and the next time i enter the screen on appear doesn’t invoko so I don’t call api method to refresh data while I have data from previous call

This issue only occurs for me when using List. My solution was to use ScrollView with LazyVStack as this will call onAppear for every row.

I just tested the OP's code in Xcode 14.3 and iPhone 14 Pro Simulator running iOS 16.4 and the problem appears to be fixed.

I believe the old behaviour was because the onAppear was tied to the underlying UICollectionViewCell and if you are familiar with UIKit those cells are reused for performance reasons and as you scroll they all only appear once.

I'd be interested to know what Xcode/iOS version this was changed in. I usually read all the release notes and didn't notice this mentioned.

Recorded using Simulator File->Record Screen, stop, right click preview, save as animated GIF.

I'm aware this is an old topic but I am also facing this situation today (2024). That's too bad that the .onAppear() does not behave as the name implies. I understand that the behaviours between SwiftUI and UIKit are not quite the same (UIKit version viewDidAppear / viewWillAppear will always be called when a view will be facing to the user, that's because UIKit manipulates class instances VS SwiftUI which uses structs, so the view hierarchy will not be changed in some cases), but I think the name .onAppear() should be changed to something more descriptive (e.g. onViewAttached or onViewHierarchyChanged)

SwiftUI onAppear called inconsistently based on the presence of listStyle
 
 
Q