
简介面向iOS工程师的打字机动画示例使用Objective-C语言编写借助核心动画和定时器实现字符逐个显示解决文本动态展示、自动居中与多行换行等交互需求适用于消息提示、加载提示或故事叙述等场景。压缩包共64个文件大小1.3MB包含工程源码、界面布局文件、属性列表、演示动画以及版本管理记录便于直接打开工程运行查看。压缩包内资源完整适合直接导入开发环境体验。目前已有1098人学习参考。示例中封装了文本分割、定时触发、逐字追加和换行处理等核心逻辑并利用视图动画添加缩放或淡入效果使字符呈现打字机般的动感体验。开发者可调整定时器间隔控制打字速度也可在此基础上扩展自定义字体、声音或特殊动画。整体结构清晰适合初级、中级开发者学习动画机制与封装思路。1. 项目概述1.1 核心需求解析要说iOS开发中最有代入感的文字展示效果打字机风格必须占一席之地。这种逐字出现的动画效果不论是用在对话剧情、动态日志、还是在App引导页里营造叙事感都能让原本静态的文字多一层正在发生的临场体验。这个Demo项目的核心需求其实非常明确在iOS平台上实现一个文字逐字显示的打字机效果并封装成可以直接复用的组件。表面上看功能简单——无非就是定时器每隔一小段间隔往TextView里追加字符但真正落地时你会发现这里面藏着一堆细节定时器的频率控制、UI更新的线程安全、用户交互的打断策略、以及如何做到不卡顿地显示长文本。我最初做这个Demo的时候踩了不少坑比如用Timer做逐字追加时一旦文本量上来就会出现明显的丢帧又比如用户快速点击跳过时多个定时器同时工作导致字符乱跳。后来我把整个实现方案重写了一遍用GCD的DispatchSourceTimer替换了基础的Timer配合RunLoop的模式调整才算彻底解决这些问题。这个Demo适合谁看如果你是想给文字展示加一点动态效果的iOS新手开发者或者是需要在聊天界面、故事阅读器、日志查看器里嵌入打字机交互的中级工程师这篇内容可以直接帮你省掉自己摸索的时间。我会从最基础的方案讲起再给出生产级的优化版本并把关键参数的计算方法和运行机制一并交代清楚。1.2 应用场景与价值分析打字机效果并不是一个看起来很酷但没什么用的花架子。实战中它的应用场景很多剧情类App的对话系统逐字弹出能让用户更专注于文本内容节奏感也更好。《生命线》这类文字冒险游戏就是典型的例子。聊天消息的正在输入反馈在IM场景中对方收到消息时打字机式的逐字显示配合声音反馈交互沉浸感更强。日志与命令行工具比如网络调试工具里的实时响应展示或者SSH客户端里模拟终端打印效果。引导页与公告栏重要通知用打字机效果展示能有效引导用户的视线焦点。再往深一层想这个效果背后其实涉及iOS开发中的几个核心知识点Timer与RunLoop的关系、主线程UI更新的时机控制、富文本的局部属性刷新、以及内存管理中的循环引用问题。把打字机效果吃透了你对iOS的异步编程和UI渲染机制的理解会上一个台阶。2. 方案选型与技术思路拆解2.1 三种主流实现方案对比把打字机效果在iOS里落地常用方案大致有以下三种。我实际都试过各自的优缺点心里有数这里直接列成表格供你参考方案核心机制优点缺点适用场景Timer逐字符追加Timer定时把字符串按索引截断赋值给UILabel/UITextView实现简单代码量少高频率触发下易丢帧Timer受RunLoop模式影响会暂停短文本、快速联调验证DispatchSourceTimer逐字追加GCD定时器主队列回调中更新UI稳定性高不受RunLoop切换影响支持取消和暂停需要手动管理生命周期代码稍多中长文本、生产环境动画遮罩方案用一个与背景色相同的View盖住文本通过Animation逐步移动遮罩或裁剪区域动画极其平滑CPU占用低无法逐字交互如点击某个字复杂文本适配麻烦超长文本、大段滚动内容方案一和二的核心逻辑其实一致区别只在于定时器的实现方式。方案三是另一个维度的思路不修改文本自身而是通过视觉遮蔽来模拟逐字显现性能最优但扩展性差。我在Demo里最终选择了方案二作为主实现原因在于它兼顾了性能、代码可读性和交互扩展性。方案一作为入门讲解用的铺垫版本放在第二节里说明。2.2 定时器选型为什么弃用Timer改用DispatchSourceTimer新手写打字机效果时第一反应基本都是Timer.scheduledTimer每0.05秒触发一次往文本控件里追加一个字。Demo跑起来看起来没问题但放进真实项目后会发现两个隐患第一个隐患是Timer会被RunLoop吃掉。默认的Timer会添加在当前RunLoop的default模式下当用户拖拽ScrollView、或者界面发生滚动时RunLoop会切换到UITrackingRunLoopMode此时Timer会被暂停。结果是打字机动画在用户滑动屏幕时会突然停顿体验大打折扣。虽然可以通过RunLoop.main.add(timer, forMode: .common)来解决但终归不够优雅。第二个隐患是Timer的触发精度不够稳定。Apple官方文档里明确写了Timer的触发时刻不保证精确尤其是当主线程忙的时候Timer事件会被延后甚至合并。对于打字机这种高频小间隔操作累积误差会导致文本显示忽快忽慢。DispatchSourceTimer则没有这两个问题。它基于GCD队列不受RunLoop模式影响触发精度由系统调度器保证稳定性明显更高。配合schedule(deadline:repeating:)方法和.setEventHandler回调可以实现精确到毫秒级别的重复任务。你可能会问那直接用DispatchQueue.asyncAfter做递归调用行不行理论上可以但可控性差——取消这个递归链条很麻烦而且每次触发都要创建新的block内存开销更大。DispatchSourceTimer自带cancel()和suspend()接口生命周期管理干净利落。2.3 为什么要在主队列更新UI这个点对新手来说尤其容易忽略。我在这个Demo里虽然用了全局队列做字符拆分的计算但所有对UILabel/UITextView的属性赋值都强制切回主队列执行。原因在于iOS的UI渲染机制UIKit框架本身不是线程安全的所有的view层级操作都必须在主线程Main Thread进行。如果你在子线程里改了UILabel的text属性轻则出现闪烁、卡顿重则直接触发Main Thread Checker的崩溃警告。汉字和网络热词这只里还要注意一点在按字符拆分字符串时不要直接用String.count做索引截取某些Emoji和组合字符比如这种ZWJ序列会占多个UTF-16单元直接截取会造成乱码。稳妥的做法是用Array(text)先转成字符数组再按索引取元素拼接。3. 核心实现与实操过程3.1 工程搭建与环境准备开始之前先确认环境配置Xcode 13 及以上版本低版本也能跑但不保证Swift并发特性可用iOS 13 及以上系统版本NSAttributedString的某些Key需要系统支持Swift 5.5 及以上新建工程时我习惯选择SwiftUI和UIKit混合架构。虽然纯SwiftUI也能实现打字机效果但SwiftUI的Text组件在处理逐字刷新时性能损耗明显比UIKit的UILabel大而且SwiftUI对NSAttributedString的支持比较有限。所以这个Demo我采用UIKit编写核心逻辑SwiftUI只做容器展示。3.2 完整代码实现整个Demo的核心类很简洁我拆成三个部分来说明。第一步定义打字机效果的管理类import UIKit final class TypewriterEffectManager { private var timer: DispatchSourceTimer? private var fullText: String private var characters: [Character] [] private var currentIndex: Int 0 private var onUpdate: ((String) - Void)? private var onComplete: (() - Void)? /// 是否正在播放动画 private(set) var isPlaying: Bool false /// 初始化并开始打字机效果 /// - Parameters: /// - text: 完整文本 /// - speed: 打字速度单位是「字符/秒」 /// - onUpdate: 每次更新文字的回调 /// - onComplete: 播放完成的回调 func start( text: String, speed: Double 20, onUpdate: escaping (String) - Void, onComplete: (() - Void)? nil ) { stop() fullText text characters Array(text) currentIndex 0 self.onUpdate onUpdate self.onComplete onComplete // 计算时间间隔每秒显示的字符数越多间隔越小 let interval 1.0 / speed let timer DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(10)) timer.setEventHandler { [weak self] in self?.updateNextCharacter() } self.timer timer isPlaying true timer.resume() } /// 停止打字机效果并清空状态 func stop() { timer?.cancel() timer nil isPlaying false currentIndex 0 } /// 直接显示完整文本 func skipToEnd() { guard !fullText.isEmpty else { return } timer?.cancel() timer nil isPlaying false currentIndex characters.count onUpdate?(fullText) onComplete?() } private func updateNextCharacter() { guard currentIndex characters.count else { // 所有字符已显示完 timer?.cancel() timer nil isPlaying false onComplete?() return } currentIndex 1 let currentText String(characters.prefix(currentIndex)) onUpdate?(currentText) } }这段代码有几点值得单独说明为什么用queue: .mainDispatchSourceTimer的回调队列直接指定为主队列就不用每次在回调里手动DispatchQueue.main.async跳转了。同时因为主队列是串行的多个定时器之间不会产生并发竞争。leeway参数的作用。leeway是宽容时间告诉系统这个定时器的触发时刻允许有10毫秒的浮动。这不是精度下降反而给了系统调度器更多的合并空间能显著降低CPU唤醒频率对省电有帮助。[weak self]防止循环引用。定时器的eventHandler捕获了self如果不加weakself永远不会释放定时器也永远不会停止这是典型的闭包循环引用坑。第二步UI部分把打字机效果绑定到控件上import UIKit final class TypewriterLabel: UILabel { private let manager TypewriterEffectManager() private var fullText: String /// 开始打字机动画 /// - Parameters: /// - text: 展示的文本 /// - speed: 打字速度字符/秒 func typewrite(text: String, speed: Double 20, completion: (() - Void)? nil) { fullText text self.text manager.start( text: text, speed: speed, onUpdate: { [weak self] displayedText in self?.text displayedText }, onComplete: completion ) } /// 直接展示完整文本 func skip() { manager.skipToEnd() } /// 停止动画 func stop() { manager.stop() self.text fullText } }如果你需要显示多行文本并且带样式比如部分文字加粗、变颜色建议把UILabel换成UITextView并将onUpdate回调中的赋值改成设置attributedText// UITextView版本的核心回调 onUpdate: { [weak self] displayedText in let attributed NSAttributedString( string: displayedText, attributes: [ .font: UIFont.systemFont(ofSize: 17), .foregroundColor: UIColor.label ] ) self?.textView.attributedText attributed }第三步在ViewController里串联整个流程import UIKit class ViewController: UIViewController { IBOutlet weak var typewriterLabel: TypewriterLabel! IBOutlet weak var startButton: UIButton! IBOutlet weak var skipButton: UIButton! private let demoText 打字机效果是iOS开发中一个经典又有趣的动态文字展示方式。 它模拟了老式机械打字机逐字敲出的过程为界面增添叙事感。 这个长文本案例里我们会展示如何优雅地处理换行、速度控制、和交互打断。 override func viewDidLoad() { super.viewDidLoad() startButton.addTarget(self, action: #selector(startTapped), for: .touchUpInside) skipButton.addTarget(self, action: #selector(skipTapped), for: .touchUpInside) } objc private func startTapped() { typewriterLabel.typewrite(text: demoText, speed: 30) } objc private func skipTapped() { typewriterLabel.skip() } }3.3 速度参数的工程化计算你可能会疑惑打字速度speed这个参数怎么确定。我在Demo里做了个小实验发现每秒10个字符适合营造悬念长文本展示会让人着急每秒20个字符iOS系统键盘输入的平均速度适合通用场景每秒30个字符适合快速浏览型内容比如日志打印每秒50个字符接近瞬间出现但仍有逐字感更精确的做法是按文本长度自适应速度。你可以先给定一个目标总时长比如3000毫秒然后用总时长除以字符数自动算出间隔func autoSpeed(for text: String, totalDuration: TimeInterval 3.0) - Double { let count max(Array(text).count, 1) return Double(count) / totalDuration }这样不管多长的文本最后都是3秒播完省心。3.4 光标闪烁的附加效果不少打字机效果会带一个光标闪烁的小尾巴形如▌或_增强拟物感。实现思路是往Label里追加一个字符用一个独立Timer控制它的显隐。我用了一个巧妙点的做法在updateNextCharacter的回调里把当前显示文本拼上光标字符然后用另一个Timer切换光标的透明度而不是去增删字符。这避免了频繁修改文本内容导致的布局抖动。4. 实操中的常见问题与避坑指南4.1 内存泄漏定时器不销毁导致控制器无法释放这是我最初用Timer方案时掉得最深的坑。在ViewController里强引用了TimerTimer的回调闭包又强引用了self形成了Timer - Block - self - Timer的循环引用页面退出后deinit永远不会执行。排查方法Xcode 13的Memory Graph Debugger能直观看到这种循环。进去后能看到一条红色的循环引用弧线从Timer指向ViewController。解决方式在闭包里用[weak self]打破引用环。在viewWillDisappear或者类的deinit里调用timer?.invalidate()/timer?.cancel()。最稳妥的是用我上面Demo里的做法把Timer封装在管理类里控制器只持有管理类对象通过stop()统一处理清理逻辑。4.2 Emoji与组合字符显示乱码打Typewriter效果时直接Array(text)这一步看似转换成了字符数组但Swift的Character是扩展字位簇一个Character可能由多个码点组成。对绝大多数场景这反而是好事——比如这个家庭Emoji会被当成一个完整的Character不会拆出半个来显示成乱码。真正容易出问题的是带修饰符的国旗Emoji比如由一个区域指示符对组成。Array()拿到的是一个完整的Character所以直接追加显示是安全的。需要避免的是用NSString的方式去做按UTF-16单元截取// 错误示范按UTF-16截取容易截出半个Emoji let nsText text as NSString let sub nsText.substring(with: NSRange(location: 0, length: currentIndex))这样会把从中截断显示出来就是几个残缺的方框。牢记永远用Swift原生Character数组做截取不要碰NSRange。4.3 UI更新闪烁或乱跳这种现象通常发生在多行文本快速刷新时Label的高度频繁变化导致AutoLayout约束反复计算界面像是在抖动。解决方法有几个按推荐程度排序预设Label的最终frame提前用text.boundingRect(with:options:attributes:context:)预估完整文本的尺寸把Label的高度约束固定下来再开始打字机动画。使用textView替代label并禁用滚动TextView天然支持内容尺寸固定用isScrollEnabled false后高度不会随内容频繁跳动。在动画期间开启UIView.performWithoutAnimation关闭隐式动画减少CALayer的重复布局计算。如果你用我前面Demo里的TypewriterLabel第1种方式最合适。在typewrite(text:)方法里可以先let maxSize CGSize(width: bounds.width, height: .greatestFiniteMagnitude) let attributes: [NSAttributedString.Key: Any] [.font: font ?? UIFont.systemFont(ofSize: 17)] let boundingRect text.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: attributes, context: nil) heightConstraint?.constant ceil(boundingRect.height) layoutIfNeeded()4.4 用户快速点击时动画状态错乱在实际使用中用户可能连续点击开始按钮两次或者在上一次动画还没播完时就点了跳过。如果没有做好状态管理会出现两个Timer同时跑文本内容一会儿多一会儿少最后直接乱掉。处理技巧是在每次start前强制stopfunc start() { manager.stop() // 先停掉旧的 manager.start(...) // 再启动新的 }这样无论用户怎么乱点始终只会有一个Timer在运行。界面上同时可以加一个isPlaying标记在动画播放期间禁用开始按钮只允许触发跳过或停止。4.5 长文本性能优化当文本长度超过几千字时每次更新都做String(characters.prefix(currentIndex))会产生O(n²)的字符串拼接开销。实测在iPhone 8这类老机型上3000字的文本就能感受到打字机在变卡。优化思路有两个方向分段更新策略不要求逐字特效时可以一次更新5-10个字牺牲少量细腻度换取流畅性。改用NSAttributedString增量拼接使用NSMutableAttributedString每次在末尾append一个新字符避免全量重建。let mutableAttr NSMutableAttributedString() // 每次事件只更新新增的那1~N个字符 mutableAttr.append(NSAttributedString(string: String(nextChar), attributes: attrs)) textView.attributedText mutableAttr这种方式的时间复杂度接近O(n)在长文本场景下的性能提升非常明显。5. 进阶玩法从Demo到工程落地5.1 添加交互控制与手势支持基础的打字机效果只是单向播放实际项目中往往需要用户干预。我建议在管理类里增加暂停/恢复的接口func pause() { dispatchSourceTimer?.suspend() } func resume() { dispatchSourceTimer?.resume() }注意DispatchSourceTimer的suspend()和resume()必须成对调用重复suspend或对已cancel的timer调用resume会直接crash。这个敏感度比UI操作还要高一定要加状态判断。5.2 将纯文本升级为富文本打字机很多场景需要文字中带颜色、链接或者特殊字体。处理方式是在TypewriterEffectManager里把单一的fullText扩展成fullAttributedText然后按字符切割富文本。这里有个坑NSAttributedString的字符切割远比纯字符串复杂附加的属性必须手动传递。更简单高效的做法是先按字符数组切出字符串再对每个字符查找它在原富文本中的属性范围extension NSAttributedString { func typewriterSliced(to index: Int) - NSAttributedString { guard index 0, index length else { return self } let range NSRange(location: 0, length: index) return attributedSubstring(from: range) } }attributedSubstring会自动保留所有字符属性不用手动处理。5.3 接入音频反馈打字机效果配合哒哒哒的机械键盘音效沉浸感直接翻倍。实现时有个性能关键点如果用AudioServicesPlaySystemSound每打一个字就调一次声音播放会有明显的延迟因为系统音效的加载是需要时间的。稳妥做法是预加载短音频文件到SystemSoundIDimport AudioToolbox var soundID: SystemSoundID 0 AudioServicesCreateSystemSoundID(Bundle.main.url(forResource: typewriter_key, withExtension: wav)! as CFURL, soundID) // 每次打字时调用 AudioServicesPlaySystemSound(soundID)这样音频始终驻留在内存中触发时几乎没有延时。但注意别让音效和UI更新完全同步——实际听感上音效比字符显示晚10~20毫秒反而更自然。5.4 适配SwiftUI的无缝集成虽然Demo主体是UIKit写的但现在不少新项目是SwiftUI为主。为了复用我给TypewriterLabel写了一个UIViewRepresentable包装让SwiftUI页面也能直接使用import SwiftUI struct TypewriterText: UIViewRepresentable { let text: String var speed: Double 20 func makeUIView(context: Context) - TypewriterLabel { let label TypewriterLabel() label.numberOfLines 0 return label } func updateUIView(_ uiView: TypewriterLabel, context: Context) { uiView.typewrite(text: text, speed: speed) } }注意updateUIView在SwiftUI里可能会被多次调用需要加判断防止动画重复触发。通常的做法是把源文本存在一个私有变量里只有文本变化时才重新调用typewrite。6. 最终实践心得与扩展建议这个Demo虽然代码量不大但麻雀虽小五脏俱全——定时器管理、线程切换、富文本处理、生命周期管理、性能优化覆盖了iOS开发中多个核心领域。我在实际项目里还做过一次比较特别的改造把打字机效果应用到UITableView的Cell上实现聊天消息逐条「边说边出」的效果。那次改造遇到的最大挑战和前面提到的AutoLayout高度闪烁完全一致解决方式同样是预设高度、关闭隐式动画。这也印证了一个基础效果理解扎实了在不同业务场景里迁移其实没有太多额外成本。最后分享一个我自己用着很顺手的小技巧把打字机效果的触发时机做成通知驱动而不是在ViewController里直接调用。比如在某个业务场景下消息到达时发送Notification.Name.typewritterShouldStart管理类接收后自动取最新文本播放。这样UI层和逻辑层完全解耦后续接SwiftUI、接Widget、接App Intents都会轻松很多。打字机效果不复杂但把它做稳定、做优雅、做出专业感还是需要绕一圈路的。希望这份踩坑总结能帮你少走几步弯路。本文还有配套的精品资源点击获取