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?

This behavior continues to plague iOS and iPadOS 26 Beta 4. The List itself scrolls 100% smoothly, but the scroll indicator is choppy and jerky. This manifests even when each row element is given a fixed height.

Once you scroll the List all the way down (in my example, I have about 30 rows), the indicator will then be smooth as you subsequently scroll around.

It's almost as if the indicator is jumping around in reaction to new "in-flight" rows being added just outside the ScrollView's clipRect. As if the new rows are staged and ready to scroll into view and as they are added to the view hierarchy, the indicator size/position is recalculated based on their presence.

SwiftUI List scroll indicator stutters, does not call &#96;onAppear&#96; or &#96;onDisappear&#96; consistently in iOS 16
 
 
Q