You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI中如何检测水平ScrollView滚动至末尾?

检测ScrollView+HStack水平轮播的滚动末尾

问题场景

我用ScrollView搭配HStack实现了一个水平轮播组件,代码如下:

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

使用方式:

CarouselView(spacing: 10) {
    ForEach(0 ..< imagesNames.count, id: \.self) { index in
        DemoView()
    }
}

需要检测用户是否滚动到ScrollView末尾,尝试过在HStack末尾添加视图并通过onAppear触发,但HStack初始化时就会触发该事件,无法正常工作;且因限制必须使用HStack,不能替换为LazyHStack,求其他实现方式。


方案1:利用PreferenceKey监听滚动偏移与内容宽度

通过自定义PreferenceKey获取HStack总宽度和ScrollView的滚动偏移量,当偏移量达到「内容宽度 - ScrollView可视宽度」时,判定为滚动到末尾。

1. 定义PreferenceKey

// 监听内容总宽度
struct ContentWidthKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

// 监听滚动偏移量
struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

2. 修改CarouselView实现

新增滚动末尾回调,添加尺寸与偏移监听逻辑:

struct CarouselView<Content: View>: View {
    let content: Content
    private let spacing: CGFloat
    private let shouldSnap: Bool
    // 滚动到末尾的回调
    let onScrollToEnd: () -> Void
    
    @State private var contentWidth: CGFloat = 0
    @State private var scrollViewWidth: CGFloat = 0
    @State private var scrollOffset: CGFloat = 0
    
    init(spacing: CGFloat = .zero,
         shouldSnap: Bool = false,
         onScrollToEnd: @escaping () -> Void,
         @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.spacing = spacing
        self.shouldSnap = shouldSnap
        self.onScrollToEnd = onScrollToEnd
    }
    
    var body: some View {
        GeometryReader { scrollViewGeo in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: spacing) {
                    content
                }
                .apply {
                    if #available(iOS 17.0, *), shouldSnap {
                        $0.scrollTargetLayout()
                    } else {
                        $0
                    }
                }
                // 监听HStack总宽度
                .background(
                    GeometryReader { contentGeo in
                        Color.clear
                            .preference(key: ContentWidthKey.self, value: contentGeo.size.width)
                    }
                )
                // 监听滚动偏移
                .background(
                    GeometryReader { proxy in
                        Color.clear
                            .preference(key: ScrollOffsetKey.self, value: proxy.frame(in: .named("scroll")).minX)
                    }
                )
            }
            .coordinateSpace(name: "scroll")
            .onPreferenceChange(ContentWidthKey.self) {
                contentWidth = $0
                scrollViewWidth = scrollViewGeo.size.width
            }
            .onPreferenceChange(ScrollOffsetKey.self) { newOffset in
                scrollOffset = -newOffset
                // 预留10pt误差避免边界误触发,可按需调整
                let threshold = contentWidth - scrollViewWidth - 10
                if scrollOffset >= threshold && scrollOffset <= contentWidth - scrollViewWidth {
                    onScrollToEnd()
                }
            }
            .clipped()
            .apply {
                if #available(iOS 17.0, *), shouldSnap {
                    $0.scrollTargetBehavior(.viewAligned)
                } else {
                    $0
                }
            }
        }
    }
}

使用示例

CarouselView(spacing: 10, onScrollToEnd: {
    print("已滚动到轮播末尾")
}) {
    ForEach(0 ..< imagesNames.count, id: \.self) { index in
        DemoView()
    }
}

方案2:结合ScrollViewReader监听滚动位置

如果内容元素有唯一ID,可通过ScrollViewReader获取当前滚动位置,判断最后一个元素是否进入可视区域。

修改CarouselView实现

struct CarouselView<Content: View>: View {
    let content: Content
    private let spacing: CGFloat
    private let shouldSnap: Bool
    let onScrollToEnd: () -> Void
    // 传入内容元素的唯一ID集合
    let itemIds: [String]
    
    init(spacing: CGFloat = .zero,
         shouldSnap: Bool = false,
         itemIds: [String],
         onScrollToEnd: @escaping () -> Void,
         @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.spacing = spacing
        self.shouldSnap = shouldSnap
        self.itemIds = itemIds
        self.onScrollToEnd = onScrollToEnd
    }
    
    var body: some View {
        ScrollViewReader { proxy in
            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
                }
            }
            // 监听滚动位置变化
            .onChange(of: proxy.scrollPosition) { position in
                guard let lastId = itemIds.last, position == lastId else { return }
                onScrollToEnd()
            }
        }
    }
}

使用示例

// 假设imagesNames是元素唯一ID集合
CarouselView(spacing: 10, itemIds: imagesNames, onScrollToEnd: {
    print("已滚动到轮播末尾")
}) {
    ForEach(imagesNames, id: \.self) { name in
        DemoView()
            .id(name) // 给每个元素绑定唯一ID
    }
}

注意事项

  • 方案1的阈值预留了10pt误差,避免滚动精度问题导致多次触发,可根据需求调整。
  • 方案2要求内容元素必须有唯一ID,适合元素明确的场景。

内容的提问来源于stack exchange,提问作者Shawn Frank

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 16:34:55