0

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:

enter image description here

Do you have any solutions for this issue, or did you find a way around it?

Thank you.

rdor
  • 101
  • 1
  • 4
  • I could not reproduce this. I made an array of 36 strings starting with Item (Index) plus a random 10 to 30 extra characters. I used both the `Button` and a `NavigationLink`. Are you sure this code reproduces the problem? Xcode 14.3.1, iOS 16.4. – Yrb Jun 25 '23 at 18:15
  • Thank you for your reply. It does contain the bug, but the bug is intermittent. It seems to depend on the way (speed) you scroll and therefore does not always present itself. It also seems to depend on the strings itself. For example, when you use just the index as string, there is no wrapping and the issue does not occur. When the issue does occur, tapping a button, or even moving to the iOS app switcher, draws the view in place. I am also on Xcode 14.3.1 and using the latest iOS 16.5.1 on device. Simulators, including iOS 15 also have the bug. – rdor Jun 25 '23 at 19:49
  • I made sure the strings were long enough to wrap, and wrap different amounts. The label were up to 4 lines long, but varied. I also was very carefully to scroll very slowly. I was very careful to replicate this as closely as possible to what you described. – Yrb Jun 25 '23 at 22:07
  • I also did some further debugging and updated my question with a full working example with the bug as well as a video showing the bug. View state changes also seem to be a factor. Setting the 'showGrid' @State to true will not result in the bug (since there is then no state transition in the view). In my full app there are obviously (many) state transitions possible. – rdor Jun 25 '23 at 22:26

0 Answers0