This is probably a SwiftUI bug, but I hope someone has a solution for the following problem. I have tried numerous work-arounds but did not find a solution myself unfortunately. I have tested with iOS 15, iOS 16, 16.1, iOS 16.5.1 on devices and on the simulator, the problem is present on all variants.
Given the following full code example:
import SwiftUI
struct Item: Identifiable {
let id: UUID
let name: String
}
struct BugView: View {
@State private var showGrid = false
let items: [Item] = [
.init(id: UUID(), name: "Addition"),
.init(id: UUID(), name: "Subtraction"),
.init(id: UUID(), name: "Multiplication"),
.init(id: UUID(), name: "Division"),
.init(id: UUID(), name: "Times Tables"),
.init(id: UUID(), name: "Division Tables"),
.init(id: UUID(), name: "Positive and negative numbers"),
.init(id: UUID(), name: "Fractions"),
.init(id: UUID(), name: "Add Fractions"),
.init(id: UUID(), name: "Subtract Fractions"),
.init(id: UUID(), name: "Multiply Fractions"),
.init(id: UUID(), name: "Divide Fractions"),
.init(id: UUID(), name: "Fractional Amount"),
.init(id: UUID(), name: "Simplify Fractions"),
.init(id: UUID(), name: "Convert to Fractions"),
.init(id: UUID(), name: "Decimals"),
.init(id: UUID(), name: "Add Decimals"),
.init(id: UUID(), name: "Subtract Decimals"),
.init(id: UUID(), name: "Multiply Decimals"),
.init(id: UUID(), name: "Divide Decimals"),
.init(id: UUID(), name: "Round Decimals"),
.init(id: UUID(), name: "Convert to Decimals"),
.init(id: UUID(), name: "Percentages"),
.init(id: UUID(), name: "Percentages of Amount"),
.init(id: UUID(), name: "Convert to Percentages"),
.init(id: UUID(), name: "Exponents"),
.init(id: UUID(), name: "Roots"),
.init(id: UUID(), name: "Money"),
.init(id: UUID(), name: "Add Money"),
.init(id: UUID(), name: "Subtract Money"),
.init(id: UUID(), name: "Multiply Money"),
.init(id: UUID(), name: "Divide Money"),
.init(id: UUID(), name: "Percentages of Money"),
.init(id: UUID(), name: "Worksheets"),
.init(id: UUID(), name: "Addition Worksheets"),
.init(id: UUID(), name: "Multiplication Worksheets")
]
var body: some View {
if showGrid {
ScrollView(.vertical) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 16, alignment: .top)], spacing: 16) {
ForEach(items) { item in
Button {
} label: {
Text(verbatim: item.name)
.padding(48)
}
.buttonStyle(.plain)
}
}
.padding()
}
} else {
Button {
showGrid = true
} label: {
Text(verbatim: "Bug")
}
}
}
}
@main
struct BugApp: App {
var body: some Scene {
WindowGroup {
BugView()
}
}
}
As you can see it is a very minimal example which already contains the bug.
A description of the problem:
When scrolling slowly (this is important), the last two items are sometimes not shown / layed-out. This problem is intermittent. This also only occurs when a Button or NavigationLink is used inside the LazyVGrid as in the example code. When the label of the Button (or NavigationLink) is put in the ForEach without the Button (or NavigationLink), the problem never occurs. When tapping on a NavigationLink with the problem occurring (the last two items not visible), they suddenly pop into view just before the view navigates.
The problem does not occur when the body of the LazyVGrid is:
ForEach(items) { item in
Text(verbatim: item.name)
.padding(48)
}
Update: The problem also seems not to occur when the @State variable is set to true, so the view update in BugView also is a factor in the bug.
What I tried to circumvent the bug:
- Different LazyVGrid settings, such as no spacing, no alignment, changing to two columns using .flexible(); all did not solve the problem.
- Using a .fixedSize() or .fixedSize(horizontal: false, vertical: true) on the button itself, or inside the label, or both; all did not solve the problem.
- Removing the .buttonStyle(.plain); did not solve the problem.
- Adding .id(item.id) to the button, or adding two unique .id's to both the button and its label; all did not solve the problem.
- Removing the .padding() on the LazyVGrid; did not solve the problem.
- Creating a separate View for the Button label content; did not solve the problem. I did notice though with a let _ = print("Init") inside the body of the view, that it is always initialised when scrolling back up from the bottom, which does not occur if that view is not part of a Button.
My thoughts:
This looks like a SwiftUI bug to me which I cannot seem to resolve. I also could not find any documentation or previous issue describing this. Having an .onTapGesture {} on the view (so using no Button or NavigationLink) would solve the bug, but is not a fix for my problem.
From my testing I believe the issue is a lay-out issue in combination with the ScrollView not updating (in time?). Due to the wrapping that occurs with mixed string lengths perhaps with the escaping closure of the Button's or NavigationLink's label, the LazyVGrid might be struggling to set its required space. But I am only guessing about this of course.
What I also found is that when there is no view update, (e.g., setting showGrid = true) then the bug does not seem to appear.
I added a video to show the bug. In the video you see I tap on the button, then scroll down relatively slow. The last item visible is 'Worksheets' which is not the last item in the list. Scrolling back up slightly and scrolling down faster shows the other (correct) last two items:
Do you have any solutions for this issue, or did you find a way around it?
Thank you.