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

自定义ScrollView用UIPanGestureRecognizer滚动时上下反复抖动怎么办

问题描述

我尝试参考矢量插画软件的设计思路,用UIBezier和UILabel实现类似日历应用的时间线效果,再通过UIPanGestureRecognizer实现上下滚动功能。但在模拟器中滚动时,内容没有按预期正常移动,反而反复叠加渲染(我在scrollValue变化时调用了setNeedsDisplay方法重绘整个视图),大概率是代码某处的小问题导致的。

我知道可以直接用UIScrollView或者UITableView实现这个效果,但我平时习惯用CAD绘图和矢量插画工具,想尝试自定义实现表格作为小挑战,我觉得直接用现成组件限制太多。

模拟器中的问题效果如下图:
项目运行效果示意图

我用的实现代码如下:

import UIKit

class ViewController: UIViewController {
    
    var tlobject = TimelineView()
    let gesto = UIPanGestureRecognizer()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // ===== 把TimelineView添加到父视图
        let TLObjectFrame = CGRect(x: 0, y: 40, width: 100, height: 100)
        tlobject = TimelineView(frame: TLObjectFrame)
        view.addSubview(tlobject)
        
        // ===== 添加拖动手势
        gesto.addTarget(self, action: #selector(touchinput))
        view.addGestureRecognizer(gesto)
    }
    
    var touchStartLocation: Int = 0
    var scrollDistance: Int = 0
    var lastScrollDistance: Int = 0
    
    // 以下方法计算Y轴上的拖动距离,把结果值scrollDistance传给时间线组件,用来调整每个UIBezier的Y坐标。感谢Youtube上Mitchell Hudson的教程"06 11 touches value"提供的实现思路
    @objc func touchinput (sender: UIPanGestureRecognizer) {
        
        if sender.state == UIGestureRecognizer.State.began {
            touchStartLocation = Int(sender.location(in: view).y)
            lastScrollDistance = scrollDistance
        }
        
        if sender.state == UIGestureRecognizer.State.changed {
            let touchEndLocation = Int(sender.location(in: view).y)
            let currentScrollDistance = touchEndLocation - touchStartLocation
            print("deltaY", currentScrollDistance)
            var newScrollDistance = lastScrollDistance + currentScrollDistance
            scrollDistance = newScrollDistance
            
            tlobject.totalScrollDistance = scrollDistance //把滚动值传给TimelineView
        }
        
        if sender.state == UIGestureRecognizer.State.ended {
            print("lastScrollDistance", lastScrollDistance)
            print("scroll Distance", scrollDistance)
        }
    }
}

// 自定义时间线视图
class TimelineView: UIView {
    
    var totalScrollDistance: Int = 0 {
        didSet{
            setNeedsDisplay() //每次手势位置变化都会触发重绘
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor.clear
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func draw(_ rect: CGRect) {
        timelinemechanism()
    }
    
    func timelinemechanism() {
        
        let lineElements: Array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
        let spacing: Int = 30
        let scrollDistance: Int = totalScrollDistance
        let totalElements: Int = lineElements.count
        
        for n in 1...totalElements {
            
            // 计算UILabel/分割线的屏幕Y坐标 = 数组下标 * 间距 + 手势滚动距离
            let yPosition = lineElements[n - 1] * spacing + scrollDistance
            
            let linepath = UIBezierPath()
            linepath.move(to: CGPoint(x: 60, y: yPosition))
            linepath.addLine(to: CGPoint(x: 300, y: yPosition))
            
            let lineshape = CAShapeLayer()
            lineshape.path = linepath.cgPath
            lineshape.strokeColor = UIColor.blue.cgColor
            self.layer.addSublayer(lineshape)
            
            let hourlabel = UILabel()
            hourlabel.frame = CGRect(x: 5, y: yPosition - 20, width: 45, height: 40)
            hourlabel.text = "\(n):00"
            hourlabel.textColor = UIColor.blue
            hourlabel.textAlignment = NSTextAlignment.right
            self.addSubview(hourlabel)
        }
    }
}
问题原因
  • 每次滚动触发setNeedsDisplay()后,timelinemechanism()方法都会新建10个CAShapeLayer和10个UILabel添加到视图上,旧的图层和标签没有被移除,多次调用后就会出现内容叠加的问题
  • 额外小问题:初始化TimelineView时设置的宽度只有100,但是分割线的终点x坐标是300,超出视图范围的内容会显示异常。
修复方案
  1. 每次执行timelinemechanism()前,先移除之前添加的所有自定义子视图和自定义图层
  2. 调整TimelineView的初始化宽度,适配内容显示范围
  3. 可选:开启视图的clipsToBounds,避免超出视图范围的内容显示
修改后完整代码
import UIKit

class ViewController: UIViewController {
    
    var tlobject = TimelineView()
    let gesto = UIPanGestureRecognizer()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 调整TimelineView的宽高适配屏幕和内容
        let TLObjectFrame = CGRect(x: 0, y: 40, width: 350, height: UIScreen.main.bounds.height - 80)
        tlobject = TimelineView(frame: TLObjectFrame)
        // 开启裁剪,超出视图范围的内容不显示
        tlobject.clipsToBounds = true
        view.addSubview(tlobject)
        
        gesto.addTarget(self, action: #selector(touchinput))
        view.addGestureRecognizer(gesto)
    }
    
    var touchStartLocation: Int = 0
    var scrollDistance: Int = 0
    var lastScrollDistance: Int = 0
    
    @objc func touchinput (sender: UIPanGestureRecognizer) {
        if sender.state == .began {
            touchStartLocation = Int(sender.location(in: view).y)
            lastScrollDistance = scrollDistance
        }
        
        if sender.state == .changed {
            let touchEndLocation = Int(sender.location(in: view).y)
            let currentScrollDistance = touchEndLocation - touchStartLocation
            let newScrollDistance = lastScrollDistance + currentScrollDistance
            scrollDistance = newScrollDistance
            tlobject.totalScrollDistance = scrollDistance
        }
    }
}

class TimelineView: UIView {
    
    var totalScrollDistance: Int = 0 {
        didSet{
            setNeedsDisplay()
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = .clear
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func draw(_ rect: CGRect) {
        timelinemechanism()
    }
    
    func timelinemechanism() {
        // 清空之前添加的所有子标签
        subviews.forEach { $0.removeFromSuperview() }
        // 清空之前添加的所有分割线图层
        layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
        
        let lineElements = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
        let spacing = 30
        let totalElements = lineElements.count
        
        for n in 1...totalElements {
            let yPosition = lineElements[n - 1] * spacing + totalScrollDistance
            
            let linepath = UIBezierPath()
            linepath.move(to: CGPoint(x: 60, y: yPosition))
            linepath.addLine(to: CGPoint(x: 300, y: yPosition))
            
            let lineshape = CAShapeLayer()
            lineshape.path = linepath.cgPath
            lineshape.strokeColor = UIColor.blue.cgColor
            layer.addSublayer(lineshape)
            
            let hourlabel = UILabel()
            hourlabel.frame = CGRect(x: 5, y: yPosition - 20, width: 45, height: 40)
            hourlabel.text = "\(n):00"
            hourlabel.textColor = .blue
            hourlabel.textAlignment = .right
            addSubview(hourlabel)
        }
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.10.04 03:27:01