Is your SwiftUI ScrollView scrolling to the wrong location in iOS 15?

I isolated a couple of new iOS 15 ScrollView bugs recently while working on the timing editor view in our new video subtitling app Captionista. These particular problems did not exist in iOS 14.

Our new friend since iOS 14 ScrollViewReader lets us scrollTo: a specific view to make sure it is on screen and if possible, aligned to a specific alignment of the ScrollView itself. e.g. scrollTo(viewID, anchor: .top) should scroll your view to align its top with the top of the ScrollView viewport on screen.

I found that when users tapped on our subtitle cells, they were supposed to scroll to the top but there were two problems. One, they would sometimes — but not always — scroll twice, once to an almost correctly location, and immediately again a bit further into the wrong location. I’m not going to tackle that issue here, but I posted the content of my radar FB9697336 for it here.

Once I worked out the double-scroll issue I still found that scrolling my cell view to the “top” would not do this, it would scroll to the button that is above it in the ScrollView.

Eventually I isolated it. scrollTo(id ...) does not scroll to the frame of the view with the id if you have a ForEach in the ScrollView and the result of the ForEach body for the view you want to scroll to contains other views. In this situation, it seems to scroll to the frame that is the union of all views returned by the ForEach body. This is a pretty serious problem but you can see how many people will not notice this as usually your ScrollView code is something like this:


ScrollViewReader { scrollReader in
  ScrollView {
    VStack {
      ForEach(data, id: \.self) { item in
         Text(item.name)
      }
    }
  }
}

// The problem is if you do something like this:

ScrollViewReader { scrollReader in
  ScrollView {
    VStack {
      ForEach(data, id: \.self) { item in
         if item.kind == whatever {
           Text(item.name)
           .id(item.id)
         } else {
           Button(action: { }) {
              Text("Do something")
           }

           Text(item.name)
           .id(item.id)
         }
      }
    }
  }
}

Here for some items it will return two views, and if you try to scroll to the id of the text view in that scenario, it will scroll the Button into view. The problem is shown clearly in this video below. A radar was filed with an isolated test case if you want to hack around with it yourself.

The workaround? Rejig your ForEach to return only “one view” that matches the frame you want to use with scrollTo(id:,anchor:) — in my case I map my model data to an array of new enum types that describe what the ForEach should output for each iteration.

FB9727405 — ScrollView scrolls to view incorrectly when view is in ForEach with multiple child views

Please provide a descriptive title for your feedback:
ScrollView scrolls to view incorrectly when view is in ForEach with multiple child views

Which area are you seeing an issue with?
SwiftUI Framework

What type of feedback are you reporting?
Incorrect/Unexpected Behavior

Please describe the issue and what steps we can take to reproduce it:
Given a ForEach in a ScrollView, where the body of the ForEach returns different views based on state, scrolling one of these child views to a specific location results in an offset if there is another child view returned by the same iteration of ForEach.

I have attached a sample project that reproduces the problem simply, and a video showing the problem. In case it isn’t clear, tapping a green box should always scroll that green box precisely to the top of the scroll view area. This works, unless you tap a green view that is below a blue view.

Here’s the sample code inline for your convenience:


struct ContentView: View {
    let items: [Int] = Array(0...50)
    var body: some View {
        VStack(spacing: 0) {
            Text("Tap green items to scroll them to top. Items with a blue 'Text Item' before them will be offset from the top.")
            .foregroundColor(.white)
            .frame(maxWidth: .infinity)
            .padding()
            .background(.black)
            
            GeometryReader { geometryProxy in
                ScrollViewReader { scrollProxy in
                    ScrollView(.vertical) {
                        VStack(spacing: 20) {
                            ForEach(items, id: \.self) { item in
                                // The mere presence of this view - any view - before the item that we
                                // want to scroll to top, will offset the scrolled view from the top.
                                if item % 3 == 0 {
                                    Text("Text item")
                                    .frame(maxWidth: .infinity)
                                    .padding()
                                    .background(
                                        Color.blue
                                    )
                                    .frame(height: 50)
                                }
                                
                                Text(item % 3 == 0 ? "Item #\(item)\nTap me — I **will not** get to the top" :
                                        "Item #\(item)\nTap me — I'll get to top")
                                .multilineTextAlignment(.leading)
                                .padding(.horizontal, 20)
                                .padding(.vertical, 40)
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .background(Color.green)
                                .id(item)
                                .onTapGesture {
                                    withAnimation {
                                        // Scroll the tapped item to the top. That's all we want to do.
                                        scrollProxy.scrollTo(item, anchor: .top)
                                    }
                                }
                                .contentShape(Rectangle())
                            }
                        }
                        .frame(width: geometryProxy.size.width)
                    }
                }
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Download project

ScrollBug-offset-by-other-views.zip

About

Marc Palmer (@marcpalmerdev) is a consultant and software engineer specialising in Apple platforms. He currently works on the iOS team of Concepts sketching app, as well as his own apps like video subtitle app Captionista. He created the Flint open source framework. He can also do a pretty good job of designing app products. Don't ask him to draw anything, because that's really embarrassing. You can find out more here.

Comments are closed.