I have created Horizontal Picker View with following functionalities:
But... there is one issue:
I need to support iOS 17 also. And there is now working. Trying solution with simultanous gestures failed. Do you have any ideas?
Here is current working example. Just copy and paste.
import SwiftUI
struct SnapPickerView: View {
private var elements = ["Sirene 187", "FC Clubbing completely new", "John", "Malkovich", "Santander stret 126 hood", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "FC Clubbing completely new 23"]
@State private var selectedIndex = 10
@State private var itemsPositions: [Int: CGRect] = [:]
@State private var containerWidth: CGFloat = 0
var body: some View {
GeometryReader { geometry in
let centerX = geometry.size.width / 2
VStack {
ScrollViewReader { proxy in
if #available(iOS 18.0, *) {
ScrollView(.horizontal, showsIndicators: false) {
ZStack(alignment: .leading) {
// Use this to track scroll position
GeometryReader { scrollGeo in
Color.clear
.preference(key: ScrollOffsetKey.self, value: scrollGeo.frame(in: .named("scrollView")).minX)
}
.frame(width: 0, height: 0)
HStack(spacing: 16) {
Spacer()
.frame(width: centerX)
ForEach(0..<elements.count, id: \.self) { index in
Text(elements[index])
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.fill(selectedIndex == index ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
)
.id(index)
.onTapGesture {
withAnimation {
selectedIndex = index
proxy.scrollTo(index, anchor: .center)
}
}
.background(
GeometryReader { itemGeometry in
Color.clear
.preference(
key: ItemPositionKey.self,
value: [ItemPosition(
index: index,
position: itemGeometry.frame(in: .named("scrollView"))
)]
)
}
)
}
Spacer()
.frame(width: centerX)
}
}
}
.coordinateSpace(name: "scrollView")
.onPreferenceChange(ItemPositionKey.self) { positions in
// Process the positions and store them
positions.forEach { pos in
itemsPositions[pos.index] = pos.position
}
}
.onAppear {
containerWidth = geometry.size.width
// Initial selection
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation {
proxy.scrollTo(selectedIndex, anchor: .center)
}
}
}
.onChange(of: geometry.size.width) { _, newWidth in
containerWidth = newWidth
// Recenter on container size change
withAnimation {
proxy.scrollTo(selectedIndex, anchor: .center)
}
}
// Ensure selection is centered when it changes
.onChange(of: selectedIndex) { _, _ in
withAnimation {
proxy.scrollTo(selectedIndex, anchor: .center)
}
}
.onScrollPhaseChange { _, newPhase in
if newPhase == .idle {
// Scrolling has completely stopped
print("Scrolling stopped. Finding nearest item...")
selectNearestItem(proxy: proxy)
}
}
} else {
}
}
}
.frame(height: 70)
}
}
// Helper method to select the item nearest to the center
private func selectNearestItem(proxy: ScrollViewProxy) {
guard !itemsPositions.isEmpty else { return }
let center = containerWidth / 2
// Find the item whose center is closest to the container's center
var closestIndex = 0
var smallestDistance: CGFloat = .infinity
for (index, frame) in itemsPositions {
let itemCenter = frame.midX
let distance = abs(itemCenter - center)
if distance < smallestDistance {
smallestDistance = distance
closestIndex = index
}
}
// Debug print
print("Selecting item at index: \(closestIndex)")
// Only update if different from current selection
if selectedIndex != closestIndex {
withAnimation {
selectedIndex = closestIndex
proxy.scrollTo(closestIndex, anchor: .center)
}
}
}
}
// Preference key for tracking scroll offset
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
// Structure for tracking item positions
struct ItemPosition: Equatable {
let index: Int
let position: CGRect
}
// Preference key for tracking item positions
struct ItemPositionKey: PreferenceKey {
static var defaultValue: [ItemPosition] = []
static func reduce(value: inout [ItemPosition], nextValue: () -> [ItemPosition]) {
value.append(contentsOf: nextValue())
}
}
#Preview {
SnapPickerView()
.padding()
}
You might not need .onScrollPhaseChange
if you use .scrollTargetBehavior
instead (available in iOS 17).
If the items all had the same widths then you could just use ViewAlignedScrollTargetBehavior
. But since the items have irregular widths, a custom ScrollTargetBehavior
can be used to snap to the selected target.
The answer to Center View align SwiftUI ScrollView provides a custom ScrollTargetBehavior
that works by tracking the selected item using .scrollPosition
, then using the midX
offset of this item as the target for scrolling (it was my answer). This works well for a slow scroll, but sometimes snaps in the wrong direction for an inertia scroll.
An improvement can be made on that implementation by recording the positions of all the elements in an array. Then, when an inertia scroll is detected, the scroll target can be set to the element that is closest to the predicted position:
struct MidPositionAligned: ScrollTargetBehavior {
let selectedIndex: Int?
let elementPositions: [CGRect]
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// Only perform an adjustment if the target anchor is
// top-leading. This will be the case for a scroll action,
// but may not be the case on initial show.
if (target.anchor?.x ?? 0) == 0 {
if let selectedIndex {
let targetX = target.rect.origin.x + (context.containerSize.width / 2)
let dx = context.velocity.dx
var frame = elementPositions[selectedIndex]
var index = selectedIndex
if dx < 0 {
while index > 0 && targetX < frame.minX {
index -= 1
let prevFrame = elementPositions[index]
if prevFrame == .zero {
break
} else {
frame = prevFrame
}
}
} else if dx > 0 {
while index < elementPositions.count - 1 && targetX > frame.maxX {
index += 1
let nextFrame = elementPositions[index]
if nextFrame == .zero {
break
} else {
frame = nextFrame
}
}
}
target.rect.origin.x = frame.midX - (context.containerSize.width / 2)
}
}
}
}
Also missing from the other answer was padding at the side of the HStack
, to allow the first and last items to be selected. If the padding needed to be precise then we would need to know the widths of the first and last elements. But an easy solution is just to add horizontal padding equal to half the screen width.
The screen width can either be measured by wrapping the ScrollView
with a GeometryReader
(as you were doing before), or by using .onGeometryChange
and writing to a state variable. The updated example below uses the latter technique. This way, the height of the view is determined by the content, it is not greedy in the vertical axis.
struct SnapPickerView: View {
private var elements = ["Sirene 187", "FC Clubbing completely new", "John", "Malkovich", "Santander stret 126 hood", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "FC Clubbing completely new 23"]
@State private var elementPositions: [CGRect]
@State private var selectedIndex: Int?
@State private var scrollViewWidth = CGFloat.zero
@Namespace private var ns
init() {
elementPositions = .init(repeating: .zero, count: elements.count)
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(Array(elements.enumerated()), id: \.offset) { index, element in
Text(element)
.padding()
.background {
RoundedRectangle(cornerRadius: 10)
.fill(selectedIndex == index ? .blue.opacity(0.2) : .gray.opacity(0.1))
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation {
selectedIndex = index
}
}
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .named(ns))
} action: { frame in
if frame != elementPositions[index] {
elementPositions[index] = frame
}
}
}
}
.padding(.horizontal, scrollViewWidth / 2)
.coordinateSpace(name: ns)
.scrollTargetLayout()
}
.scrollPosition(id: $selectedIndex, anchor: .center)
.scrollTargetBehavior(MidPositionAligned(selectedIndex: selectedIndex, elementPositions: elementPositions))
.onGeometryChange(for: CGFloat.self, of: \.size.width) { width in
scrollViewWidth = width
}
.onAppear {
selectedIndex = 10
}
}
}