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

SwiftUI:如何判断ScrollView停止滚动、处理回调并让选中项居中?

SwiftUI ScrollView 滚动状态判断与居中调整方案

1. 判断ScrollView是否已停止滚动

SwiftUI原生ScrollView没有直接提供滚动停止的回调,需通过桥接UIKit的UIScrollView利用其代理机制实现。核心是通过scrollViewDidEndDecelerating(滚动减速至停止时触发)和scrollViewDidEndDragging(_:willDecelerate:)(拖拽结束后,若willDecelerate为false则直接停止滚动)这两个代理方法来判定滚动停止状态。

2. 处理scrollViewDidEndDecelerating和scrollViewDidEndDragging方法

通过自定义UIViewRepresentable包装UIScrollView,并实现其代理逻辑,将滚动状态回调传递给SwiftUI视图:

  • 创建ScrollViewWithDelegate结构体,遵循UIViewRepresentable协议,在makeUIView方法中配置UIScrollView并设置代理。
  • 内部嵌套Coordinator类遵循UIScrollViewDelegate,实现两个目标代理方法,更新滚动状态并触发居中调整逻辑。

3. 滚动停止时让选中项居中

当滚动停止时,计算选中项对应的目标偏移量,调用UIScrollView的setContentOffset(_:animated:)方法调整位置,确保选中项中心与屏幕中心对齐:

  • 编写calculateTargetOffset方法,根据选中索引、项宽度、间距和屏幕宽度计算目标偏移值。
  • 在代理方法中调用该方法,自动滚动到目标位置。

完整实现代码

import SwiftUI

struct ScrollViewWithDelegate<Content: View>: UIViewRepresentable {
    let axis: Axis.Set
    let showsIndicators: Bool
    @Binding var isScrolling: Bool
    @Binding var selectedIndex: Int
    let itemWidth: CGFloat
    let spacing: CGFloat
    let content: () -> Content
    
    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()
        scrollView.delegate = context.coordinator
        scrollView.showsHorizontalScrollIndicator = showsIndicators && axis.contains(.horizontal)
        scrollView.showsVerticalScrollIndicator = showsIndicators && axis.contains(.vertical)
        scrollView.isPagingEnabled = false
        
        let hostingController = UIHostingController(rootView: content())
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(hostingController.view)
        
        NSLayoutConstraint.activate([
            hostingController.view.topAnchor.constraint(equalTo: scrollView.topAnchor),
            hostingController.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
            hostingController.view.heightAnchor.constraint(equalTo: scrollView.heightAnchor)
        ])
        
        return scrollView
    }
    
    func updateUIView(_ uiView: UIScrollView, context: Context) {
        let targetOffset = calculateTargetOffset(for: selectedIndex)
        if uiView.contentOffset.x != targetOffset {
            uiView.setContentOffset(CGPoint(x: targetOffset, y: 0), animated: true)
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        let parent: ScrollViewWithDelegate
        
        init(parent: ScrollViewWithDelegate) {
            self.parent = parent
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            parent.isScrolling = true
            let itemTotalWidth = parent.itemWidth + parent.spacing
            let index = Int(round(scrollView.contentOffset.x / itemTotalWidth))
            parent.selectedIndex = max(0, min(index, parent.imageCount - 1))
        }
        
        func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
            parent.isScrolling = false
            adjustToCenter(scrollView: scrollView)
        }
        
        func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
            if !decelerate {
                parent.isScrolling = false
                adjustToCenter(scrollView: scrollView)
            }
        }
        
        private func adjustToCenter(scrollView: UIScrollView) {
            let targetOffset = parent.calculateTargetOffset(for: parent.selectedIndex)
            scrollView.setContentOffset(CGPoint(x: targetOffset, y: 0), animated: true)
        }
    }
    
    private var imageCount: Int {
        (content() as? HStack<AnyView>)?.children.count ?? 0
    }
    
    private func calculateTargetOffset(for index: Int) -> CGFloat {
        let itemTotalWidth = itemWidth + spacing
        let screenWidth = UIScreen.main.bounds.width
        return (itemTotalWidth * CGFloat(index)) + (itemWidth / 2) - (screenWidth / 2)
    }
}

struct ContentView: View {
    @State private var imageList: [String] = ["Onur","Onur","Onur","Onur","Onur","Onur","Onur","Onur","Onur"]
    private let itemWidth: CGFloat = 100
    private var spacing: CGFloat { UIScreen.main.bounds.width / 2 - itemWidth }
    @State private var isScrolling: Bool = false
    @State private var selectedIndex: Int = 0
    
    var body: some View {
        ScrollViewWithDelegate(
            axis: .horizontal,
            showsIndicators: false,
            isScrolling: $isScrolling,
            selectedIndex: $selectedIndex,
            itemWidth: itemWidth,
            spacing: spacing
        ) {
            HStack(spacing: spacing) {
                Spacer().frame(width: UIScreen.main.bounds.width / 2 - itemWidth / 2)
                ForEach(Array(imageList.enumerated()), id: \.element) { index, image in
                    Image(image)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: itemWidth, height: itemWidth)
                        .clipShape(Circle())
                        .overlay(Circle().stroke(.blue, lineWidth: index == selectedIndex ? 4 : 0))
                }
                Spacer().frame(width: UIScreen.main.bounds.width / 2 - itemWidth / 2)
            }
            .frame(height: itemWidth)
        }
    }
}

代码说明

  • ScrollViewWithDelegate:桥接UIKit的UIScrollView,实现代理方法监听滚动状态,实时更新选中索引,并在滚动停止时自动调整选中项到屏幕中心。
  • calculateTargetOffset:精准计算选中项需要的偏移量,确保其中心与屏幕中心对齐。
  • ContentView:使用自定义滚动视图,调整左右边距保证首尾项也能居中显示,根据选中状态高亮对应项的边框。

内容的提问来源于stack exchange,提问作者onur.altintas

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.04 10:12:08