I am trying to create a horizontal scroll view in SwiftUI where I can move only one item at a time, and I want to maintain the proportions of the peeking view (i.e., the portion of the item that remains visible when scrolling).
I would like to avoid using GeometryReader if possible, and the solution should support iOS 16 and above (so, I cannot use .scrollTargetBehavior(.viewAligned) or .scrollTargetLayout() since these features are only available in iOS 17 and above).
Here's the code I've written, which only works for iOS 17 and above. How can I modify it to make it compatible with iOS 16 and still keep the same layout logic without breaking the view or functionality?
let itemCount = 5 // Number of items
var body: some View {
let screenWidth = UIScreen.main.bounds.width
let itemWidth: CGFloat = screenWidth - (16 + 30 + 48)
let spacingValue: CGFloat = 8 // Define your spacing value
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.black)
.frame(width: screenWidth - 48, height: 400)
ScrollView(.horizontal, showsIndicators: false) {
if #available(iOS 17.0, *) {
LazyHStack(spacing: spacingValue) {
ForEach(0..<itemCount, id: \.self) { i in
RoundedRectangle(cornerRadius: 25)
.fill(Color(hue: Double(i) * 10, saturation: 1, brightness: 1).gradient)
.frame(width: itemWidth, height: 200)
}
}
.padding(.horizontal)
.scrollTargetLayout()
} else {
// Fallback on earlier versions
}
}
.scrollTargetBehavior(.viewAligned)
.safeAreaPadding(.horizontal, itemCount == 1 ? 8 : 0)
.environment(\.layoutDirection, .rightToLeft)
.frame(width: screenWidth - 48, height: 200)
.padding(.top, 100)
}
.onAppear {
// Print the item width and spacing value to the console
print("Item width: \(itemWidth), screen Width: \(screenWidth), spacing: \(spacingValue)")
}
}
}
**
**
How can I implement the ability to move only one item at a time (while maintaining the proportions of the peeking view) without using iOS 17-specific methods? What alternative strategies can I use to achieve the desired behavior in iOS 16, such as snapping items or limiting the scrolling speed? Is there any way to maintain the visual consistency and layout (including the safe area and padding) without .scrollTargetBehavior(.viewAligned) and .scrollTargetLayout()? Additional Context:
I also tried using UIScrollView.appearance().isPagingEnabled = true, but this causes the scroll view to behave incorrectly, skipping over items and sometimes snapping to the middle or end of each item instead of moving one item at a time proportionally. How can I fix this behavior while maintaining a smooth and consistent scroll?
Any help or suggestions would be appreciated!
If you know the width of the screen and the width of the items then you can implement your own scrollable viewport using an HStack
with a DragGesture
.
In your example, you are deriving the width of the items from the screen width, so I guess it is reasonable to assume that the widths are known.
To find the screen width, it is really better to use a GeometryReader
instead of UIScreen.main
. UIScreen.main
is deprecated and doesn't work with iPad split screen.
Here is a simplified version of your example to show it working. Some notes:
The spacing for the HStack
also needs to be known. I guess this is no big deal.
Horizontal padding has been added to the HStack
, to ensure that the first and last items are centered.
Apply .fixedSize()
to the HStack
, to allow it to consume all the width it needs to fit the contents. Then apply a frame to constrain the width and clip to this frame, to hide the overflow.
The selected item is moved into the visible position using .offset
.
The drag offset is applied as an additional offset.
A GestureState
variable can be used to record the drag offset. This automatically resets to 0 at end of drag.
You were originally setting the environment value layoutDirection
to .rightToLeft
(which had me very confused, until I noticed it!). If you want to keep it that way, the drag gesture will need to be interpreted in reverse.
Also, you were originally using a LazyHStack
as container for the views. If you wanted to keep it semi-lazy, you could consider optimizing the contents of the HStack
so that there are never more than, say, 3 subviews on view at any time. Then implement your own logic for deciding which 3 views are shown and what the adjusted offset for the HStack
needs to be. Getting changes to animate smoothly may be the hardest part.
struct ContentView: View {
let itemCount = 5 // Number of items
let spacingValue: CGFloat = 8 // Define your spacing value
let sideMargin: CGFloat = 24
@State private var selectedIndex: Int?
@GestureState private var dragOffset = CGFloat.zero
var body: some View {
GeometryReader { proxy in
let screenWidth = proxy.size.width
let itemWidth: CGFloat = screenWidth - (16 + 30 + 48)
HStack(spacing: spacingValue) {
ForEach(0..<itemCount, id: \.self) { i in
RoundedRectangle(cornerRadius: 25)
.fill(Color(hue: Double(i) * 0.1, saturation: 1, brightness: 1).gradient)
.overlay {
Text("\(i)")
.font(.largeTitle)
.foregroundStyle(.white)
}
.frame(width: itemWidth, height: 200)
}
}
.padding(.horizontal, ((screenWidth - itemWidth) / 2) - sideMargin)
.fixedSize()
.offset(x: -CGFloat(selectedIndex ?? 0) * (itemWidth + spacingValue))
.offset(x: dragOffset)
.frame(width: screenWidth - (2 * sideMargin), alignment: .leading)
.clipped()
.animation(.easeInOut, value: selectedIndex)
.animation(.easeInOut(duration: 0.1), value: dragOffset)
.gesture(
DragGesture(minimumDistance: 0)
.updating($dragOffset) { val, state, trans in
state = val.translation.width
}
.onEnded { val in
let dx = val.translation.width
if dx > 0 {
selectedIndex = max(0, (selectedIndex ?? 0) - 1)
} else if dx < 0 {
selectedIndex = min(itemCount - 1, (selectedIndex ?? 0) + 1)
}
}
)
.frame(maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 25)
.fill(Color.black)
.frame(maxWidth: .infinity)
}
.padding(.horizontal, sideMargin)
}
.frame(height: 400)
// .environment(\.layoutDirection, .rightToLeft)
}
}