I'm trying to make a horizontal scrolling view that sends the centered view's ID to the parent view via a binding on scrollPosition(id:anchor:)
, but it's getting the wrong item at the center.
Here's a sample:
struct ScrollPositionSample: View {
let words = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod".components(separatedBy: " ")
@State private var selection: String? = "Lorem"
var body: some View {
VStack {
if let selection {
Text("Selected: \(selection)")
.font(.title3)
}
ScrollView(.horizontal) {
LazyHStack {
ForEach(words, id: \.self) { word in
Text(word)
.font(.title2)
.padding()
.background(.teal, in: RoundedRectangle(cornerRadius: 5.0))
}
}
.scrollTargetLayout()
.frame(maxHeight: 125)
}
.scrollPosition(id: $selection, anchor: .center)
.contentMargins(.leading, 100) // see note below
.contentMargins(.trailing, 40) // about contentMargins
.overlay {
// line centered over scrollView to visualize centered item
Color.red.frame(width: 2)
}
}
}
}
The problem is that the centered overlay line often doesn't match the selection
string displayed above the scrollView. It's usually close, but gets worse if I set horizontal contentMargins
on the scrollView (regardless of if they're equal or not).
Is there some trick to getting the centered value right?
The reason for the issue may be because you are using a lazy container to hold items that have irregular widths. Item positioning does not work very well when this is the case.
A workaround is to detect the item under the center position yourself. This is not too difficult if you know the width of the ScrollView
.
ScrollView
can be measured by wrapping it with a GeometryReader
..onGeometryChange
modifier to each.ScrollView
is less than half of its (width + spacing).selection
variable..scrollTargetLayout
and .scrollPosition
are not needed. If you want to be able to set the scroll position, as well as reading it, the ScrollView
could be wrapped with a ScrollViewReader
.Here is the updated example to show it working this way:
struct ScrollPositionSample: View {
let words = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod".components(separatedBy: " ")
let spacing: CGFloat = 10
@State private var selection: String?
var body: some View {
VStack {
if let selection {
Text("Selected: \(selection)")
.font(.title3)
}
GeometryReader { outer in
let midScroll = outer.size.width / 2
ScrollView(.horizontal) {
LazyHStack(spacing: spacing) {
ForEach(words, id: \.self) { word in
Text(word)
.font(.title2)
.padding()
.background(.teal, in: RoundedRectangle(cornerRadius: 5.0))
.onGeometryChange(for: Bool.self) { geo in
let midX = geo.frame(in: .scrollView).midX
return abs(midX - midScroll) < (geo.size.width + spacing) / 2
} action: { isBelowCenter in
if isBelowCenter {
selection = word
}
}
}
}
.padding(.horizontal, outer.size.width / 2)
}
.overlay {
Color.red.frame(width: 2)
}
}
.frame(maxHeight: 125)
}
}
}
In your original example, you were shifting the center line by applying un-equal leading and trailing content margins. If you want to detect when an item is below some point that is not in the center then obviously you will need to change the detection logic, for example, by modifying the way that midScroll
is computed.