SwiftUI ScrollView使用.paging或.viewAligned(limitBehavior: .alwaysByOne)时的吸附动画自定义问题
兄弟,我太懂你这种感受了!之前做全屏分页ScrollView的时候,也被这个默认的慢吸附动画折磨过——明明给ScrollView加了.animation(),结果手动滚动的吸附动画完全没变化,气死人!
先给你捋清楚问题出在哪:你给ScrollView加的.animation(.spring, value: selectedItem),只对编程式触发的滚动生效,也就是你主动修改selectedItem的时候的动画。而手动滚动松手后的吸附动画,是底层UIScrollView自带的“弹性吸附”逻辑,SwiftUI的原生修饰符根本碰不到它,默认用的就是那种拖拖拉拉的ease-out曲线,所以你会觉得结尾慢半拍。
那怎么解决呢?给你两个实用的方案,按需选:
方案一:用UIViewRepresentable包装,完全自定义动画(推荐)
这个方案可控性最强,能精准定义动画的时长、曲线,不管是手动滚动还是编程式滚动,动画逻辑完全由你掌控。
先写一个自定义的分页ScrollView组件:
struct CustomPagingScrollView<Content: View>: UIViewRepresentable { var content: Content @Binding var selectedIndex: Int // 自定义动画参数,你可以随便调 var animationDuration: TimeInterval = 0.2 var animationCurve: UIView.AnimationCurve = .easeIn init(selectedIndex: Binding<Int>, animationDuration: TimeInterval = 0.2, animationCurve: UIView.AnimationCurve = .easeIn, @ViewBuilder content: () -> Content) { self._selectedIndex = selectedIndex self.animationDuration = animationDuration self.animationCurve = animationCurve self.content = content() } func makeUIView(context: Context) -> UIScrollView { let scrollView = UIScrollView() scrollView.isPagingEnabled = true scrollView.showsVerticalScrollIndicator = false scrollView.delegate = context.coordinator // 把SwiftUI内容转成UIView加到ScrollView里 let hostingView = UIHostingController(rootView: content).view! hostingView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(hostingView) // 约束设置,保证每个item占满一屏 NSLayoutConstraint.activate([ hostingView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), hostingView.heightAnchor.constraint(equalTo: scrollView.heightAnchor, multiplier: CGFloat(selectedIndex + 1)), hostingView.topAnchor.constraint(equalTo: scrollView.topAnchor), hostingView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), hostingView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor) ]) return scrollView } func updateUIView(_ uiView: UIScrollView, context: Context) { // 处理编程式滚动的动画 let targetOffset = CGPoint(x: 0, y: CGFloat(selectedIndex) * uiView.bounds.height) if uiView.contentOffset != targetOffset { UIView.animate(withDuration: animationDuration, delay: 0, options: animationCurve.animationOption) { uiView.setContentOffset(targetOffset, animated: false) } } } func makeCoordinator() -> Coordinator { Coordinator(self) } // 处理UIScrollView的代理事件 class Coordinator: NSObject, UIScrollViewDelegate { var parent: CustomPagingScrollView init(_ parent: CustomPagingScrollView) { self.parent = parent } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { // 计算要吸附的目标页面偏移量 let pageHeight = scrollView.bounds.height let targetY = round(targetContentOffset.pointee.y / pageHeight) * pageHeight targetContentOffset.pointee.y = targetY // 自定义吸附动画 UIView.animate(withDuration: parent.animationDuration, delay: 0, options: parent.animationCurve.animationOption) { scrollView.setContentOffset(CGPoint(x: 0, y: targetY), animated: false) } // 更新选中的页码 parent.selectedIndex = Int(targetY / pageHeight) } } } // 给动画曲线加个扩展,转成UIView的动画选项 extension UIView.AnimationCurve { var animationOption: UIView.AnimationOptions { switch self { case .easeInOut: return .curveEaseInOut case .easeIn: return .curveEaseIn case .easeOut: return .curveEaseOut case .linear: return .curveLinear @unknown default: return .curveEaseInOut } } }
然后你就可以像用原生ScrollView一样用它了:
struct YourView: View { @State private var selectedItem = 0 let items = Array(0..<10) // 你的数据源 var body: some View { GeometryReader { geo in CustomPagingScrollView(selectedIndex: $selectedItem, animationDuration: 0.2, animationCurve: .easeIn) { LazyVStack(spacing: 0) { ForEach(items, id: \.self) { item in // 这里放你的每个页面的内容 Text("第 \(item + 1) 页") .frame(width: geo.size.width, height: geo.size.height) .background(Color(hue: Double(item)/10, saturation: 0.8, brightness: 0.9)) } } } } } }
你可以把animationDuration设成0.15-0.2秒,曲线用.easeIn或者.linear,这样吸附的时候就会非常干脆,完全符合你要的“snappier”的手感。
方案二:全局修改UIScrollView的减速速率(简单但不灵活)
如果你不想写这么多代码,只是想稍微改善一下手感,也可以试试全局调整UIScrollView的减速速率:
// 在你的App入口(比如App.swift的init里)加这段代码 init() { UIScrollView.appearance().decelerationRate = .fast }
这个操作会让所有ScrollView的滚动减速更快,吸附的时候也会显得更灵敏,不过它不是直接修改动画曲线,只是调整滚动的减速特性,好处是不用改现有代码,缺点是会影响App里所有的ScrollView,如果你只有这个分页ScrollView需要改的话,不太推荐。
最后再给你敲个重点:SwiftUI原生的ScrollView的.animation()修饰符,只负责状态变化触发的视图动画,管不到底层UIScrollView的滚动吸附逻辑,所以你之前的尝试才会没用。要改手动滚动的吸附动画,要么自己包装UIScrollView,要么用全局外观调整。
内容来源于stack exchange

