iosswiftswiftuihstackswiftui-scrollview

Detect when the user has scrolled to the end of a horizontal ScrollView in SwiftUI


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 ?


Solution

  • 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
                }
            }
        }
    }