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