I have created a horizontal carousel using a ScrollView
with an HStack
.
This is my code:
struct CarouselView<Content: View>: View {
let content: Content
private let spacing: CGFloat
private let shouldSnap: Bool
init(spacing: CGFloat = .zero,
shouldSnap: Bool = false,
@ViewBuilder content: @escaping () -> Content) {
self.content = content()
self.spacing = spacing
self.shouldSnap = shouldSnap
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: spacing) {
content
}.apply {
if #available(iOS 17.0, *), shouldSnap {
$0.scrollTargetLayout()
} else {
$0
}
}
}
.clipped()
.apply {
if #available(iOS 17.0, *), shouldSnap {
$0.scrollTargetBehavior(.viewAligned)
} else {
$0
}
}
}
}
I then can use it as follows:
CarouselView(spacing: 10) {
ForEach(0 ..< imagesNames.count, id: \.self) { index in
DemoView()
}
}
How can I detect when the user has scrolled to the end of the scroll view.
I've tried the some answers here - the main one of adding a view at the end of the hstack and doing the work on the onAppear
of that view does not work as it is fired immediately as the HStack is created.
This could work if I used a LazyHStack
- however, I have to use an HStack due to some limitations.
Are there any other ways to achieve this ?
You can use this DetectOnScreen
view modifier I wrote in this answer to detect whether an invisible view is on screen.
struct IsOnScreenKey: PreferenceKey {
static let defaultValue: Bool? = nil
static func reduce(value: inout Value, nextValue: () -> Value) {
if let next = nextValue() {
value = next
}
}
}
struct DetectIsOnScreen: ViewModifier {
func body(content: Content) -> some View {
GeometryReader { reader in
content
.preference(
key: IsOnScreenKey.self,
// bounds(of: .scrollView) gives us the scroll view's frame
// frame(in: .local) gives us the frame of the invisible view
// both are in the local coordinate space
value: reader.bounds(of: .scrollView)?.intersects(reader.frame(in: .local)) ?? false
)
}
}
}
Usage:
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: spacing) {
content
Color.clear
.frame(width: 0, height: 0)
.modifier(DetectIsOnScreen())
.onPreferenceChange(IsOnScreenKey.self) { value in
if value == true {
print("Has reached end!")
}
}
}
}
This does mean the HStack
will have on extra view, so there will be some extra space (of size spacing
) between the last view and the invisible view. If this is undesirable, you can wrap the last view in its own HStack
and put the invisible view in the inner HStack
. But this needs to be done at the use-site.
ForEach(0..<imagesNames.count, id: \.self) { index in
if index == imageNames.count - 1 {
HStack(spacing: 0) {
DemoView()
Color.clear
.frame(width: 0, height: 0)
...
}
} else { DemoView() }
}
Of if you don't mind using View Extractor, you can do this in CarouselView
HStack(spacing: spacing) {
ExtractMulti(content) { views in
ForEach(views) { view in
if view.id == views.last?.id {
HStack(spacing: 0) {
view
Color.clear
.frame(width: 0, height: 0)
...
}
} else {
view
}
}
}
}