前言
度过春节期间的安逸期,需要从慵懒的状态尽快恢复过来,节前有几个月时间,趁着公司业务线不怎么繁忙,抱着学习的态度,尝试将 YY 大神的 YYText 用 Swift 重新实现一下(之前用 Swift 最多写写 Demo,没有用来做项目)。但是由于年前还有个 bug 没有修复,怕大家的 issue 雪花般飞过来,果断没敢开源出来。新年新气象,改完 bug 赶紧和大家分享一下啦 ?
目前项目已经实现了 YYText 中的所有功能,如果大家遇到什么问题,欢迎提 issue,或者邮箱联系 a1049145827@hotmail.com ,如果觉得有用,请不要吝惜您的 star ✨。
用过 YYText 的同学应该已经相当熟悉了,传送门。
一些挑战
由于近年来 Swift 发展迅速,ABI 一直不能稳定下来,导致开发者们都在吐槽:“每年学习一门新语言”,这样就直接导致在网上查资料非常困难,好不容易查到的资料,demo 代码甚至都不能通过编译,这样推进的效率大打折扣,确实非常痛苦,眼看 Swift 就要发布 Swift5,心里似乎又有了希望。于是决心要用 Swift 把 YYText 的功能实现一遍,一来练习 Swift 语法,二来以后也可以持续维护,希望好的轮子可以被更多的开发者认可和采用,目前本项目已经可以在 Swift5 (Xcode10 beta3)环境下正常编译运行。
项目介绍
功能强大的 iOS 富文本编辑与显示框架。
(该项目是 YYText 的 Swift 版本,项目的前缀 'BS' 来自于 BlueSky,就是创作了《冰河世纪》系列电影的 BlueSky 工作室)
特性
- API 兼容 UILabel 和 UITextView
- 支持高性能的异步排版和渲染
- 扩展了 CoreText 的属性以支持更多文字效果
- 支持 UIImage、UIView、CALayer 作为图文混排元素
- 支持添加自定义样式的、可点击的文本高亮范围
- 支持自定义文本解析 (内置简单的 Markdown/表情解析)
- 支持文本容器路径、内部留空路径的控制
- 支持文字竖排版,可用于编辑和显示中日韩文本
- 支持图片和富文本的复制粘贴
- 文本编辑时,支持富文本占位符
- 支持自定义键盘视图
- 撤销和重做次数的控制
- 富文本的序列化与反序列化支持
- 支持多语言,支持 VoiceOver
- 全部代码都有文档注释
架构
本项目架构与 YYText 保持一致
文本属性
BSText 原生支持的属性
Demo | Attribute Name | Class |
---|---|---|
TextAttachment | TextAttachment | |
TextHighlight | TextHighlight | |
TextBinding | TextBinding | |
TextShadow TextInnerShadow | TextShadow | |
TextBorder | TextBorder | |
TextBackgroundBorder | TextBorder | |
TextBlockBorder | TextBorder | |
TextGlyphTransform | NSValue(CGAffineTransform) | |
TextUnderline | TextDecoration | |
TextStrickthrough | TextDecoration | |
TextBackedString | TextBackedString |
BSText 支持的 CoreText 属性
Demo | Attribute Name | Class |
---|---|---|
Font | UIFont(CTFontRef) | |
Kern | NSNumber | |
StrokeWidth | NSNumber | |
StrokeColor | CGColorRef | |
Shadow | NSShadow | |
Ligature | NSNumber | |
VerticalGlyphForm | NSNumber(BOOL) | |
WritingDirection | NSArray(NSNumber) | |
RunDelegate | CTRunDelegateRef | |
TextAlignment | NSParagraphStyle (NSTextAlignment) | |
LineBreakMode | NSParagraphStyle (NSLineBreakMode) | |
LineSpacing | NSParagraphStyle (CGFloat) | |
ParagraphSpacing ParagraphSpacingBefore | NSParagraphStyle (CGFloat) | |
FirstLineHeadIndent | NSParagraphStyle (CGFloat) | |
HeadIndent | NSParagraphStyle (CGFloat) | |
TailIndent | NSParagraphStyle (CGFloat) | |
MinimumLineHeight | NSParagraphStyle (CGFloat) | |
MaximumLineHeight | NSParagraphStyle (CGFloat) | |
LineHeightMultiple | NSParagraphStyle (CGFloat) | |
BaseWritingDirection | NSParagraphStyle (NSWritingDirection) | |
DefaultTabInterval TabStops | NSParagraphStyle CGFloat/NSArray(NSTextTab) |
用法
基本用法
// BSLabel (和 UILabel 用法一致)
let label = BSLabel()
label.frame = ...
label.font = ...
label.textColor = ...
label.textAlignment = ...
label.lineBreakMode = ...
label.numberOfLines = ...
label.text = ...
// BSTextView (和 UITextView 用法一致)
let textView = BSTextView()
textView.frame = ...
textView.font = ...
textView.textColor = ...
textView.dataDetectorTypes = ...
textView.placeHolderText = ...
textView.placeHolderTextColor = ...
textView.delegate = ...
复制代码
属性文本
// 1. 创建一个属性文本
let text = NSMutableAttributedString(string: "Some Text, blabla...")
// 2. 为文本设置属性
text.bs_font = UIFont.boldSystemFont(ofSize:30)
text.bs_color = UIColor.blue
text.bs_set(color: UIColor.red, range: NSRange(location: 0, length: 4))
text.bs_lineSpacing = 10
// 3. 赋值到 BSLabel 或 BSTextView
let label = BSLabel()
label.frame = CGRect(x: 15, y: 100, width: 200, height: 80)
label.attributedText = text
let textView = BSTextView()
textView.frame = CGRect(x: 15, y: 200, width: 200, height: 80)
textView.attributedText = text
复制代码
文本高亮
你可以用一些已经封装好的简便方法来设置文本高亮:
text.bs_set(textHighlightRange: range,
color: UIColor.blue,
backgroundColor: UIColor.gray) { (view, text, range, rect) in
print("tap text range:...")
}
复制代码
或者用更复杂的办法来调节文本高亮的细节:
// 1. 创建一个"高亮"属性,当用户点击了高亮区域的文本时,"高亮"属性会替换掉原本的属性
let border = TextBorder.border(with: UIColor.gray, cornerRadius: 3)
let highlight = TextHighlight()
highlight.color = .white
highlight.backgroundBorder = highlightBorder
highlight.tapAction = { (containerView, text, range, rect) in
print("tap text range:...")
// 你也可以把事件回调放到 BSLabel 和 BSTextView 来处理。
}
// 2. 把"高亮"属性设置到某个文本范围
let attributedText = NSMutableAttributedString(string: " ")
attributedText.bs_set(textHighlight: highlight, range: highlightRange)
// 3. 把属性文本设置到 BSLabel 或 BSTextView
let label = BSLabel()
label.attributedText = attributedText
let textView = BSTextView()
textView.delegate = self
textView.attributedText = ...
// 4. 接受事件回调
label.highlightTapAction = { (containerView, text, range, rect) in
print("tap text range:...")
};
label.highlightLongPressAction = { (containerView, text, range, rect) in
print("tap text range:...")
};
// MARK: - TextViewDelegate
func textView(_ textView: BSTextView, didTap highlight: TextHighlight, in characterRange: NSRange, rect: CGRect) {
print("tap text range:...")
}
func textView(_ textView: BSTextView, didLongPress highlight: TextHighlight, in characterRange: NSRange, rect: CGRect) {
print("tap text range:...")
}
复制代码
图文混排
let text = NSMutableAttributedString()
let font = UIFont.systemFont(ofSize: 16)
// 嵌入 UIImage
let image = UIImage.init(named: "dribbble64_imageio")
guard let attachment = NSMutableAttributedString.bs_attachmentString(with: image, contentMode: .center, attachmentSize: image?.size ?? .zero, alignTo: font, alignment: .center) else {
return
}
text.append(attachment)
// 嵌入 UIView
let switcher = UISwitch()
switcher.sizeToFit()
guard let attachment1 = NSMutableAttributedString.bs_attachmentString(with: switcher, contentMode: .center, attachmentSize: switcher.frame.size, alignTo: font, alignment: .center) else {
return
}
text.append(attachment1)
// 嵌入 CALayer
let layer = CAShapeLayer()
layer.path = ...
guard let attachment2 = NSMutableAttributedString.bs_attachmentString(with: layer, contentMode: .center, attachmentSize: layer.frame.size, alignTo: font, alignment: .center) else {
return
}
text.append(attachment2)
复制代码
文本布局计算
let text = NSAttributedString()
let size = CGSize(width: 100, height: CGFloat.greatestFiniteMagnitude)
let container = TextContainer()
container.size = size
guard let layout = TextLayout(container: container, text: text) else {
return
}
// 获取文本显示位置和大小
layout.textBoundingRect // get bounding rect
layout.textBoundingSize // get bounding size
// 查询文本排版结果
layout.lineIndex(for: CGPoint(x: 10, y: 10))
layout.closestLineIndex(for: CGPoint(x: 10, y: 10))
layout.closestPosition(to: CGPoint(x: 10, y: 10))
layout.textRange(at: CGPoint(x: 10, y: 10))
layout.rect(for: TextRange(range: NSRange(location: 10, length: 2)))
layout.selectionRects(for: TextRange(range: NSRange(location: 10, length: 2)))
// 显示文本排版结果
let label = BSLabel()
label.frame = CGRect(x: 0, y: 0, width: layout.textBoundingSize.width, height: layout.textBoundingSize.height)
label.textLayout = layout;
复制代码
文本行位置调整
// 由于中文、英文、Emoji 等字体高度不一致,或者富文本中出现了不同字号的字体,
// 可能会造成每行文字的高度不一致。这里可以添加一个修改器来实现固定行高,或者自定义文本行位置。
// 简单的方法:
// 1. 创建一个文本行位置修改类,实现 `TextLinePositionModifier` 协议。
// 2. 设置到 Label 或 TextView。
let modifier = TextLinePositionSimpleModifier()
modifier.fixedLineHeight = 24
let label = BSLabel()
label.linePositionModifier = modifier
// 完全控制:
let modifier = TextLinePositionSimpleModifier()
modifier.fixedLineHeight = 24
let container = TextContainer()
container.size = CGSize(width: 100, height: CGFloat.greatestFiniteMagnitude)
container.linePositionModifier = modifier
guard let layout = TextLayout(container: container, text: text) else {
return
}
let label = BSLabel()
label.size = layout.textBoundingSize
label.textLayout = layout
复制代码
异步排版和渲染
// 如果你在显示字符串时有性能问题,可以这样开启异步模式:
let label = BSLabel()
label.displaysAsynchronously = true
// 如果需要获得最高的性能,你可以在后台线程用 `TextLayout` 进行预排版:
let label = BSLabel()
label.displaysAsynchronously = true // 开启异步绘制
label.ignoreCommonProperties = true // 忽略除了 textLayout 之外的其他属性
DispatchQueue.global().async {
// 创建属性字符串
let text = NSMutableAttributedString(string: "Some Text")
text.bs_font = UIFont.systemFont(ofSize: 16)
text.bs_color = UIColor.gray
text.bs_set(color: .red, range: NSRange(location: 0, length: 4))
// 创建文本容器
let container = TextContainer()
container.size = CGSize(width: 100, height: CGFloat.greatestFiniteMagnitude);
container.maximumNumberOfRows = 0;
// 生成排版结果
let layout = TextLayout(container: container, text: text)
DispatchQueue.main.async {
label.frame = CGRect(x: 0, y: 0, width: layout.textBoundingSize.width, height: layout.textBoundingSize.height)
label.textLayout = layout;
}
}
复制代码
文本容器控制
let label = BSLabel()
label.textContainerPath = UIBezierPath(...)
label.exclusionPaths = [UIBezierPath(), ...]
label.textContainerInset = UIEdgeInsets(...)
label.verticalForm = true/false
let textView = BSTextView()
textView.exclusionPaths = [UIBezierPath(), ...]
textView.textContainerInset = UIEdgeInsets(...)
textView.verticalForm = true/false
复制代码
文本解析
// 1. 创建一个解析器
// 内置简单的表情解析
let simpleEmoticonParser = TextSimpleEmoticonParser()
var mapper = [String: UIImage]()
mapper[":smile:"] = UIImage.init(named: "smile.png")
mapper[":cool:"] = UIImage.init(named: "cool.png")
mapper[":cry:"] = UIImage.init(named: "cry.png")
mapper[":wink:"] = UIImage.init(named: "wink.png")
simpleEmoticonParser.emoticonMapper = mapper;
// 内置简单的 markdown 解析
let markdownParser = TextSimpleMarkdownParser()
markdownParser.setColorWithDarkTheme()
// 实现 `TextParser` 协议的自定义解析器
let parser = MyCustomParser()
// 2. 把解析器添加到 BSLabel 或 BSTextView
let label = BSLabel()
label.textParser = parser
let textView = BSTextView()
textView.textParser = parser
复制代码
Debug
// 设置一个全局的 debug option 来显示排版结果。
let debugOption = TextDebugOption()
debugOption.baselineColor = .red
debugOption.ctFrameBorderColor = .red
debugOption.ctLineFillColor = UIColor(red: 0, green: 0.463, blue: 1, alpha: 0.18)
debugOption.cgGlyphBorderColor = UIColor(red: 1, green: 0.524, blue: 0, alpha: 0.2)
TextDebugOption.setSharedDebugOption(debugOption)
复制代码
更多示例
查看演示工程 Demo/BSTextDemo.xcodeproj
:
安装
CocoaPods
-
在 Podfile 中添加
pod 'BSText'
。source 'https://github.com/CocoaPods/Specs.git' platform :ios, '8.0' use_frameworks! target 'MyApp' do # your other pod # ... pod 'BSText', '~> 1.0' end 复制代码
-
执行
pod install
或pod update
。 -
导入模块
import BSText
,OC 项目中使用@import BSText;
。
Carthage
- 在 Cartfile 中添加
github "a1049145827/BSText"
。 - 执行
carthage update --platform ios
并将生成的 framework 添加到你的工程。 - 导入模块
import BSText
,OC 项目中使用@import BSText;
。
手动安装
- 下载 BSText 文件夹内的所有内容。
- 将 BSText 内的源文件添加(拖放)到你的工程。
- 链接以下 frameworks:
- UIKit
- CoreFoundation
- CoreText
- QuartzCore
- Accelerate
- MobileCoreServices
- 导入模块
import BSText
,OC 项目中使用@import BSText;
。
注意
你可以添加 YYImage 或 YYWebImage 到你的工程,以支持动画格式(GIF/APNG/WebP)的图片。
文档
本项目目前还没有生成在线文档,你可以在 CocoaDocs 查看 YYText 的在线 API 文档,也可以用 appledoc 本地生成文档。
系统要求
该项目最低支持 iOS 8.0
和 Xcode 10.0
。
已知问题
- 与 YYText 一样,BSText 并不能支持所有 CoreText/TextKit 的属性,比如 NSBackgroundColor、NSStrikethrough、NSUnderline、NSAttachment、NSLink 等,但 BSText 中基本都有对应属性作为替代。详情见上方表格。
- BSTextView 未实现局部刷新,所以在输入和编辑大量的文本(比如超过大概五千个汉字、或大概一万个英文字符)时会出现较明显的卡顿现象。
- 竖排版时,添加 exclusionPaths 在少数情况下可能会导致文本显示空白。
- 当添加了非矩形的 textContainerPath,并且有嵌入大于文本排版方向宽度的 RunDelegate 时,RunDelegate 之后的文字会无法显示。这是 CoreText 的 Bug(或者说是 Feature)。