Signal-iOS/SignalUI/Views/ConversationView/CVText.swift
Pete Walters f1c4eb6b7b
Fix unreadable search result highlighting
CVTextLabel incorrectly attempts to set default .font
and .foregroundColor on the label text, removing any
existing font & color attributes. This results
in unreadable text when displaying search results.

Instead, add helper method to set default attributes on
attributed strings, but only on ranges that don't have the
attribute already defined.
2023-02-02 10:17:38 -06:00

348 lines
11 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public enum CVTextValue: Equatable, Hashable {
public typealias CacheKey = String
case text(text: String)
case attributedText(attributedText: NSAttributedString)
func apply(label: UILabel) {
switch self {
case .text(let text):
label.text = text
case .attributedText(let attributedText):
label.attributedText = attributedText
}
}
func apply(textView: UITextView) {
switch self {
case .text(let text):
textView.text = text
case .attributedText(let attributedText):
textView.attributedText = attributedText
}
}
public var stringValue: String {
switch self {
case .text(let text):
return text
case .attributedText(let attributedText):
return attributedText.string
}
}
public var attributedString: NSAttributedString {
switch self {
case .text(let text):
return NSAttributedString(string: text)
case .attributedText(let attributedText):
return attributedText
}
}
var debugDescription: String {
switch self {
case .text(let text):
return "text: \(text)"
case .attributedText(let attributedText):
return "attributedText: \(attributedText.string)"
}
}
fileprivate var cacheKey: CacheKey {
switch self {
case .text(let text):
return "t\(text)"
case .attributedText(let attributedText):
return "a\(attributedText.string.description)"
}
}
}
// MARK: - UILabel
public struct CVLabelConfig {
public typealias CacheKey = String
fileprivate let text: CVTextValue
public let font: UIFont
public let textColor: UIColor
public let numberOfLines: Int
public let lineBreakMode: NSLineBreakMode
public let textAlignment: NSTextAlignment?
public init(text: String,
font: UIFont,
textColor: UIColor,
numberOfLines: Int = 1,
lineBreakMode: NSLineBreakMode = .byWordWrapping,
textAlignment: NSTextAlignment? = nil) {
self.text = .text(text: text)
self.font = font
self.textColor = textColor
self.numberOfLines = numberOfLines
self.lineBreakMode = lineBreakMode
self.textAlignment = textAlignment
}
public init(attributedText: NSAttributedString,
font: UIFont,
textColor: UIColor,
numberOfLines: Int = 1,
lineBreakMode: NSLineBreakMode = .byWordWrapping,
textAlignment: NSTextAlignment? = nil) {
self.text = .attributedText(attributedText: attributedText)
self.font = font
self.textColor = textColor
self.numberOfLines = numberOfLines
self.lineBreakMode = lineBreakMode
self.textAlignment = textAlignment
}
func applyForMeasurement(label: UILabel) {
label.font = self.font
label.numberOfLines = self.numberOfLines
label.lineBreakMode = self.lineBreakMode
// Skip textColor, textAlignment.
// Apply text last, to protect attributed text attributes.
// There are also perf benefits.
self.text.apply(label: label)
}
public func applyForRendering(label: UILabel) {
label.font = self.font
label.numberOfLines = self.numberOfLines
label.lineBreakMode = self.lineBreakMode
label.textColor = self.textColor
if let textAlignment = textAlignment {
label.textAlignment = textAlignment
} else {
label.textAlignment = .natural
}
// Apply text last, to protect attributed text attributes.
// There are also perf benefits.
self.text.apply(label: label)
}
public func measure(maxWidth: CGFloat) -> CGSize {
let size = CVText.measureLabel(config: self, maxWidth: maxWidth)
if size.width > maxWidth {
owsFailDebug("size.width: \(size.width) > maxWidth: \(maxWidth)")
}
return size
}
public var stringValue: String {
text.stringValue
}
public var attributedString: NSAttributedString {
text.attributedString
}
public var debugDescription: String {
"CVLabelConfig: \(text.debugDescription)"
}
public var cacheKey: CacheKey {
// textColor doesn't affect measurement.
"\(text.cacheKey),\(font.fontName),\(font.pointSize),\(numberOfLines),\(lineBreakMode.rawValue),\(textAlignment?.rawValue ?? 0)"
}
}
// MARK: - UITextView
public struct CVTextViewConfig {
public typealias CacheKey = String
public let text: CVTextValue
public let font: UIFont
public let textColor: UIColor
public let textAlignment: NSTextAlignment?
public let linkTextAttributes: [NSAttributedString.Key: Any]?
public let extraCacheKeyFactors: [String]?
public init(text: String,
font: UIFont,
textColor: UIColor,
textAlignment: NSTextAlignment? = nil,
linkTextAttributes: [NSAttributedString.Key: Any]? = nil) {
self.text = .text(text: text)
self.font = font
self.textColor = textColor
self.textAlignment = textAlignment
self.linkTextAttributes = linkTextAttributes
self.extraCacheKeyFactors = nil
}
public init(attributedText: NSAttributedString,
font: UIFont,
textColor: UIColor,
textAlignment: NSTextAlignment? = nil,
linkTextAttributes: [NSAttributedString.Key: Any]? = nil,
extraCacheKeyFactors: [String]? = nil) {
self.text = .attributedText(attributedText: attributedText)
self.font = font
self.textColor = textColor
self.textAlignment = textAlignment
self.linkTextAttributes = linkTextAttributes
self.extraCacheKeyFactors = extraCacheKeyFactors
}
public var stringValue: String {
text.stringValue
}
public var debugDescription: String {
"CVTextViewConfig: \(text.debugDescription)"
}
public var cacheKey: CacheKey {
// textColor and linkTextAttributes (for the attributes we set)
// don't affect measurement.
var cacheKey = "\(text.cacheKey),\(font.fontName),\(font.pointSize),\(textAlignment?.rawValue ?? 0)"
if let extraCacheKeyFactors = self.extraCacheKeyFactors {
cacheKey += extraCacheKeyFactors.joined(separator: ",")
}
return cacheKey
}
}
// MARK: -
public class CVText {
public typealias CacheKey = String
private static var cacheMeasurements = true
private static let cacheSize: Int = 500
// MARK: - UILabel
private static func buildCacheKey(configKey: String, maxWidth: CGFloat) -> CacheKey {
"\(configKey),\(maxWidth)"
}
private static let labelCache = LRUCache<CacheKey, CGSize>(maxSize: cacheSize)
public static func measureLabel(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize {
let cacheKey = buildCacheKey(configKey: config.cacheKey, maxWidth: maxWidth)
if cacheMeasurements,
let result = labelCache.get(key: cacheKey) {
return result
}
let result = measureLabelUsingLayoutManager(config: config, maxWidth: maxWidth)
owsAssertDebug(result.isNonEmpty || config.text.stringValue.isEmpty)
if cacheMeasurements {
labelCache.set(key: cacheKey, value: result.ceil)
}
return result.ceil
}
#if TESTABLE_BUILD
public static func measureLabelUsingView(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize {
guard !config.text.stringValue.isEmpty else {
return .zero
}
let label = UILabel()
config.applyForMeasurement(label: label)
var size = label.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)).ceil
// Truncate to available space if necessary.
size.width = min(size.width, maxWidth)
return size
}
#endif
static func measureLabelUsingLayoutManager(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize {
guard !config.text.stringValue.isEmpty else {
return .zero
}
let textContainer = NSTextContainer(size: CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
textContainer.maximumNumberOfLines = config.numberOfLines
textContainer.lineBreakMode = config.lineBreakMode
textContainer.lineFragmentPadding = 0
return textContainer.size(for: config.text, font: config.font)
}
// MARK: - CVTextLabel
private static let bodyTextLabelCache = LRUCache<CacheKey, CVTextLabel.Measurement>(maxSize: cacheSize)
public static func measureBodyTextLabel(config: CVTextLabel.Config, maxWidth: CGFloat) -> CVTextLabel.Measurement {
let cacheKey = buildCacheKey(configKey: config.cacheKey, maxWidth: maxWidth)
if cacheMeasurements,
let result = bodyTextLabelCache.get(key: cacheKey) {
return result
}
let measurement = CVTextLabel.measureSize(config: config, maxWidth: maxWidth)
owsAssertDebug(measurement.size.width > 0)
owsAssertDebug(measurement.size.height > 0)
owsAssertDebug(measurement.size == measurement.size.ceil)
if cacheMeasurements {
bodyTextLabelCache.set(key: cacheKey, value: measurement)
}
return measurement
}
}
// MARK: -
private extension NSTextContainer {
func size(for textValue: CVTextValue, font: UIFont) -> CGSize {
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(self)
let attributedString: NSAttributedString
switch textValue {
case .attributedText(let text):
let mutableText = NSMutableAttributedString(attributedString: text)
// The original attributed string may not have an overall font assigned.
// Without it, measurement will not be correct. We assign the default font
// to any ranges that don't currently have a font assigned.
mutableText.addDefaultAttributeToEntireString(.font, value: font)
attributedString = mutableText
case .text(let text):
attributedString = NSAttributedString(string: text, attributes: [.font: font])
}
// The string must be assigned to the NSTextStorage *after* it has
// an associated layout manager. Otherwise, the `NSOriginalFont`
// attribute will not be defined correctly resulting in incorrect
// measurement of character sets that font doesn't support natively
// (CJK, Arabic, Emoji, etc.)
let textStorage = NSTextStorage()
textStorage.addLayoutManager(layoutManager)
textStorage.setAttributedString(attributedString)
// The NSTextStorage object owns all the other layout components,
// so there are only weak references to it. In optimized builds,
// this can result in it being freed before we perform measurement.
// We can work around this by explicitly extending the lifetime of
// textStorage until measurement is completed.
let size = withExtendedLifetime(textStorage) { layoutManager.usedRect(for: self).size }
return size.ceil
}
}