如何在UITextView中实现类似Xcode占位符的特定文本可视化替换
解决方案(iOS 15+)
1. 定义TAG匹配工具类
先实现一个工具类,用来检测文本中<#TAG#>格式的内容,返回每个TAG的原始范围和内部文本:
import Foundation struct TagMatch { let range: NSRange // 原文本中<#TAG#>的完整范围 let content: String // TAG内部的核心文本(如"TAG") } class TagDetector { private static let regex = try! NSRegularExpression(pattern: "<#([^#]+)#>", options: []) static func findTags(in text: String) -> [TagMatch] { let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) return matches.map { match in let contentRange = match.range(at: 1) let content = (text as NSString).substring(with: contentRange) return TagMatch(range: match.range, content: content) } } }
2. 自定义NSTextLayoutManager处理绘制逻辑
因为要保留原文本,我们不直接修改attributedText,而是通过自定义NSTextLayoutManager,在绘制时替换TAG的显示样式:
import UIKit class TagLayoutManager: NSTextLayoutManager { private let tagBgColor = UIColor.systemBlue.withAlphaComponent(0.2) private let tagTextColor = UIColor.systemBlue private let symbolImage = UIImage(systemName: "tag")?.withTintColor(.systemBlue, renderingMode: .alwaysOriginal) override func drawGlyphs(forGlyphRange glyphRange: NSRange, at origin: CGPoint) { let text = textStorage.string let tagMatches = TagDetector.findTags(in: text) // 先绘制非TAG区域的原文本 var remainingRange = glyphRange for match in tagMatches { guard let glyphTagRange = self.glyphRange(forCharacterRange: match.range, actualCharacterRange: nil) else { continue } let intersection = NSIntersectionRange(remainingRange, glyphTagRange) if intersection.length > 0 { // 绘制TAG之前的普通文本 let beforeRange = NSRange(location: remainingRange.location, length: intersection.location - remainingRange.location) if beforeRange.length > 0 { super.drawGlyphs(forGlyphRange: beforeRange, at: origin) } // 绘制自定义TAG样式 drawCustomTag(for: match, glyphRange: glyphTagRange, origin: origin) // 更新剩余待绘制范围 remainingRange = NSRange( location: intersection.location + intersection.length, length: remainingRange.length - (intersection.location + intersection.length - remainingRange.location) ) } } // 绘制剩余的普通文本 if remainingRange.length > 0 { super.drawGlyphs(forGlyphRange: remainingRange, at: origin) } } private func drawCustomTag(for match: TagMatch, glyphRange: NSRange, origin: CGPoint) { guard let textLine = textLineManager.textLine(forGlyphAt: glyphRange.location), let lineFrame = textLine.lineFragmentFrame else { return } let tagContent = match.content let symbolSize = CGSize(width: 12, height: 12) let padding: CGFloat = 6 let textFont = UIFont.systemFont(ofSize: 14) // 计算TAG整体尺寸 let textSize = tagContent.size(withAttributes: [.font: textFont]) let totalWidth = symbolSize.width + 4 + textSize.width + padding * 2 let totalHeight = max(symbolSize.height, textSize.height) + padding * 2 // 绘制圆角背景 let bgRect = CGRect( x: lineFrame.minX + origin.x, y: lineFrame.minY + origin.y + (lineFrame.height - totalHeight)/2, width: totalWidth, height: totalHeight ) let bgPath = UIBezierPath(roundedRect: bgRect, cornerRadius: totalHeight/2) tagBgColor.setFill() bgPath.fill() // 绘制SF Symbol if let symbolImage = symbolImage { let symbolRect = CGRect( x: bgRect.minX + padding, y: bgRect.minY + (totalHeight - symbolSize.height)/2, width: symbolSize.width, height: symbolSize.height ) symbolImage.draw(in: symbolRect) } // 绘制TAG文本 let textRect = CGRect( x: bgRect.minX + padding + symbolSize.width + 4, y: bgRect.minY + (totalHeight - textSize.height)/2, width: textSize.width, height: textSize.height ) tagContent.draw(in: textRect, withAttributes: [ .font: textFont, .foregroundColor: tagTextColor ]) } }
3. 自定义UITextView限制选中范围
通过拦截选中逻辑,确保用户只能选中整个TAG,无法选中部分内容:
class TagTextView: UITextView { override func setSelectedRange(_ range: NSRange) { let text = self.text ?? "" let tagMatches = TagDetector.findTags(in: text) // 检查选中范围是否与TAG重叠 for match in tagMatches { let intersection = NSIntersectionRange(range, match.range) if intersection.length > 0 { // 自动选中整个TAG super.setSelectedRange(match.range) return } } // 非TAG区域正常处理 super.setSelectedRange(range) } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { // 拦截长按选中,确保选中整个TAG if gestureRecognizer is UILongPressGestureRecognizer { let location = gestureRecognizer.location(in: self) guard let charIndex = self.characterRange(at: location)?.startIndex else { return true } let charPosition = self.offset(from: self.beginningOfDocument, to: charIndex) let text = self.text ?? "" for match in TagDetector.findTags(in: text) { if NSLocationInRange(charPosition, match.range) { self.selectedRange = match.range return false // 阻止系统默认选中逻辑 } } } return super.gestureRecognizerShouldBegin(gestureRecognizer) } }
4. 配置并使用自定义TextView
在ViewController中初始化自定义TextView,替换默认的LayoutManager:
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let textView = TagTextView(frame: view.bounds.inset(by: UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16))) textView.font = UIFont.systemFont(ofSize: 14) textView.backgroundColor = .systemBackground // 配置自定义LayoutManager let layoutManager = TagLayoutManager() let textContainer = NSTextContainer(size: textView.bounds.size) textContainer.widthTracksTextView = true layoutManager.textContainer = textContainer textView.textStorage.addLayoutManager(layoutManager) view.addSubview(textView) // 测试文本 textView.text = "这是一个<#测试TAG#>,还有另一个<#示例标签#>,普通文本和TAG共存。" } }
关键说明
- 原文本保留:所有
<#TAG#>格式的原始文本不会被修改,仅在绘制阶段替换显示样式,完全满足需求。 - iOS15+兼容:基于TextKit2的
NSTextLayoutManager实现,符合系统版本要求。 - 选中限制:通过重写选中方法和手势拦截,强制用户只能选中完整的TAG,无法选中局部内容。
- SF Symbol集成:在TAG绘制流程中优先渲染小型标签符号,再绘制核心文本,符合额外需求。
内容的提问来源于stack exchange,提问作者Zaphod
相关产品推荐
相关产品推荐

