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)
}
}
}
}
}