How can I render a ScrollView so that its ScrollPosition is at the correct ID during layout?

Below you will find an example view of my problem. It has one button that, when pressed, will toggle between two scroll views using withAnimation, setting the scroll position onto the 2nd and 3rd items for either scroll view in onAppear.

The intent is to have the background of items within each list transition smoothly from their position in one list, to their position in the other. However, this does not appear to be easily possible when setting the list position using an ID/ScrollPosition:

  • Initializing a ScrollPosition with the correct ID and rendering the ScrollView with that appears to have no effect - the ScrollView will be drawn at the top of the scroll contents
  • The only way I've found to render the ScrollView at an ID position is to scroll to that position in onAppear. However, it appears that when doing so, the matchedGeometryEffect interpolates the position of the elements as if the contentOffset.y of the ScrollView is briefly 0, resulting in a strange effect
  • The desired animation can be seen if the two lists are toggled rapidly, allowing for the matchedGeometryEffect to smooth out the brief y 0 and interpolate between the correct positions

It seems I either need to

a) ensure the list is laid out at the correct y location beforehand (very difficult with dynamic list items, but seems to solve this problem if setting the y position explicitly)

b) ensure that the list is laid out at the correct ID beforehand (have not been able to figure out how)

c) ensure the matched geometry effect animation ignores the brief "0" y offset of the ScrollView before setting the ID position in onAppear (have not been able to figure out how)

Note that I have to use VStack here for the matched geometry effect to work consistently.

Any ideas on solving this?

Code:

import SwiftUI

struct Item: Identifiable {
  let id = UUID().uuidString
  var height: CGFloat
  var label: String
}

enum TestScrollListStyle {
  case primary
  case alternate
}

struct TestScrollList: View {
  let items: [Item]
  let style: TestScrollListStyle
  let namespace: Namespace.ID
  @Binding var scrollPosition: ScrollPosition
  var initialIndex: Int = 2

  var body: some View {
    ScrollView {
      VStack(spacing: style == .primary ? 8 : 16) {
        ForEach(items, id: \.id) { item in
          switch style {
          case .primary:
            Text(item.label)
              .frame(maxWidth: .infinity)
              .frame(height: item.height)
              .padding(.horizontal)
              .background(
                Rectangle()
                  .fill(.blue.opacity(0.15))
                  .matchedGeometryEffect(id: item.id, in: namespace)
              )
          case .alternate:
            HStack {
              Circle()
                .fill(.green.opacity(0.25))
                .frame(width: 24, height: 24)
              Text(item.label)
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .frame(height: item.height)
            .padding(.horizontal)
            .background(
              Rectangle()
                .fill(.green.opacity(0.08))
                .matchedGeometryEffect(id: item.id, in: namespace)
            )
          }
        }
      }
      .scrollTargetLayout()
      .padding(.vertical)
    }
    .scrollPosition($scrollPosition, anchor: .top)
    .onAppear {
      var tx = Transaction()
      tx.disablesAnimations = true
      withTransaction(tx) {
        if items.indices.contains(initialIndex) {
          scrollPosition.scrollTo(id: items[initialIndex].id)
        }
      }
    }
  }
}

struct ContentView: View {
  @Namespace private var matchedNamespace

  @State private var items: [Item] =
    (0..<10).map { i in Item(height: .random(in: 80...220), label: "Row \(i)") }

  @State private var showAlternateView: Bool = false

  // Scroll positions for each scroll view
  @State private var primaryScrollPosition = ScrollPosition(idType: String.self)
  @State private var alternateScrollPosition = ScrollPosition(idType: String.self)

  var body: some View {
    NavigationStack {
      ZStack {
        if !showAlternateView {
          TestScrollList(
            items: items,
            style: .primary,
            namespace: matchedNamespace,
            scrollPosition: $primaryScrollPosition,
            initialIndex: 2
          )
        }

        if showAlternateView {
          TestScrollList(
            items: items,
            style: .alternate,
            namespace: matchedNamespace,
            scrollPosition: $alternateScrollPosition,
            initialIndex: 3
          )
        }
      }
      .navigationTitle("Two ScrollViews + Matched Geometry")
      .toolbar {
        ToolbarItem(placement: .topBarTrailing) {
          Button(showAlternateView ? "Primary" : "Alternate") {
            withAnimation() {
              showAlternateView.toggle()
            }
          }
        }
      }
    }
  }
}

#Preview { ContentView() }

Thanks for your post.

Do you get the same results with just the relevant code in a small test project? If so, please share a link to your test project. That'll help us better understand what's going on. If you're not familiar with preparing a test project, take a look at Creating a test project.

Albert Pascual
  Worldwide Developer Relations.

How can I render a ScrollView so that its ScrollPosition is at the correct ID during layout?
 
 
Q