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
相关产品推荐
相关产品推荐

