背景色和圆角

我有一个关于自定义 UIView圆角和文字背景颜色的问题。

基本上,我需要在一个自定义的 UIView 中达到这样的效果(附图-注意一边的圆角) : Background highlight

我认为使用的方法是:

  • 使用核心文本获得字形运行。
  • 检查高光范围。
  • 如果当前运行在突出显示范围内,则在绘制标志符号运行之前绘制一个具有圆角和所需填充颜色的背景矩形。
  • 绘制字形运行。

然而,我不确定这是否是唯一的解决方案(或者就此而言,这是否是最有效的解决方案)。

使用 UIWebView不是一个选项,所以我必须在一个自定义的 UIView

我的问题是,这是最好的方法吗? 我的方向是否正确?还是我错过了什么重要的事情,或者做错了什么?

29729 次浏览

创建一个自定义视图,它呈现同样的旧的 NSAttributedString,但是有圆角。

与 Android 的 SpannableString不同,iOS 不支持“自定义字符串属性的自定义渲染”,至少在没有完整的自定义视图的情况下不支持(编写本文时,2022)。


我设法达到了上述效果,所以我想我会发布一个相同的答案。

如果任何人有任何建议,使这更有效,请随时贡献。我会确保你的答案是正确的。:)

为此,您需要向 NSAttributedString添加一个“自定义属性”。

基本上,这意味着您可以添加任何键-值对,只要它是您可以添加到 NSDictionary实例的。如果系统不能识别该属性,则不执行任何操作。作为开发人员,您可以为该属性提供自定义实现和行为。

为了得到这个答案,让我们假设我已经添加了一个名为: @"MyRoundedBackgroundColor"的自定义属性,其值为 [UIColor greenColor]

对于接下来的步骤,您需要对 CoreText如何完成工作有一个基本的了解。查看 苹果核心文本编程指南了解什么是帧/行/字形运行/字形等。

所以,步骤如下:

  1. 创建自定义 UIView 子类。
  2. 具有用于接受 NSAttributedString的属性。
  3. 使用该 NSAttributedString实例创建一个 CTFramesetter
  4. 重写 drawRect:方法
  5. CTFramesetter创建一个 CTFrame实例。
  6. 您需要给出一个 CGPathRef来创建 CTFrame。使 CGPath与您希望绘制文本的框架相同。
  7. 获取当前图形上下文并翻转文本坐标系。
  8. 使用 CTFrameGetLines(...),获取刚才创建的 CTFrame中的所有行。
  9. 使用 CTFrameGetLineOrigins(...),获取 CTFrame的所有行起点。
  10. 启动一个 for loop-对于 CTLine数组中的每一行..。
  11. 使用 CGContextSetTextPosition(...)将文本位置设置为 CTLine的开始。
  12. 使用 CTLineGetGlyphRuns(...)CTLine获得所有的字形运行(CTRunRef)。
  13. 启动另一个 for loop-对于 CTRun数组中的每个象形文字运行..。
  14. 使用 CTRunGetStringRange(...)获取运行的范围。
  15. 使用 CTRunGetTypographicBounds(...)获取排版界限。
  16. 使用 CTLineGetOffsetForStringIndex(...)获取运行的 x 偏移量。
  17. 使用前面提到的函数返回的值计算边界 rect (我们称之为 runBounds)。
  18. 记住—— CTRunGetTypographicBounds(...)需要指向变量的指针来存储文本的“上升”和“下降”。您需要添加这些来获得运行高度。
  19. 使用 CTRunGetAttributes(...)获取运行的属性。
  20. 检查属性字典是否包含属性。
  21. 如果属性存在,则计算需要绘制的矩形的边界。
  22. 核心文本具有基线处的行起点。我们需要从文本的最低点画到最高点。因此,我们需要调整下降。
  23. 因此,从我们在步骤16(runBounds)中计算的边界直方图中减去下降。
  24. 现在我们有了 runBounds,我们知道我们想要画什么区域-现在我们可以使用任何的 CoreGraphis/UIBezierPath方法来绘制和填充一个直角与特定的圆角。
  25. UIBezierPath有一个称为 bezierPathWithRoundedRect:byRoundingCorners:cornerRadii:的方便的类方法,它可以让您绕过特定的拐角。在第二个参数中使用位掩码指定角点。
  26. 现在您已经填充了右边,只需使用 CTRunDraw(...)绘制运行的标志符号。
  27. 庆祝你创建了自定义属性的胜利-喝一杯啤酒或者别的什么

关于检测属性范围是否扩展到多个运行,您可以在第一次运行遇到属性时获得自定义属性的整个有效范围。如果您发现属性的最大有效范围的长度大于运行的长度,那么您需要在右侧绘制尖角(对于从左到右的脚本)。更多的数学将让您检测下一行的高亮角落样式。:)

附件是效果的截图。顶部的框是一个标准的 UITextView,我已经为其设置了 AttributedText。底部的框是使用上述步骤实现的框。已经为两个 textView 设置了相同的属性字符串。 custom attribute with rounded corners

再说一次,如果有比我用过的方法更好的方法,请一定告诉我

希望这对社区有所帮助。 :)

干杯!

我通过检查文本片段的框架来做到这一点。在我的项目中,我需要在用户输入文本时突出显示标签。

class HashtagTextView: UITextView {


let hashtagRegex = "#[-_0-9A-Za-z]+"


private var cachedFrames: [CGRect] = []


private var backgrounds: [UIView] = []


override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
configureView()
}


required init?(coder: NSCoder) {
super.init(coder: coder)
configureView()
}


override func layoutSubviews() {
super.layoutSubviews()


// Redraw highlighted parts if frame is changed
textUpdated()
}


deinit {
NotificationCenter.default.removeObserver(self)
}


@objc private func textUpdated() {
// You can provide whatever ranges needed to be highlighted
let ranges = resolveHighlightedRanges()


let frames = ranges.compactMap { frame(ofRange: $0) }.reduce([], +)


if cachedFrames != frames {
cachedFrames = frames


backgrounds.forEach { $0.removeFromSuperview() }
backgrounds = cachedFrames.map { frame in
let background = UIView()
background.backgroundColor = UIColor.hashtagBackground
background.frame = frame
background.layer.cornerRadius = 5
insertSubview(background, at: 0)
return background
}
}
}


/// General setup
private func configureView() {
NotificationCenter.default.addObserver(self, selector: #selector(textUpdated), name: UITextView.textDidChangeNotification, object: self)
}


/// Looks for locations of the string to be highlighted.
/// The current case - ranges of hashtags.
private func resolveHighlightedRanges() -> [NSRange] {
guard text != nil, let regex = try? NSRegularExpression(pattern: hashtagRegex, options: []) else { return [] }


let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..<text.endIndex, in: text))
let ranges = matches.map { $0.range }
return ranges
}
}

还有一个辅助扩展,用于确定范围框架:


extension UITextView {
func convertRange(_ range: NSRange) -> UITextRange? {
let beginning = beginningOfDocument
if let start = position(from: beginning, offset: range.location), let end = position(from: start, offset: range.length) {
let resultRange = textRange(from: start, to: end)
return resultRange
} else {
return nil
}
}


func frame(ofRange range: NSRange) -> [CGRect]? {
if let textRange = convertRange(range) {
let rects = selectionRects(for: textRange)
return rects.map { $0.rect }
} else {
return nil
}
}
}


结果文本视图: text view example

只需定制 NSLayoutManager并覆盖 drawUnderline(forGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin:) 苹果 API 文件

在此方法中,您可以自己绘制下划线,

override func drawUnderline(forGlyphRange glyphRange: NSRange,
underlineType underlineVal: NSUnderlineStyle,
baselineOffset: CGFloat,
lineFragmentRect lineRect: CGRect,
lineFragmentGlyphRange lineGlyphRange: NSRange,
containerOrigin: CGPoint
) {
let firstPosition  = location(forGlyphAt: glyphRange.location).x


let lastPosition: CGFloat


if NSMaxRange(glyphRange) < NSMaxRange(lineGlyphRange) {
lastPosition = location(forGlyphAt: NSMaxRange(glyphRange)).x
} else {
lastPosition = lineFragmentUsedRect(
forGlyphAt: NSMaxRange(glyphRange) - 1,
effectiveRange: nil).size.width
}


var lineRect = lineRect
let height = lineRect.size.height * 3.5 / 4.0 // replace your under line height
lineRect.origin.x += firstPosition
lineRect.size.width = lastPosition - firstPosition
lineRect.size.height = height


lineRect.origin.x += containerOrigin.x
lineRect.origin.y += containerOrigin.y


lineRect = lineRect.integral.insetBy(dx: 0.5, dy: 0.5)


let path = UIBezierPath(rect: lineRect)
// let path = UIBezierPath(roundedRect: lineRect, cornerRadius: 3)
// set your cornerRadius
path.fill()
}

然后构建您的 NSAttributedString并添加属性 .underlineStyle.underlineColor

addAttributes(
[
.foregroundColor: UIColor.white,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: UIColor(red: 51 / 255.0, green: 154 / 255.0, blue: 1.0, alpha: 1.0)
],
range: range
)

就是这样!

result

我在@codeBearer 的回答后面写了下面的代码。

import UIKit


class CustomAttributedTextView: UITextView {


override func layoutSubviews() {
super.layoutSubviews()
}


func clearForReuse() {
setNeedsDisplay()
}
var lineCountUpdate: ((Bool) -> Void)?


override func draw(_ rect: CGRect) {
super.draw(rect)
UIColor.clear.setFill()
UIColor.clear.setFill()
guard let context = UIGraphicsGetCurrentContext() else { return }
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
let path = CGMutablePath()
let size = sizeThatFits(CGSize(width: self.frame.width, height: .greatestFiniteMagnitude))
path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity)


let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil)


let lines: [CTLine] = frame.lines


var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)


for lineIndex in 0..<lines.count {
let line = lines[lineIndex]
let runs: [CTRun] = line.ctruns
var tagCountInOneLine = 0
for run in runs {
var cornerRadius: CGFloat = 3
let attributes: NSDictionary = CTRunGetAttributes(run)
var imgBounds: CGRect = .zero
if let value: UIColor =  attributes.value(forKey: NSAttributedString.Key.customBackgroundColor.rawValue) as? UIColor {
var ascent: CGFloat = 0
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil) + 4)
imgBounds.size.height = ascent + 6


let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset + 3
imgBounds.origin.y = origins[lineIndex].y - 13


if lineIndex != 0 {
imgBounds.origin.y = imgBounds.origin.y - 1
}


let path = UIBezierPath(roundedRect: imgBounds, cornerRadius: cornerRadius)
value.setFill()
path.fill()
value.setStroke()
}
}
}
}
}


extension CTFrame {


var lines: [CTLine] {
let linesAO: [AnyObject] = CTFrameGetLines(self) as [AnyObject]
guard let lines = linesAO as? [CTLine] else {
return []
}


return lines
}
}


extension CTLine {
var ctruns: [CTRun] {
let linesAO: [AnyObject] = CTLineGetGlyphRuns(self) as [AnyObject]
guard let lines = linesAO as? [CTRun] else {
return []
}


return lines
}
}