Skip to content

Commit d1989aa

Browse files
committed
feat: add text parser example
1 parent e8614d5 commit d1989aa

File tree

3 files changed

+294
-0
lines changed

3 files changed

+294
-0
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//
2+
// SimpleEmoticonParser.swift
3+
// RETextExample
4+
//
5+
// Created by phoenix on 2025/6/19.
6+
//
7+
8+
import REText
9+
import UIKit
10+
11+
/// 一个简单的表情符号解析器,遵循 TextParser 协议。
12+
///
13+
/// 使用此解析器可以将指定的字符串片段映射为图片表情。
14+
/// 例如: "你好 :smile:" -> "你好 😀"
15+
///
16+
/// 它也可以用于扩展 Unicode 本身不支持的“自定义表情”。
17+
public class SimpleEmoticonParser: TextParser {
18+
19+
/// 自定义表情的映射字典。
20+
/// Key 是一个特定的普通字符串,例如 `":smile:"`。
21+
/// Value 是一个 UIImage,它将替换文本中对应的普通字符串。
22+
public var emoticonMapper: [String: UIImage]? {
23+
get {
24+
// 使用同步队列确保线程安全读取
25+
return queue.sync { _mapper }
26+
}
27+
set {
28+
// 使用同步队列确保线程安全写入
29+
queue.sync {
30+
_mapper = newValue
31+
32+
// 如果映射为空,则清空正则表达式
33+
guard let mapper = _mapper, !mapper.isEmpty else {
34+
_regex = nil
35+
return
36+
}
37+
38+
// 从所有 key 创建正则表达式的模式。
39+
// NSRegularExpression.escapedPattern(for:) 会自动处理需要转义的特殊字符,
40+
// 例如 . ? [ ] ( ) 等,这比手动检查更安全、更高效。
41+
let pattern = mapper.keys
42+
.map { NSRegularExpression.escapedPattern(for: $0) }
43+
.joined(separator: "|")
44+
45+
// 将所有模式包裹在一个捕获组中 `()`,以便匹配整个表情文本
46+
let fullPattern = "(\(pattern))"
47+
48+
do {
49+
_regex = try NSRegularExpression(pattern: fullPattern, options: [])
50+
} catch {
51+
// 在实践中,处理或记录这个错误是很重要的
52+
print("SimpleEmoticonParser: 创建正则表达式失败,pattern: '\(fullPattern)', 错误: \(error)")
53+
_regex = nil
54+
}
55+
}
56+
}
57+
}
58+
59+
// 用于保护内部属性的私有队列
60+
private let queue = DispatchQueue(label: "com.relabel.simpleemoticonparser.lock")
61+
private var _regex: NSRegularExpression?
62+
private var _mapper: [String: UIImage]?
63+
64+
public init() {}
65+
66+
/// `TextParser` 协议的核心实现方法。
67+
/// - Parameters:
68+
/// - text: 需要被解析的可变富文本字符串。
69+
/// - selectedRange: 指向当前选中范围的指针,解析器可能会在修改文本后更新它。
70+
/// - Returns: 如果文本被修改则返回 `true`,否则返回 `false`。
71+
public func parseText(_ text: NSMutableAttributedString?, selectedRange: UnsafeMutablePointer<NSRange>?) -> Bool {
72+
guard let text, !text.string.isEmpty else { return false }
73+
74+
// 从线程安全的队列中获取当前的映射和正则表达式
75+
let (mapper, regex) = queue.sync { (_mapper, _regex) }
76+
77+
guard let currentMapper = mapper, let currentRegex = regex, !currentMapper.isEmpty else {
78+
return false
79+
}
80+
81+
// 查找所有匹配项
82+
let matches = currentRegex.matches(in: text.string, options: [], range: text.rangeOfAll)
83+
if matches.isEmpty { return false }
84+
85+
var hasChanges = false
86+
var currentSelectedRange = selectedRange?.pointee ?? NSRange(location: 0, length: 0)
87+
let hasSelection = selectedRange != nil
88+
89+
// 从后向前遍历匹配项进行替换。
90+
// 这是至关重要的,因为从后向前替换不会影响前面未处理的匹配项的 range.location。
91+
for match in matches.reversed() {
92+
let matchRange = match.range
93+
94+
// 获取匹配到的表情文本,例如 ":smile:"
95+
let matchedString = (text.string as NSString).substring(with: matchRange)
96+
97+
// 从映射中查找对应的图片
98+
guard let image = currentMapper[matchedString] else { continue }
99+
100+
// 获取匹配位置的字体大小,以便表情图片可以自适应。
101+
// 如果没有指定字体,则使用一个合理的默认值。
102+
var font: UIFont
103+
if let aFont = text.attribute(.font, at: matchRange.location, effectiveRange: nil) as? UIFont {
104+
font = aFont
105+
} else {
106+
font = UIFont.systemFont(ofSize: 12) // CoreText 的默认字体大小
107+
}
108+
109+
// 创建一个包含图片附件的富文本字符串
110+
let attachmentString = NSAttributedString.attachmentString(with: image, font: font)
111+
112+
// 替换文本
113+
text.replaceCharacters(in: matchRange, with: attachmentString)
114+
115+
// 如果外部传入了 selectedRange,我们需要在文本修改后对其进行校正
116+
if hasSelection {
117+
currentSelectedRange = self.updatedSelectedRange(
118+
for: matchRange,
119+
withLength: attachmentString.length,
120+
currentSelectedRange: currentSelectedRange
121+
)
122+
}
123+
124+
hasChanges = true
125+
}
126+
127+
// 如果有选中范围,将更新后的值写回指针
128+
if hasSelection {
129+
selectedRange?.pointee = currentSelectedRange
130+
}
131+
132+
return hasChanges
133+
}
134+
135+
/// 在文本替换期间修正 selectedRange。
136+
/// 这是对 YYText 中 `_replaceTextInRange:withLength:selectedRange:` 方法的 Swift 实现。
137+
private func updatedSelectedRange(for textReplacementInRange: NSRange, withLength: Int, currentSelectedRange: NSRange) -> NSRange {
138+
var newRange = currentSelectedRange
139+
let replacementLength = withLength
140+
let originalLength = textReplacementInRange.length
141+
let delta = replacementLength - originalLength
142+
143+
// 情况 1: 替换发生在选中区域的右侧,选中区域不受影响
144+
if textReplacementInRange.location >= newRange.location + newRange.length {
145+
return newRange
146+
}
147+
148+
// 情况 2: 替换发生在选中区域的左侧,选中区域需要整体平移
149+
if newRange.location >= textReplacementInRange.location + originalLength {
150+
newRange.location += delta
151+
return newRange
152+
}
153+
154+
// 情况 3: 替换区域与选中区域有交集或包含关系
155+
if textReplacementInRange.location == newRange.location {
156+
// 替换的起始位置与选中区域相同
157+
if originalLength >= newRange.length {
158+
// 替换区域完全覆盖了选中区域
159+
newRange.length = 0
160+
newRange.location += replacementLength
161+
} else {
162+
// 替换区域是选中区域的前缀部分
163+
newRange.length += delta
164+
}
165+
} else if textReplacementInRange.location > newRange.location {
166+
// 替换区域在选中区域内部
167+
if textReplacementInRange.location + originalLength < newRange.location + newRange.length {
168+
// 替换区域被选中区域完全包含
169+
newRange.length += delta
170+
} else {
171+
// 替换区域是选中区域的后缀部分
172+
newRange.length = textReplacementInRange.location - newRange.location
173+
}
174+
} else { // textReplacementInRange.location < newRange.location
175+
// 选中区域在替换区域内部
176+
newRange.location += delta
177+
if textReplacementInRange.location + originalLength < newRange.location + newRange.length {
178+
newRange.length += delta
179+
} else {
180+
newRange.length = 0
181+
}
182+
}
183+
184+
return newRange
185+
}
186+
}
187+
188+
189+
// MARK: - NSAttributedString 辅助扩展
190+
191+
fileprivate extension NSAttributedString {
192+
193+
/// 创建一个包含图片附件的富文本字符串,使其大小与字体匹配。
194+
static func attachmentString(with image: UIImage, font: UIFont) -> NSAttributedString {
195+
let imageView = YYAnimatedImageView(image: image)
196+
let attachment = TextAttachment()
197+
attachment.content = .view(imageView)
198+
199+
// 计算图片的 bounds 以便垂直对齐。
200+
// font.descender 是基线以下的部分,通常为负值。
201+
// 将 y 设置为 descender 可以让图片的底部与文本的基线对齐。
202+
let fontHeight = font.ascender - font.descender
203+
let imageSize = image.size
204+
205+
// 如果图片高度为0,则无法缩放
206+
guard imageSize.height > 0 else {
207+
return NSAttributedString(attachment: attachment)
208+
}
209+
210+
let scale = fontHeight / imageSize.height
211+
attachment.bounds = CGRect(x: 0,
212+
y: font.descender,
213+
width: imageSize.width * scale,
214+
height: fontHeight)
215+
216+
return NSAttributedString(attachment: attachment)
217+
}
218+
}

RETextExample/RETextExample/ViewControllers/DemoViewController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class DemoViewController: UITableViewController {
3636
addCell("Text Link", className: "TextLinkViewController")
3737
addCell("Text Selection", className: "TextSelectionViewController")
3838
addCell("Size Calculation", className: "TextSizeCalculationViewController")
39+
addCell("Text Parser (Emoticon)", className: "TextEmoticonViewController")
3940
addCell("Attributes Separation", className: "TextAttributesSeparationViewController")
4041
addCell("Features Comparison", className: "FeaturesComparisonViewController", storyboardID: "FeaturesComparison")
4142
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//
2+
// TextEmoticonViewController.swift
3+
// RETextExample
4+
//
5+
// Created by phoenix on 2025/6/19.
6+
//
7+
8+
import UIKit
9+
import REText
10+
11+
@objc(TextEmoticonViewController)
12+
class TextEmoticonViewController: UIViewController {
13+
private var label: RELabel!
14+
15+
override func viewDidLoad() {
16+
super.viewDidLoad()
17+
view.backgroundColor = .white
18+
ExampleHelper.addDebugOption(to: self)
19+
20+
setupLabel()
21+
}
22+
23+
private func setupLabel() {
24+
let parser = SimpleEmoticonParser()
25+
parser.emoticonMapper = createEmoticonMapper()
26+
27+
label = RELabel()
28+
label.textParser = parser
29+
label.font = .systemFont(ofSize: 22)
30+
label.numberOfLines = 0
31+
label.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
32+
33+
let text = "Hahahah:smile:, it's emoticons::cool::arrow::cry::wink:\n\nYou can input \":\" + \"smile\" + \":\" to display smile emoticon, or you can copy and paste these emoticons."
34+
label.text = text
35+
36+
view.addSubview(label)
37+
38+
label.frame = view.bounds
39+
}
40+
41+
42+
private func createEmoticonMapper() -> [String: YYImage] {
43+
var mapper: [String: YYImage] = [:]
44+
45+
mapper[":smile:"] = imageWithName("002")
46+
mapper[":cool:"] = imageWithName("013")
47+
mapper[":biggrin:"] = imageWithName("047")
48+
mapper[":arrow:"] = imageWithName("007")
49+
mapper[":confused:"] = imageWithName("041")
50+
mapper[":cry:"] = imageWithName("010")
51+
mapper[":wink:"] = imageWithName("085")
52+
53+
return mapper.compactMapValues { $0 }
54+
}
55+
56+
func imageWithName(_ name: String) -> YYImage? {
57+
guard let path = Bundle.main.pathForScaledResource(name, ofType: "gif", inDirectory: "EmoticonQQ.bundle") else {
58+
print("Warning: Could not find path for \(name).gif")
59+
return nil
60+
}
61+
62+
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
63+
print("Warning: Could not load data for \(name).gif")
64+
return nil
65+
}
66+
67+
guard let image = YYImage(data: data, scale: 2.0) else {
68+
print("Warning: YYImage could not decode data for \(name).gif")
69+
return nil
70+
}
71+
72+
image.preloadAllAnimatedImageFrames = true
73+
return image
74+
}
75+
}

0 commit comments

Comments
 (0)