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, thematchedGeometryEffect
interpolates the position of the elements as if thecontentOffset.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 y0
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() }