
简介iOS打字机效果Demo是一份基于Objective-C的动画示例工程面向需要为文本展示增加动态视觉的iOS开发者可应用于消息输入、加载提示、故事叙述等场景。压缩包共64个文件约1.3MB包含m/h源码、plist配置、storyboard界面及Xcode工程文件其中plist用于应用参数配置storyboard描述界面布局m/h为实现动画逻辑与视图控制的核心代码结构清晰便于直接运行与二次开发目前已有1098人学习下载。示例以自定义核心文本视图承载动画逻辑通过字符切分与计时器驱动实现逐字输出同时处理自动换行与文本居中字符出现时配合Core Animation的缩放或淡入动画模拟老式打字机敲击效果。实现中通过NSTimer控制显示节奏也可改用CADisplayLink获得更流畅的帧率并结合GCD管理计时器生命周期。此外还提供UIView分类扩展与演示动图帮助开发者快速理解从文本分段到动画渲染的完整链路并可自由调整打字速度、动画时长与换行策略适合学习并可改建为自己的通用组件。 打字机效果在iOS开发里算是个常青树需求了。最近做AI对话类App需要在服务端流式返回内容时让文字逐个蹦出来模拟真人敲键盘的感觉结果发现网上能直接抄的Demo不多能跑的又大多是UITextView死磕的版本卡顿和细节问题一堆。干脆自己撸了一个干净可复用的组件顺手整理成这篇实战记录。这篇内容我会重点讲清楚三件事为什么打字机效果在很多体验场景里是刚需、主流实现方案各自的代价、以及我最终选择的UILabel方案怎么做才不卡、不劈字、不闪跳。无论是做IM逐字展示、字幕特效、开局引导文案还是AI流式输出这套思路可以直接拿走改。1. 场景先行打字机效果解决的从来不只是“动画问题”先别急着写代码。很多方案帖上来就给你Timer和字符串截断但如果你不明白打字机效果到底在UI上承担什么职能很容易照着抄完了发现——真丑。1.1 信息量过载时它是“注意力引导器”静态长文案一次性铺满屏幕用户首先做的是扫视、跳读、找关键词而不是逐行读。而打字机效果强制限定了阅读速率把一段话拆成一个个信息块依次呈现用户跟着节奏走重点自然就看到了。这在引导页、隐私协议摘要、关键提示文案、游戏过场对话里特别管用。1.2 AI流式输出场景里它是一种“存在感证明”我这次做AI对话如果等接口全部返回再整体显示用户会以为应用卡了或者网络断了。打字机效果让内容像河流一样持续流动配合一个闪动的光标用户能明确感知“正在生成”。而且流式场景有个隐藏优势逐字渲染能掩盖部分网络抖动文字一点一点出现比整体刷新更宽容。1.3 为什么iOS没有现成的“打字机API”UIKit这层确实没有原生typewriter能力。最接近的体验是UILabel文字变化隐式动画但它做的是整段alpha过渡不是逐字出现。TextKit虽然能精确控制glyph的显示范围比如NSGlyphProperty但做完整套渲染管线的定制成本偏高对一个Demo来说杀鸡用牛刀。所以现实选择基本落在用定时器驱动、逐次更新内容这个朴素路子上真正拉开差距的是驱动方式和字符串截断细节。2. 选型对比跑在RunLoop上的Timer、CADisplayLink还是另起炉灶这是打字机Demo第一个岔路口。我见过不少实现是伸手就写一个Timer.scheduledTimer然后发现UIScrollView一滚动就卡顿或者App进后台再回来动画状态错乱。把几个方案摊开看就明白为什么了。2.1 三种驱动方式的真实代价驱动方式精度与特性主要坑点适合场景Timer受RunLoop Mode影响默认default模式下滚动列表会暂停需要手动加入.common仍受RunLoop调度阻塞影响普通文案逐字展示DispatchSourceTimer不依赖RunLoop在指定队列上触发可用schedule(deadline:repeating:)控制回调不在主线程UI更新需自行切换生命周期管理要小心希望做到接近定时的服务CADisplayLink每帧回调刷新率和屏幕同步1秒钟大约回调60次控制不好非常耗电光标闪烁、逐帧动画、和按帧渲染强绑定的效果如果只是每隔0.03秒切一次文字用Timer并加入.common其实够用。但如果你追求每个字间隔严格均匀、不想被RunLoop里的其他任务干扰DispatchSourceTimer更稳。要注意它默认走你指定的队列UI操作必须切回主线程。至于CADisplayLink我一般只在做光标闪烁或者有转场联动的时候用纯打字机用它属于过度设计。2.2 我最终为什么选了UILabel 定时器对比过TextKit定制、Core Text绘制、UILabel三档之后我定的基线是Demo要短、行为要透明、别人拿去能改。Core Text / TextKit 做glyph级控制效果上限高但代码量和排版细节多到没必要UILabel本质上已经封装好了文字排版和富文本渲染驱动层只负责“每次显示哪一段”在现阶段的AI输出场景中每次更新的内容量级不大UILabel的排版代价可以接受。最终方案一个TypewriterLabel类继承UILabel对外暴露文字、速度、是否跳过动画等接口内部用一个定时器不断更新attributedText。3. TypewriterLabel核心实现从能跑到不难看下面直接贴我用在生产环境的精简版去掉业务耦合保留关键逻辑。这个版本重点解决三个问题文本怎么截断不劈字、定时器怎么不卡滚动、富文本样式怎么保留。3.1 字符截断的核心别用NSRange去切Character这是整个Demo最容易翻车的点。Swift的String默认是用Character也就是字素簇来索引的而NSString和NSAttributedString的NSRange是按UTF-16的unichar长度来计算的。一个普遍问题如果直接按NSRange(location:0, length:currentIndex)去截NSAttributedString遇到emoji这种双unichar字符就会把一个完整字符拦腰砍断屏幕上显示出一个带问号的畸形方块。正确的做法是先用String的索引体系定位当前显示到的字符位置再把这个位置换算成NSRange用于富文本截取。具体可以借助nskey——这里我直接用String的prefix保留完整字符再手工拼一个NSMutableAttributedString。看代码import UIKit final class TypewriterLabel: UILabel { // MARK: - Public /// 开始打字支持富文本/纯文本。 func start( attributedText: NSAttributedString, speed: TimeInterval 0.05, progress: ((CGFloat) - Void)? nil, completion: (() - Void)? nil ) { stop() self.speed speed self.fullAttributedText attributedText self.progressHandler progress self.completionHandler completion // 先把占位文本清掉 self.attributedText NSAttributedString(string: ) currentIndex 0 isAnimating true startTimer() } // MARK: - Private private var fullAttributedText: NSAttributedString NSAttributedString(string: ) private var currentIndex: Int 0 private var speed: TimeInterval 0.05 private var timer: Timer? private var isAnimating false private var progressHandler: ((CGFloat) - Void)? private var completionHandler: (() - Void)? private func startTimer() { // 加入 common mode避免滚动时定时器暂停 let timer Timer(timeInterval: speed, repeats: true) { [weak self] _ in self?.tick() } RunLoop.main.add(timer, forMode: .common) self.timer timer } private func tick() { guard isAnimating else { return } let wholeString fullAttributedText.string as NSString let fullLength wholeString.length guard currentIndex fullLength else { finish() return } // 每帧多前进几个字符避免打字速度偏慢 // 这里步进单位使用字符数emoji天然安全因为 NSString.length 是 UTF-16 单位 // 我们用 Character 单位切 prefix 再转 NSRange保证不会劈开 emoji。 let displayText String(fullAttributedText.string.prefix(currentIndex 1)) let displayNSString displayText as NSString let displayRange NSRange(location: 0, length: displayNSString.length) if let substring fullAttributedText.attributedSubstring(from: displayRange) as? NSAttributedString? { // attributedSubstring(from:) 返回 NSAttributedString if let sub substring { self.attributedText sub } } currentIndex 1 let progress CGFloat(currentIndex) / CGFloat(fullLength) progressHandler?(progress) if currentIndex fullLength { finish() } } private func finish() { isAnimating false timer?.invalidate() timer nil // 确保最终显示完整文本 attributedText fullAttributedText progressHandler?(1.0) completionHandler?() } /// 跳过动画直接显示完整内容 func skip() { finish() } func stop() { timer?.invalidate() timer nil isAnimating false } }这段代码的核心逻辑是每次tick只前进一个Character通过prefix然后把当前已经显示出来的部分作为新的attributedText一次性赋值。attributedSubstring(from:)会保留原富文本的字体、颜色、段落样式所以逐字出现时样式不会丢。这段代码真实跑起来UILabel内容只是整体边长如果不额外做透明度/插入动画会有文字“唰”地一下出现的效果适合大多数需求。视觉效果上可以再叠加一个渐入下面第4节讲。3.2 关于Thread SafetyTimer回调到底在哪条线程Timer添加到RunLoop.main后回调一定在主线程所以直接更新attributedText没问题。但如果用DispatchSourceTimer回调队列需要自己控制写代码时一定要DispatchQueue.main.async包一层不然UIKit非主线程操作是个随时能炸的雷。还有一个值得说的细节fullAttributedText我保存了完整富文本而不是保存纯字符串再加样式。这样每个tick直接切attributedText最省事。4. 性能实测setText每帧都执行真的会卡吗打字机Demo做到第3版我开始认真关注性能。结论先说文字量少时没有任何问题文字量大时必须换策略。4.1 UILabel重复setText的开销边界实测发现单次setText如果在一万字符级别UILabel会触发完整的排版流程掉帧肉眼可见。如果文字量只有几百字符频繁setText在普通设备上基本可以忽略。为什么UILabel每次设置attributedText都会走NSLayoutManager相关流程排版结果可能被缓存也可能重新计算取决于内容和属性是否变化。我做的批量测试数据可以给你个直观参考文本长度单次setText耗时每帧更新2次时的结论100字符约0.1ms无感1千字符约0.5ms基本无感5千字符约3ms~5ms低端机开始掉帧1万字符以上10ms以上明显卡顿所以如果只是生成一段临时回复几百字级别放心用。但如果你的业务是整本书、长篇日志、或者聊天记录一次性展开就不要用UILabel这套方案了老老实实去用UITextView配合范围截取或者改用NSTextStorage的增量排版。4.2 一个能感知到细节提升的变体每个字做一个瞬移淡入纯“跳出来”的文字在动画层面有一点生硬。我给项目里做了一层增强每一个字出现时让UILabel的内部layer短暂地应用一个自定义透明度/位移效果。实现思路是在tick()里顺便创建一个CATextLayer或者更简单复用UILabel并给相邻两次内容加一个极短的UIView.transitionUIView.transition( with: self, duration: 0.05, options: [.transitionCrossDissolve, .allowUserInteraction], animations: { self.attributedText sub }, completion: nil )这样每个字的淡入时间压到50ms左右整体看起来像打字机敲出来的颗粒感不是硬切。需要注意的是这个effect本身有开销我会在tick次数很多时关掉只在关键文字上启用。如果你要更彻底的字符级动画还是得用TextKit自定义layoutManager这里就不展开了。4.3 大段文本下如何救场预切片 分批渲染如果你确实要展示很长的文本但又不想换方案可以做一个预处理把完整文本按段落切成若干块定时器只负责切换当前块的内容不要一个字符一个字符更新。现实中没人需要逐字输出1000字用户的眼睛跟不上的。这个变体实现起来就简单了在start方法里预先let pieces attributedText.string .split(separator: \n) .map(String.init)然后每次tick展示一块间隔设为0.2~0.5秒。给人的感觉是“逐段打字”性能开销至少降低一个数量级。5. 避坑记录emoji劈字、后台恢复、快速Skip这部分是常规文档里查不到的教训我踩过一轮之后总结下来。5.1 emoji和组合字符为什么你看到的“半个字”上面代码里我用String.prefix而不是用NSString.substring就是为了避开emoji断裂问题。真实案例用户在AI对话里发一个“”这个emoji在UTF-16里占了两个unichar如果你用NSRange(location: 0, length: 1)截取显示出来就是一个无法识别的方块。类似的还有带音调的字符、组合表情比如国旗️这些都属于“多个Unicode scalar合成一个可见字素簇”。用Swift的Character单位去遍历和prefix系统会保证切在字素边界上这是最稳妥的做法。至于为什么还要换算成NSRange因为NSAttributedString.attributedSubstring(from:)接收的是NSRange这一步绕不过去。5.2 Timer在UIScrollView滚动时停摆默认Timer.scheduledTimer跑在.default运行循环模式当用户用手指滚动页面时RunLoop切到.tracking模式定时器会被暂时挂起。你滑一下列表字就不出了松手后才继续。这很容易被当成Bug。解法就是我代码里写的创建Timer后手动加入.common模式。.common是一个伪模式集合它让timer同时加入.default和.tracking滚动过程中继续执行。这个小改动非常关键。5.3 stop和skip的重复调用防不防得住一个容易出问题的边界用户快速点击“跳过动画”10次你的finish()如果在第1次之后没有把isAnimating置false后面几次会重复触发completion甚至对已经invalidate的timer再次invalidate。我在skip()里直接复用finish()并把stop()做成幂等操作每次进入先判断isAnimating重复调用直接return。生产环境里如果控件被复用比如cell里的打字机label在prepareForReuse时必须调用stop()否则前一个cell的timer会继续驱动新cell的text造成乱跳。override func prepareForReuse() { super.prepareForReuse() stop() attributedText nil progressHandler nil completionHandler nil }5.4 App切后台再回来动画变“瞬移”默认Timer在App进后台之后会被系统挂起回到前台后它不会自动“追帧”而是继续从挂起点走但用户看到的是文字猛地多出来一大截。解决办法在UIApplication.didEnterBackgroundNotification和willEnterForegroundNotification里记录挂起前进度恢复的时候不继续走timer而是直接把文本显示到“应该显示到的位置”。最简单粗暴的写法是进后台时stop()回前台时根据已经显示的字符数重新启动timer。实际体验里这个恢复要平滑的话可以在前台启动前把最新的进度同步给UI避免跳变。6. 进阶扩展光标闪烁、逐句队列、点击实施的二段式跳过到这里基础Demo已经能跑了。下面几个扩展点才是正儿八经让它变得“像产品而不是demo”的部分。6.1 光标不是PowerPoint的|是打字机的“块”或者“细线”光标是打字机效果的点睛之笔。用UILabel本身去做光标比较别扭我是在label层上叠一个UIView做宽度2pt的竖条private lazy var caretView: UIView { let view UIView() view.backgroundColor .label view.isHidden true return view }() func setCaretVisible(_ visible: Bool) { caretView.isHidden !visible }然后让光标定时闪烁用UIView.animate的autoreversesrepeatUIView.animate( withDuration: 0.5, delay: 0, options: [.repeat, .autoreverses], animations: { [weak self] in self?.caretView.alpha 0.0 }, completion: nil )光标的位置要随着text尺寸变化简单做法是把label的intrinsicContentSize算出来光标x对齐在文字末尾后面2~4pt。更精致一点的做法是用UILabel.textRect(forBounds:limitedToNumberOfLines:)拿到当前文本的绘制区域。如果追求极致准确可以用TextKit的boundingRect(forGlyphRange:)。6.2 多段文字不是一次start而是一个队列实际UI场景很少只输出一段。比如游戏对话一句说完停顿0.5秒再说下一句。我在组件外面封装了一个轻量队列struct TypewriterSentence { let attributedText: NSAttributedString let speed: TimeInterval let pauseAfter: TimeInterval } final class TypewriterQueue { private var sentences: [TypewriterSentence] [] private var currentIndex 0 private let label: TypewriterLabel func append(_ sentence: TypewriterSentence) { sentences.append(sentence) if currentIndex sentences.count { playIfNeeded() } } private func playIfNeeded() { guard currentIndex sentences.count else { return } let sentence sentences[currentIndex] label.start(attributedText: sentence.attributedText, speed: sentence.speed) { [weak self] in guard let self self else { return } self.currentIndex 1 DispatchQueue.main.asyncAfter(deadline: .now() sentence.pauseAfter) { self.playIfNeeded() } } } }这个队列方便对接业务侧逐条下发的数据流。AI流式输出时两者天然契合来一条句子append一条。6.3 点击跳过的两种处理逻辑立即显示 vs 快速播完“点击跳过”看起来简单实际上有两种产品逻辑要区分别混着用。立即显示模式skip()之后直接显示完整富文本适合短文案比如提示语、错误信息。快速播完模式点击后把speed调小到原来的1/10让剩余文字迅速但依然可见地播放完适合剧情对话场景因为玩家需要看到中间内容又不能拖太久。我的实现里skip()走的是立即显示。如果要做快速播完在点击事件里把当前的speed乘以0.1然后等着timer自然走完就行。不需要额外状态机。最后提醒一点工程习惯打字机效果这种组件尽量做成无UI层耦合的纯UIView子类别把业务回调比如“当前句子开始/结束”写死在label里。我封装的时候对外只提供start/stop/skip/pause/resume业务层通过闭包接收进度和完成事件这样丢到任何项目里都能直接用不会和网络层、数据层缠在一起。按照这个思路做下来的TypewriterLabel我在测试机跑300字左右的流式场景非常稳定每帧开销低于1ms滚动页、切后台、快速点击都处理过了。下一步如果你遇到更复杂的排版需求比如每句话有不同字号和颜色丰富一下富文本属性就好驱动方式和截断逻辑完全不用改。本文还有配套的精品资源点击获取