ScrollViewReader's scrollTo may be broken on iOS 15

ScrollViewReader's scrollTo scrolls too much in iOS 15. I had no problem with iOS 14.

This is the code:

import SwiftUI

struct ContentView: View {
  var body: some View {
    ScrollViewReader { proxy in
      ScrollView {
        VStack {
          Color.yellow
            .frame(height: 800)

          ScrollButton(proxy: proxy)
        }
      }
    }
  }
}

struct ScrollButton: View {
  let proxy: ScrollViewProxy

  @Namespace var bottomId

  var body: some View {
    VStack {
      Button("Scroll") {
        withAnimation {
          proxy.scrollTo(bottomId)
        }
      }
      Color.red
        .frame(height: 500)
        .id(bottomId)
    }
  }
}

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

+1. We are also having this issue. The above workarounds didn't fix it for us either.

Can anyone confirm if this was fixed when compiling with XCode 13.1?

Same problem with us.

+1

Adding that I'm also experiencing this problem on MacOS Monterey 12.0.1 as well as iOS 15

Found a workaround that works for me on both OSX and iOS. I suspected the bug was happening from scrollTo() incorrectly calculating the offset of the particular view in the reference frame of the scrollview, as I was getting different scroll positions depending on how far down the list the scrollTo view was.

Previously the scrollview content was composed of different container views, with subviews inside those. The workaround was to flatten those container views so the requested view IDs to scrollTo are all flat at the top level of the scroll view content.

ScrollView setup:

ScrollViewReader { proxy in
        GeometryReader { geometry in
            ScrollView(showsIndicators: false) {
                VStack(alignment: .leading, spacing: 17) {
                    ForEach(levelSelect.worlds) { world in
                        flatRowItems(world: world)
                    }
                }
            }
            .onAppear {
                if let id = levelSelect.selectedLevelID {
                    proxy.scrollTo(id, anchor: .center)
                }
            }
            .onChange(of: levelSelect.selectedLevelID) { selectedLevelID in
                guard let selectedLevelID = selectedLevelID else { return }
                if !levelSelect.isInitialSelection {
                    withAnimation {
                        proxy.scrollTo(selectedLevelID, anchor: nil)
                    }
                } else {
                    levelSelect.isInitialSelection = false
                    proxy.scrollTo(selectedLevelID, anchor: .center)
                }
            }
        }
    }

Flattened scrollview content using @ViewBuilder:

@ViewBuilder func flatRowItems(world: World) -> some View {
        Group {    // previously had a WorldRow which contained the following content as child views
            if world.locked {
                LockedWorldRow(world: world)
            } else {
                UnlockedWorldRow(world: world)    // previously UnlockedWorldRow contained LevelRows inside

                ForEach(world.levels) { level in
                    LevelRow(levelItem: level)
                    .id(level.id)
                }
            }
        }
    }

Flattening the hierarchy will definitely help. Although for a big feature like ours it's an impossible task. We decided to scroll to a larger parent view instead. Really hoping that Apple fixes this bug soon.

Has mentionned by @archy88 for me replacing inner VStack with LazyVStack did fix the issue.

Expect that will help some of you.

     
  ScrollViewReader { scrollProxy in
     
    ScrollView(.vertical) {
       
      VStack (spacing: 0) {
         
        ForEach(parents) { parent in
           
          Text(parent.name)
            .padding()
           
          LazyVStack (spacing: 0) { // With VStack Scroll issue
               
            ForEach(parent.childs) { child in
              
              ChildView(child)
                .id(child.uuid)
                .padding()
            }
          }
          .id(parent.id)
        }
      }
       
      Spacer()
    }
    .onAppear() {
      if let selectedChild = selectedChild {
        scrollProxy.scrollTo(selectedChild.uuid, anchor: .center)
      }
    }
  }
  .padding()
}

Just to mention it, we observed some problems on iOS 15 and employed the workaround with attaching the view IDs to the views at top level. However, immediately after that we realized that it makes sense even from UX perspective: considering e.g. the case of scrollable section with title, it makes sense to scroll to the section as whole rather to the title of the section, as it makes difference when there's not enough content below the section to scroll it to the top: in that scenario, attaching the id to the section makes the whole section rather just the title scrolled into in the view.

From my testing, this appears to be fixed in iOS 15.4 developer beta. My radar reproduction works perfectly on the beta and was broken on 15.3 and earlier

I am facing scrolling issue while applying accessibility.

:
:
:
.modifier(if: adjustableAccessibilityScrollView) {

                        $0.accessibilityElement()

                            .accessibilityLabel(Text(viewModel.accessibilityFormLabel))

                            .accessibilityValue(Text(String(viewModel.chips[selectedChipIndex].text)))

                            .accessibilityAdjustableAction { direction in

                                switch direction {

                                case .increment:

                                    guard viewModel.chips.count - 1 > selectedChipIndex else { break }

                                    selectedChipIndex += 1

                                    viewModel.selectChip(index: selectedChipIndex)

                                    proxy.scrollTo(selectedChipIndex, anchor: .center)

                                case .decrement:

                                    guard selectedChipIndex > 0 else { break }

                                    selectedChipIndex -= 1

                                    viewModel.selectChip(index: selectedChipIndex)

                                    proxy.scrollTo(selectedChipIndex, anchor: .center)

                                @unknown default:

                                    break

                                }

                            }

                            .accessibilityAction(.default) {

                                viewModel.selectChip(index: selectedChipIndex)

                            }

                    }

I think I encountered the same issue today using Xcode 13.3 and iOS 15.4, and from what I can see the issue still persists. The following code reproduces the problem I'm having:

struct ContentView: View {
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                Spacer(minLength: 300)

                Button("Scroll to #50") {
                    proxy.scrollTo(50, anchor: .center)
                }

                VStack(spacing: 0) {
                    ForEach(0..<100) { i in
                        Text("\(i)")
                            .frame(height: 100)
                            .id(i)
                            .frame(maxWidth: .infinity)
                    }
                }
            }
        }
    }
}

I submitted a feedback to Apple using the above code sample: FB9959257.

Hi! I fixed the issue by adding

 DispatchQueue.async {
      ...
      proxy.scrollTo("id")
}

now scrollTo works perfect.

For me setting the background as a clear colour with id solved the issue:

ScrollViewReader { scrollViewProxy in
    ScrollView {
      LazyVStack {
        ForEach(viewModel.items) { item in
          ItemView(item)
            .background(Color.clear.id(item.id))

        }
      }
    }
  }
ScrollViewReader's scrollTo may be broken on iOS 15
 
 
Q