SwiftUI List scroll indicator stutters, does not call `onAppear` or `onDisappear` consistently in iOS 16

My team has been debugging problems with the SwiftUI List component this week.

We have found that it's performance is sub-optimal on iOS 16. You can see a simple grid of images, the scroll indicator stutters when scrolling down:

Now compare it to what happens when we use a ScrollView with a LazyVStack:

(An error occurred while uploading my second image, but pretend you see a scroll indicator moving smoothly down the side of the screen).

You can see the scroll indicator moves smoothly without issue.

We also found that the ScrollView combined with a LazyVStack properly calls onDisappear, which enables us to call a cancel method on the async image loading code that we use for our individual cells in this example. Though in a previous question, it was asserted that onDisappear cannot be reliably expected to be called in a List, I do not feel that answer is correct or proper behavior.

Is this a bug, or is this expected behavior on a List?


This is the cell that is being rendered:

struct UserGridCell: View {
  let stackId: String
  let user: ProfileGridCellUIModel
  let userGridCellType: UserGridCellType

  @State var labelFrame: CGRect = .zero
   
  private var isOnlineAcessibilityValue: String {
    return user.isOnline == true ? "" : ""
  }
   
  init(stackId: String,
     user: ProfileGridCellUIModel,
     userGridCellType: UserGridCellType
  ) {
     
    self.stackId = stackId
    self.user = user
    self.userGridCellType = userGridCellType
  }

  var body: some View {
    GeometryReader { containerGeometry in
      ZStack(alignment: .bottom) {

        HStack(spacing: 4) {
           
          let statusAccentColor: Color = .red
           
           
          Circle()
            .frame(width: 8, height: 8)
            .foregroundColor(statusAccentColor)
           
          Text(String(user.remoteId) ?? "")
            .lineLimit(1)
            .foregroundColor(.black)
            .overlay(GeometryReader { textGeometry in
              Text("").onAppear {
                self.labelFrame = textGeometry.frame(in: .global)
              }
            })
        }
        .frame(maxWidth: .infinity, alignment: .bottomLeading)
        .padding(.leading, 8)
        .padding(.trailing, 8)
        .padding(.bottom, 8)
      }
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
      .contentShape(Rectangle())
      .accessibilityLabel(Text(user.name ?? ""))
      .accessibilityValue(isOnlineAcessibilityValue)
    }
    .background(
      ZStack {
        AsyncProfileImage(request: URLRequest(url: URL(string: "https://picsum.photos/id/\(100 + user.remoteId)/200/300")!))
      }
        .accessibilityHidden(true)
    )
    .overlay(
      RoundedRectangle(cornerRadius: 4)
        .stroke(.red, lineWidth: user.hasAnyUnreadMessages ? 4 : 0)
    )
    .cornerRadius(4)
  }
}

This is the code that renders each cell:

struct ProfileGrid: View {
  public static var AspectRatio: CGFloat = 0.75

  @Environment(\.horizontalSizeClass) var horizontalSizeClass
  @Environment(\.redactionReasons) private var reasons

  private let stacks: [ProfileGridStackUIModel]

  public init(stacks: [ProfileGridStackUIModel]
  ) {
    self.stacks = stacks
  }
   
  var body: some View {
    let columnCount: Int = 3
     
    // If you use a list, you will get the stutter. If you use what you see below,
    // it will render properly.
    ScrollView {
      LazyVStack {
        ForEach(stacks, id: \.self) { stack in
          Grid(stack: stack, columns: columnCount)
        }
      }
    }
    .buttonStyle(PlainButtonStyle())
    .listStyle(PlainListStyle())
  }
   
  @ViewBuilder private func Grid(stack: ProfileGridStackUIModel, columns: Int) -> some View {
    let chunks = stride(from: 0, to: stack.profiles.count, by: columns).map {
      Array(stack.profiles[$0..<min($0 + columns, stack.profiles.count)])
    }
       
     
    ForEach(chunks, id: \.self) { chunk in
      GridRow(chunk: chunk, stack: stack, columns: columns)
        .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
    }
  }
   
  @ViewBuilder private func GridRow(chunk: [ProfileGridCellUIModel], stack: ProfileGridStackUIModel, columns: Int) -> some View {
    let emptyElements = columns - chunk.count
    HStack(spacing: 8) {
      ForEach(chunk) { user in
        UserGridCell(stackId: "id",
               user: user,
               userGridCellType: .grid)
        .aspectRatio(ProfileGrid.AspectRatio, contentMode: .fill)
      }
       
      if emptyElements > 0 {
        ForEach(0..<emptyElements, id: \.self) { _ in
          Rectangle()
            .foregroundColor(Color.clear)
            .contentShape(Rectangle())
            .frame(maxWidth: .infinity)
            .aspectRatio(ProfileGrid.AspectRatio, contentMode: .fill)
        }
      }
    }
  }
}

I am seeing inconsistent calls to onAppear on a list row with NavigationLink on iOS 16. Did you find a cause or solution to your inconsistencies?

SwiftUI List scroll indicator stutters, does not call `onAppear` or `onDisappear` consistently in iOS 16
 
 
Q