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

Swift中实现ScrollView恒定速度自动滚动的方案咨询

SwiftUI 实现恒定速度自动滚动(含速度调节)

核心思路

通过CADisplayLink(与屏幕刷新率同步的定时器)驱动滚动偏移量持续更新,配合滑块控制速度参数,实现平滑的恒定速度自动滚动。相比普通Timer,CADisplayLink能避免滚动卡顿,更适合这类需要流畅动画的场景。

代码实现

首先封装一个可复用的自动滚动ScrollView组件:

import SwiftUI

struct AutoScrollView<Content: View>: UIViewRepresentable {
    var content: Content
    @Binding var isAutoScrolling: Bool
    @Binding var scrollSpeed: CGFloat // 单位:点/秒
    private let scrollView = UIScrollView()
    
    init(isAutoScrolling: Binding<Bool>, scrollSpeed: Binding<CGFloat>, @ViewBuilder content: () -> Content) {
        self._isAutoScrolling = isAutoScrolling
        self._scrollSpeed = scrollSpeed
        self.content = content()
    }
    
    func makeUIView(context: Context) -> UIScrollView {
        let hostingView = UIHostingController(rootView: content).view!
        hostingView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(hostingView)
        
        NSLayoutConstraint.activate([
            hostingView.topAnchor.constraint(equalTo: scrollView.topAnchor),
            hostingView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            hostingView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            hostingView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
            hostingView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
        ])
        
        scrollView.delegate = context.coordinator
        context.coordinator.setupDisplayLink()
        return scrollView
    }
    
    func updateUIView(_ uiView: UIScrollView, context: Context) {
        context.coordinator.isAutoScrolling = isAutoScrolling
        context.coordinator.scrollSpeed = scrollSpeed
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        var parent: AutoScrollView
        var isAutoScrolling: Bool = false
        var scrollSpeed: CGFloat = 0
        private var displayLink: CADisplayLink?
        private var lastTimestamp: CFTimeInterval = 0
        
        init(parent: AutoScrollView) {
            self.parent = parent
        }
        
        func setupDisplayLink() {
            displayLink = CADisplayLink(target: self, selector: #selector(updateScrollPosition))
            displayLink?.add(to: .main, forMode: .common)
        }
        
        @objc func updateScrollPosition(_ link: CADisplayLink) {
            guard isAutoScrolling, scrollSpeed > 0, lastTimestamp != 0 else {
                lastTimestamp = link.timestamp
                return
            }
            
            let deltaTime = link.timestamp - lastTimestamp
            let deltaY = scrollSpeed * CGFloat(deltaTime)
            let maxOffsetY = parent.scrollView.contentSize.height - parent.scrollView.bounds.height
            let newOffsetY = min(parent.scrollView.contentOffset.y + deltaY, maxOffsetY)
            
            parent.scrollView.setContentOffset(CGPoint(x: 0, y: newOffsetY), animated: false)
            lastTimestamp = link.timestamp
        }
        
        // 手动滚动时自动暂停(可选逻辑)
        func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
            isAutoScrolling = false
        }
    }
}

然后是使用该组件的示例页面:

struct AutoScrollDemoView: View {
    @State private var isAutoScrolling = false
    @State private var scrollSpeed: CGFloat = 50 // 初始速度50点/秒
    
    var body: some View {
        VStack(spacing: 16) {
            // 控制栏
            HStack {
                Toggle("自动滚动", isOn: $isAutoScrolling)
                Slider(value: $scrollSpeed, in: 0...200)
                Text(String(format: "%.0f 点/秒", scrollSpeed))
            }
            .padding(.horizontal)
            
            // 自动滚动区域
            AutoScrollView(isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed) {
                VStack(spacing: 20) {
                    ForEach(1...50, id: \.self) { index in
                        Text("第 \(index) 行 - 吉他和弦内容示例")
                            .font(.title)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(.horizontal)
                    }
                }
            }
            .border(.gray)
        }
        .padding()
    }
}

关键细节

  • scrollSpeed直接控制每秒滚动的像素点数,滑块调节实时生效
  • 限制滚动边界,避免滚出内容区域
  • 手动拖动时自动暂停自动滚动,提升交互体验

UIKit 实现方案

核心思路

直接基于原生UIScrollView,用CADisplayLink驱动contentOffset更新,配合UISlider调节速度,逻辑与SwiftUI版本一致,适合纯UIKit项目。

代码实现

import UIKit

class AutoScrollViewController: UIViewController {
    private let scrollView = UIScrollView()
    private let contentView = UIView()
    private let autoScrollToggle = UISwitch()
    private let speedSlider = UISlider()
    private let speedLabel = UILabel()
    
    private var displayLink: CADisplayLink?
    private var isAutoScrolling = false {
        didSet { autoScrollToggle.isOn = isAutoScrolling }
    }
    private var scrollSpeed: CGFloat = 50 {
        didSet { speedLabel.text = String(format: "%.0f 点/秒", scrollSpeed) }
    }
    private var lastTimestamp: CFTimeInterval = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupDisplayLink()
        populateContent()
    }
    
    private func setupUI() {
        // 顶部控制栏
        let controlStack = UIStackView(arrangedSubviews: [
            UILabel(text: "自动滚动"), autoScrollToggle,
            UILabel(text: "速度"), speedSlider, speedLabel
        ])
        controlStack.axis = .horizontal
        controlStack.spacing = 12
        controlStack.alignment = .center
        
        // 布局配置
        [controlStack, scrollView].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }
        contentView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(contentView)
        
        NSLayoutConstraint.activate([
            controlStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
            controlStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            controlStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            
            scrollView.topAnchor.constraint(equalTo: controlStack.bottomAnchor, constant: 16),
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),
            
            contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
            contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
            contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
        ])
        
        // 控件事件绑定
        autoScrollToggle.addTarget(self, action: #selector(toggleAutoScroll), for: .valueChanged)
        speedSlider.minimumValue = 0
        speedSlider.maximumValue = 200
        speedSlider.value = Float(scrollSpeed)
        speedSlider.addTarget(self, action: #selector(updateSpeed), for: .valueChanged)
        speedLabel.text = String(format: "%.0f 点/秒", scrollSpeed)
    }
    
    private func populateContent() {
        var lastView: UIView? = nil
        for index in 1...50 {
            let label = UILabel()
            label.text = "第 \(index) 行 - 吉他和弦内容示例"
            label.font = .systemFont(ofSize: 24)
            label.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(label)
            
            NSLayoutConstraint.activate([
                label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
                label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
                label.topAnchor.constraint(equalTo: lastView?.bottomAnchor ?? contentView.topAnchor, constant: 20)
            ])
            
            lastView = label
        }
        
        // 确保内容高度足够
        if let lastView = lastView {
            contentView.bottomAnchor.constraint(equalTo: lastView.bottomAnchor, constant: 20).isActive = true
        }
    }
    
    private func setupDisplayLink() {
        displayLink = CADisplayLink(target: self, selector: #selector(updateScrollPosition))
        displayLink?.add(to: .main, forMode: .common)
    }
    
    @objc private func toggleAutoScroll() {
        isAutoScrolling = autoScrollToggle.isOn
        lastTimestamp = 0 // 重置时间戳避免首次滚动跳变
    }
    
    @objc private func updateSpeed() {
        scrollSpeed = CGFloat(speedSlider.value)
    }
    
    @objc private func updateScrollPosition(_ link: CADisplayLink) {
        guard isAutoScrolling, scrollSpeed > 0, lastTimestamp != 0 else {
            lastTimestamp = link.timestamp
            return
        }
        
        let deltaTime = link.timestamp - lastTimestamp
        let deltaY = scrollSpeed * CGFloat(deltaTime)
        let maxOffsetY = scrollView.contentSize.height - scrollView.bounds.height
        let newOffsetY = min(scrollView.contentOffset.y + deltaY, maxOffsetY)
        
        scrollView.setContentOffset(CGPoint(x: 0, y: newOffsetY), animated: false)
        lastTimestamp = link.timestamp
    }
    
    // 手动滚动时暂停自动滚动
    override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        isAutoScrolling = false
    }
}

// 简化UILabel初始化的扩展
extension UILabel {
    convenience init(text: String) {
        self.init()
        self.text = text
    }
}

内容的提问来源于stack exchange,提问作者Kelvin Jou

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.06 13:50:30