You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 17:22:04