Last active
May 4, 2023 14:27
-
-
Save cjnevin/fc416e31cfcfb7f5f60a1fb401926f79 to your computer and use it in GitHub Desktop.
Infinite Horizontal ScrollView in SwiftUI (no bounce behaviour)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
struct ContentView: View { | |
@State var offset: CGFloat = 190 | |
@State var endOffset: CGFloat = 190 | |
@State var items: [Int] = [ | |
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 | |
] | |
var itemSpacing: CGFloat = 10 | |
var itemWidth: CGFloat = 180 | |
var itemWidthWithSpacing: CGFloat { | |
itemWidth + itemSpacing | |
} | |
var itemHeightMultiplier: CGFloat = 0.7 | |
var itemHeight: CGFloat { | |
itemWidth * itemHeightMultiplier | |
} | |
var contentWidth: CGFloat { | |
CGFloat(items.count) * (itemWidth + itemSpacing) | |
} | |
func position(at index: Int) -> CGPoint { | |
var x = offset + (CGFloat(index) * itemWidthWithSpacing) | |
if x < 0 { | |
x += contentWidth | |
} | |
// Wrap items when offset is greater than content width | |
// Subtract half item width to get center position | |
let halfItemWidth = itemWidthWithSpacing / 2 | |
let proposedX = x.truncatingRemainder(dividingBy: contentWidth) - halfItemWidth | |
let proposedY = itemHeight / 2 | |
return CGPoint(x: proposedX, y: proposedY) | |
} | |
func isValidPosition(at index: Int, proxy: GeometryProxy) -> Bool { | |
(-itemWidth...proxy.size.width + itemWidth / 2) ~= position(at: index).x | |
} | |
var body: some View { | |
VStack { | |
Text("Header") | |
GeometryReader { reader in | |
ZStack { | |
ForEach($items.indices, id: \.description) { index in | |
if isValidPosition(at: index, proxy: reader) { | |
ItemView(text: "\(items[index])") | |
.frame(width: itemWidth, height: itemHeight) | |
.position(position(at: index)) | |
} | |
} | |
} | |
} | |
.frame(height: itemHeight) | |
.gesture( | |
DragGesture() | |
.onChanged({ gesture in | |
offset = endOffset + gesture.translation.width | |
}) | |
.onEnded({ gesture in | |
// Avoid overflow by truncating value based on contentWidth | |
endOffset = offset.truncatingRemainder(dividingBy: contentWidth) | |
}) | |
) | |
Text("Footer") | |
Spacer() | |
} | |
} | |
} | |
struct ItemView: View { | |
let text: String | |
var body: some View { | |
ZStack { | |
Rectangle().fill(Color.blue) | |
Text(text) | |
} | |
.cornerRadius(10) | |
.onAppear { | |
print("appeared \(text)") | |
} | |
.onDisappear { | |
print("disappeared \(text)") | |
} | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment