1. 跑马灯效果的核心需求与场景拆解跑马灯这个交互组件但凡做过移动端开发的人都不陌生。它本质上就是让一行文字或者一组内容在有限宽度的容器里持续滚动把超出屏幕的信息循环展示出来。听起来简单但真正动手写的时候你会发现坑比想象中多得多。我在实际项目里第一次接触跑马灯是做一个电商类App的首页公告栏。需求很明确一排文字从右往左匀速滚动滚完一轮无缝衔接下一轮不能有卡顿、不能有跳跃感。当时觉得这不就是个动画吗半小时搞定。结果调了整整两天中间经历了文字抖动、循环断档、内存泄漏、后台切换回来动画错位等一系列问题。所以这篇文章我想把用Swift实现跑马灯这件事从头到尾讲透包括方案选型、核心原理、完整代码、参数计算以及那些只有踩过坑才知道的细节。这篇文章适合谁看如果你是有一定iOS开发基础、正在找跑马灯实现方案的开发者那直接照着做就行。如果你刚入门Swift也没关系我会把每一行关键代码为什么这么写都解释清楚。核心关键词就一个Swift实现iOS跑马灯效果围绕它展开的所有技术细节我都会覆盖到。先说清楚跑马灯的几个核心需求维度这决定了你后面怎么选方案滚动方向大部分场景是从右往左但也有从左往右、从下往上的需求比如弹幕场景。循环方式首尾无缝衔接是基本要求中间不能有空白间隙。速度控制匀速滚动是最常见的但有些场景需要根据文字长度动态调整速度保证滚动时长一致。交互响应滚动过程中用户能不能点击能不能暂停这些都要考虑。性能表现长时间运行不能吃太多CPU不能有内存泄漏切后台再回来要能正确恢复。把这几个维度想清楚了方案基本就定了。下面我逐个拆解。2. 三种主流实现方案的对比与选型逻辑2.1 UIView动画方案简单但有硬伤最直觉的做法是用UIView.animate配合transform来做位移。核心思路是把文字放在一个UILabel里然后对这个label的transform做平移动画动画结束后重置位置再重新开始。这个方案写起来最快大概十几行代码就能跑起来。但它有几个致命问题第一动画结束后的重置会有一个肉眼可见的跳变除非你用repeat模式但repeat模式又没法做到首尾无缝衔接第二UIView.animate的动画曲线是缓入缓出的要做成匀速需要设置.curveLinear但即便如此在循环衔接处还是会有微小的停顿感第三性能上UIView动画底层虽然也是Core Animation驱动但每次循环都要重新创建动画事务长时间运行会有累积开销。我当时的第一个版本就是用这个方案在模拟器上看着还行一到真机上就露馅了滚动到衔接处明显顿一下。所以这个方案我只推荐给那种对流畅度要求不高、临时用一下的场景。2.2 CABasicAnimation方案可控性强推荐使用CABasicAnimation是Core Animation层面的动画直接操作layer的属性性能比UIView动画好一个档次。用它做跑马灯的核心思路是创建一个CALayer或者直接用UILabel的layer对position.x或者transform.translation.x做动画设置repeatCount为.infinity实现无限循环。这个方案的关键优势在于动画完全由渲染服务器驱动不占用主线程的计算资源可以精确控制动画的时长、速度曲线配合CAAnimationGroup还能同时做多个属性的动画。而且它天然支持后台暂停和恢复只需要操作layer.speed属性就行。但这里有个细节要注意如果你直接用repeatCount .infinity动画在每次循环结束时会瞬间跳回起点对于跑马灯来说就是文字突然闪回右边体验很差。解决办法是用两个label首尾相接或者用一个足够宽的容器把内容复制两份让动画在视觉上看起来是连续的。2.3 CADisplayLink方案最灵活适合复杂场景CADisplayLink是一个和屏幕刷新率同步的定时器每帧回调一次。用它做跑马灯就是自己在每一帧里计算文字应该偏移多少然后手动设置frame或者transform。这个方案最灵活因为每一帧的位置都是你自己算的想怎么控制就怎么控制。比如你可以根据文字长度动态调整速度可以在滚动到某个位置时触发回调可以实现变速滚动、暂停恢复、手势拖拽等复杂交互。缺点是代码量最大而且需要自己处理帧率适配比如ProMotion屏幕是120Hz普通屏幕是60Hz还要注意在后台时暂停CADisplayLink否则会白白耗电。我现在的项目里如果是简单的公告栏滚动用CABasicAnimation就够了如果是弹幕、歌词滚动这种需要精确控制和交互的场景我会选CADisplayLink。三种方案的对比我整理成了一张表方便你快速决策对比维度UIView动画CABasicAnimationCADisplayLink实现难度低中高流畅度一般高高性能开销中低中取决于计算量交互灵活性低中高后台恢复需手动处理支持layer.speed控制需手动暂停恢复适用场景临时演示公告栏、通知条弹幕、歌词、复杂交互3. CABasicAnimation方案的核心原理与完整实现3.1 无缝循环的数学原理跑马灯要做到无缝循环核心就一句话当第一份内容完全滚出屏幕时第二份内容刚好接上。这听起来像废话但里面的数学关系必须算清楚。假设容器宽度是W文字内容的宽度是C。如果C W文字需要滚动的总距离是C从右边完全进入到左边完全出去。如果C W那文字本身还没容器宽滚动距离就是W C也就是从容器右边外面开始滚到容器左边外面。为了实现无缝衔接我们需要把文字内容复制一份放在第一份的右边两份内容之间的间距为0。这样当第一份滚完时第二份的位置刚好和第一份的起始位置重合视觉上就是连续的。用CABasicAnimation实现时我们对一个包含两份内容的容器做位移动画位移距离等于单份内容的宽度加上容器宽度如果内容比容器窄的话。动画的fromValue是0toValue是负的位移距离。设置repeatCount .infinity动画就会无限循环。但这里有个坑CABasicAnimation默认在动画结束后会回到初始状态。对于无限循环的动画每次循环结束时layer会瞬间回到起点然后下一轮动画开始。如果两份内容的首尾完全对齐这个瞬间跳回是看不出来的因为视觉上第二份的起始位置和第一份的起始位置是一样的。这就是为什么必须复制内容并且精确计算位移距离。3.2 完整代码实现与逐行解析下面是我在实际项目中用的一个封装好的跑马灯视图基于CABasicAnimation实现支持自定义速度、字体、颜色、间距等参数。import UIKit class MarqueeView: UIView { // MARK: - 可配置属性 var text: String { didSet { setupContent() } } var font: UIFont .systemFont(ofSize: 14) { didSet { setupContent() } } var textColor: UIColor .black { didSet { setupContent() } } var scrollSpeed: CGFloat 60 // 每秒滚动的点数 var contentSpacing: CGFloat 50 // 两份内容之间的间距 // MARK: - 私有属性 private var contentLabel1: UILabel! private var contentLabel2: UILabel! private var contentContainer: UIView! private var animation: CABasicAnimation? // MARK: - 初始化 override init(frame: CGRect) { super.init(frame: frame) setupUI() } required init?(coder: NSCoder) { super.init(coder: coder) setupUI() } private func setupUI() { clipsToBounds true backgroundColor .clear contentContainer UIView() addSubview(contentContainer) contentLabel1 UILabel() contentLabel1.numberOfLines 1 contentContainer.addSubview(contentLabel1) contentLabel2 UILabel() contentLabel2.numberOfLines 1 contentContainer.addSubview(contentLabel2) } // MARK: - 内容布局 private func setupContent() { contentLabel1.text text contentLabel2.text text contentLabel1.font font contentLabel2.font font contentLabel1.textColor textColor contentLabel2.textColor textColor contentLabel1.sizeToFit() contentLabel2.sizeToFit() let contentWidth contentLabel1.bounds.width let containerHeight bounds.height contentLabel1.frame CGRect(x: 0, y: 0, width: contentWidth, height: containerHeight) contentLabel2.frame CGRect(x: contentWidth contentSpacing, y: 0, width: contentWidth, height: containerHeight) contentContainer.frame CGRect(x: 0, y: 0, width: contentWidth * 2 contentSpacing, height: containerHeight) startAnimation() } // MARK: - 动画控制 private func startAnimation() { guard contentLabel1.bounds.width 0 else { return } contentContainer.layer.removeAllAnimations() let contentWidth contentLabel1.bounds.width let totalDistance contentWidth contentSpacing let duration CFTimeInterval(totalDistance / scrollSpeed) let anim CABasicAnimation(keyPath: transform.translation.x) anim.fromValue 0 anim.toValue -totalDistance anim.duration duration anim.repeatCount .infinity anim.timingFunction CAMediaTimingFunction(name: .linear) anim.isRemovedOnCompletion false contentContainer.layer.add(anim, forKey: marquee) animation anim } func pause() { let pausedTime contentContainer.layer.convertTime(CACurrentMediaTime(), from: nil) contentContainer.layer.speed 0 contentContainer.layer.timeOffset pausedTime } func resume() { let pausedTime contentContainer.layer.timeOffset contentContainer.layer.speed 1.0 contentContainer.layer.timeOffset 0 contentContainer.layer.beginTime 0 let timeSincePause contentContainer.layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime contentContainer.layer.beginTime timeSincePause } }这段代码有几个关键点需要展开说。第一为什么用transform.translation.x而不是直接改frame因为transform动画是Core Animation直接支持的属性走的是渲染服务器的合成通道性能比改frame触发重新布局要好得多。而且transform动画不会影响layer的frame属性不会触发布局系统的连锁反应。第二duration的计算逻辑。我用totalDistance / scrollSpeed来算动画时长其中totalDistance是单份内容宽度加上间距。这样做的目的是让滚动速度恒定不管文字多长每秒滚过的点数是一样的。如果你希望不管文字多长滚动一轮的时间固定那就把duration设成固定值反过来算速度。第三isRemovedOnCompletion false的作用。默认情况下动画结束后会被移除layer会回到模型层的值。对于无限循环动画来说虽然repeatCount是.infinity但设置这个属性可以确保动画在被打断或者移除时不会突然跳回原位。第四暂停和恢复的实现。这里用的是layer.speed和layer.timeOffset配合的方式。speed 0相当于把时间冻结timeOffset记录冻结的时刻。恢复时重新计算beginTime让动画从冻结的位置继续。这个方法是Core Animation官方推荐的暂停恢复方案比移除动画再重新添加要平滑得多。3.3 参数计算与速度调优跑马灯的速度设置是个经验活。太快了看不清太慢了显得拖沓。我一般按下面的逻辑来定公告栏类每秒滚动60到80点比较合适人眼能轻松跟上。弹幕类每秒100到150点要有那种快速掠过的感觉。歌词类每秒40到60点配合音乐节奏。但这里有个问题如果文字特别长按固定速度滚动用户可能要等很久才能看完。所以更好的做法是根据文字长度动态调整速度让滚动时长控制在一个合理范围内。比如设定最短滚动时长3秒最长8秒然后反推速度。let minDuration: CFTimeInterval 3.0 let maxDuration: CFTimeInterval 8.0 let contentWidth contentLabel1.bounds.width let totalDistance contentWidth contentSpacing let idealDuration CFTimeInterval(totalDistance / scrollSpeed) let finalDuration min(max(idealDuration, minDuration), maxDuration)这段代码的意思是先按默认速度算出一个理想时长然后把它限制在3到8秒之间。如果算出来小于3秒就强制拉长到3秒如果大于8秒就压缩到8秒。这样不管文字长短用户的等待时间都是可控的。4. 实操过程中的关键细节与避坑指南4.1 文字宽度测量的时机问题UILabel的sizeToFit()必须在设置了text、font之后调用而且要在正确的布局上下文中。我遇到过一个问题在setupContent()里调用sizeToFit()拿到的宽度是0。原因是此时UILabel还没有被添加到视图层级中或者bounds还是.zero。解决办法是确保contentLabel1已经添加到contentContainer并且contentContainer已经添加到self然后再调用sizeToFit()。如果还是不行可以用text.size(withAttributes:)手动计算宽度这个方法不依赖视图层级更可靠。let attributes: [NSAttributedString.Key: Any] [.font: font] let textWidth (text as NSString).size(withAttributes: attributes).width4.2 后台切换与内存管理CABasicAnimation在App进入后台时会被系统自动暂停回到前台时会自动恢复。但这里有个坑如果你在viewWillDisappear里手动暂停了动画在viewWillAppear里恢复要确保暂停和恢复是成对出现的否则会出现动画速度异常。另外MarqueeView被释放时一定要移除动画。虽然layer被释放时动画也会跟着释放但如果你在deinit里显式移除可以避免一些边界情况下的崩溃。deinit { contentContainer.layer.removeAllAnimations() }4.3 多行文字与富文本的处理标准的跑马灯一般只处理单行文字。如果你需要滚动多行内容比如一个列表那就不能用UILabel了得用UITableView或者自定义的UIView来承载内容。原理是一样的只是把UILabel换成你的内容视图然后对内容视图做位移动画。富文本的话把UILabel的attributedText设置好后面的逻辑完全一样。但要注意富文本的宽度计算要用boundingRect(with:options:context:)方法不能简单用sizeToFit()。4.4 常见问题速查表问题现象可能原因解决方法文字滚动到一半突然跳回动画循环时layer回到初始位置确保复制了两份内容且位移距离等于单份宽度加间距滚动速度忽快忽慢动画曲线不是linear设置timingFunction为.linear文字宽度测量为0视图未加入层级或布局未完成用size(withAttributes:)手动计算或在layoutSubviews中处理暂停后恢复位置不对timeOffset和beginTime计算错误严格按照官方推荐的暂停恢复流程操作长时间运行内存增长动画未移除或闭包循环引用在deinit中移除动画检查闭包捕获真机上滚动有微小抖动像素对齐问题确保所有frame的origin是整数或者用round()处理5. 进阶优化与扩展思路5.1 支持点击交互跑马灯上的文字有时候需要支持点击比如点击公告跳转到详情页。但滚动中的视图点击区域是动态变化的直接用UITapGestureRecognizer加在contentLabel上点击位置和实际文字位置可能对不上。我的做法是在MarqueeView上覆盖一个透明的UIButton或者加一个UITapGestureRecognizer点击时暂停动画然后判断点击位置是否在文字范围内。如果只是简单的整条点击直接在MarqueeView上加手势就行不用管文字具体在哪。let tap UITapGestureRecognizer(target: self, action: #selector(handleTap)) addGestureRecognizer(tap) objc private func handleTap() { // 处理点击事件 onTap?() }5.2 动态更新内容有时候跑马灯的内容需要动态更新比如公告切换。这时候不能直接改text属性然后重新开始动画因为那样会有一个明显的重置过程。更好的做法是等当前这一轮滚动结束后再切换内容。实现方式是用CATransaction的完成回调或者用CADisplayLink监听动画进度。但CABasicAnimation的无限循环没有完成回调所以如果你需要精确控制还是得用CADisplayLink方案。一个折中的办法是在更新内容时先暂停动画更新两份label的文字重新计算宽度和位移距离然后重新开始动画。虽然会有一个短暂的重置但如果更新频率不高用户基本感知不到。5.3 性能监控与优化跑马灯虽然不复杂但如果页面上同时有多个跑马灯或者跑马灯和其他动画同时运行性能问题就会暴露出来。我一般会用Instruments的Core Animation模板来检查帧率确保稳定在60fps或者ProMotion设备的120fps。优化手段包括减少不必要的图层混合、避免在动画过程中触发离屏渲染、使用shouldRasterize缓存静态内容但跑马灯是动态的不适合。最有效的优化其实是减少同时运行的动画数量如果页面不可见及时暂停动画。override func didMoveToWindow() { super.didMoveToWindow() if window nil { pause() } else { resume() } }这段代码的意思是当视图从窗口移除时自动暂停动画重新加入时恢复。这样可以避免不可见的跑马灯白白消耗资源。5.4 适配不同屏幕刷新率从iPhone 13 Pro开始ProMotion屏幕支持120Hz刷新率。CABasicAnimation会自动适配屏幕刷新率不需要额外处理。但如果你用的是CADisplayLink方案就需要设置preferredFramesPerSecond或者根据UIScreen.main.maximumFramesPerSecond动态调整。对于CABasicAnimation方案有一个需要注意的地方在低电量模式下系统可能会降低刷新率动画的流畅度会受影响。这是系统行为开发者无法干预但可以通过ProcessInfo.processInfo.isLowPowerModeEnabled判断在低电量模式下适当降低滚动速度减少视觉上的卡顿感。6. 完整项目集成与测试验证6.1 集成到实际页面的步骤把MarqueeView集成到页面里大概分这么几步在目标视图控制器里声明一个MarqueeView属性。在viewDidLoad里初始化设置frame、text、font、textColor、scrollSpeed等参数。添加到父视图设置好约束或者frame。如果需要点击交互设置onTap闭包。在viewWillDisappear里调用pause()在viewWillAppear里调用resume()。class ViewController: UIViewController { private let marqueeView MarqueeView() override func viewDidLoad() { super.viewDidLoad() marqueeView.frame CGRect(x: 20, y: 100, width: view.bounds.width - 40, height: 30) marqueeView.text 这是一条测试公告用于验证跑马灯效果是否正常滚动 marqueeView.font .systemFont(ofSize: 14) marqueeView.textColor .darkGray marqueeView.scrollSpeed 70 marqueeView.onTap { print(跑马灯被点击了) } view.addSubview(marqueeView) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) marqueeView.pause() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) marqueeView.resume() } }6.2 测试要点与验证清单跑马灯做完之后我一般会按下面的清单过一遍视觉连续性盯着看至少三轮循环确认没有跳变、闪烁、空白间隙。速度一致性用秒表测一下从文字出现到完全消失的时间和预期是否一致。不同文字长度分别用短文字比容器窄和长文字比容器宽测试确认都能正常滚动。后台切换切到后台再回来确认动画位置正确没有加速或减速。内存泄漏用Instruments的Leaks工具检查确认MarqueeView释放后没有残留。CPU占用用Instruments的Time Profiler检查确认滚动时CPU占用在合理范围一般低于5%。多实例测试同时放3到5个跑马灯确认帧率稳定。6.3 一个实际踩坑案例我在一个项目里遇到过一个问题跑马灯在iPhone 12上正常但在iPhone SE第一代上滚动速度明显变慢。排查后发现是因为SE的屏幕宽度小容器宽度也小但文字宽度没变导致totalDistance相对变大而duration是按totalDistance / scrollSpeed算的速度没变但视觉上感觉慢了。解决办法是不要用固定的scrollSpeed而是根据容器宽度和文字宽度的比例来动态调整速度。具体来说如果文字宽度远大于容器宽度适当提高速度如果文字宽度和容器宽度差不多降低速度。这样在不同尺寸的设备上视觉体验是一致的。let widthRatio contentWidth / bounds.width let adjustedSpeed scrollSpeed * min(max(widthRatio, 0.8), 1.5)这段代码的意思是根据文字宽度和容器宽度的比例在0.8到1.5倍之间调整速度。比例越大速度越快保证长文字不会滚太久。7. 关于跑马灯的一些个人经验跑马灯这个组件看起来简单但要做好真的需要抠很多细节。我做了这么多项目总结下来就几条经验能用CABasicAnimation就别用UIView动画性能差距在复杂页面上非常明显无缝循环的关键在于复制内容和精确计算位移距离这两点做到位了基本就不会有跳变暂停恢复一定要用layer.speed和timeOffset配合的方式不要移除动画再重新添加那样会有闪烁。还有一个容易被忽略的点跑马灯的文字内容如果是动态从网络加载的一定要处理加载失败或者内容为空的情况。我见过一个App公告接口挂了跑马灯区域直接空白一片用户以为页面坏了。正确的做法是给一个默认文案或者隐藏跑马灯区域。最后再分享一个小技巧如果你觉得CABasicAnimation的代码还是太多可以把它封装成一个UIView的扩展用链式调用配置参数用起来会更顺手。但核心原理不变该算的宽度、该设的动画属性一个都不能少。