Path: blob/main/SignalUI/ConversationView/CVCapsuleLabel.swift
1 views
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
/**
* Given an attributed string and a highlightRange, draws a colored capsule behind the characters in highlightRange.
* The color of the capsule is determined by the textColor with opacity decreased.
* highlightFont allows for the capsule text to be a different font (e.g. bold or not bold) from the rest of the attributed text.
* Since we don't want the highlight range to wrap, but we may want the rest of the range to wrap, this class manually
* truncates text longer than the given width and adds an ellipsis.
*/
public class CVCapsuleLabel: UILabel {
public enum PresentationContext {
case nonMessageBubble
case messageBubbleRegular
case messageBubbleQuoteReplyIncoming
case messageBubbleQuoteReplyOutgoing
case nameNotVerifiedWarning
}
public let highlightRange: NSRange
public let highlightFont: UIFont
public let axLabelPrefix: String?
public let presentationContext: PresentationContext
public let onTap: (() -> Void)?
// *CapsuleInset is how far beyond the text the capsule expands.
// *Offset is how shifted BOTH capsule & text are from the edge of the view.
private static let horizontalCapsuleInset: CGFloat = 8
private static let verticalCapsuleInset: CGFloat = 1
private static let verticalOffset: CGFloat = 3
private static let horizontalOffset: CGFloat = 8
public init(
attributedText: NSAttributedString,
textColor: UIColor,
font: UIFont?,
highlightRange: NSRange,
highlightFont: UIFont,
axLabelPrefix: String?,
presentationContext: PresentationContext,
lineBreakMode: NSLineBreakMode = .byTruncatingTail,
numberOfLines: Int = 0,
signalSymbolRange: NSRange?,
onTap: (() -> Void)?,
) {
self.highlightRange = highlightRange
self.highlightFont = highlightFont
self.axLabelPrefix = axLabelPrefix
self.presentationContext = presentationContext
self.onTap = onTap
super.init(frame: .zero)
self.font = font
self.textColor = textColor
self.lineBreakMode = lineBreakMode
self.numberOfLines = numberOfLines
isUserInteractionEnabled = true
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapMemberLabel)))
let attributedString = NSMutableAttributedString(attributedString: attributedText)
applyFontToAttributedString(attributedString, signalSymbolRange: signalSymbolRange)
attributedString.addAttribute(.foregroundColor, value: textColor, range: attributedText.entireRange)
self.attributedText = attributedString
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var capsuleColor: UIColor {
switch presentationContext {
case .messageBubbleQuoteReplyOutgoing:
return UIColor.white.withAlphaComponent(0.36)
case .messageBubbleQuoteReplyIncoming:
if Theme.isDarkThemeEnabled {
return UIColor.white.withAlphaComponent(0.16)
}
return UIColor.black.withAlphaComponent(0.1)
case .messageBubbleRegular, .nonMessageBubble:
if Theme.isDarkThemeEnabled {
return textColor.withAlphaComponent(0.32)
}
return textColor.withAlphaComponent(0.14)
case .nameNotVerifiedWarning:
if Theme.isDarkThemeEnabled {
return textColor.withAlphaComponent(0.2)
}
return textColor.withAlphaComponent(0.12)
}
}
private func applyFontToAttributedString(_ attributedString: NSMutableAttributedString, signalSymbolRange: NSRange?) {
guard let signalSymbolRange else {
attributedString.addAttribute(.font, value: self.font!, range: attributedString.entireRange)
// The highlighted text may have different font than the sender name
attributedString.addAttribute(.font, value: highlightFont, range: highlightRange)
return
}
// Apply font avoiding the signal symbol
attributedString.applyAttributesToRangeAvoidingSubrange(
attributes: [.font: self.font!],
range: attributedString.entireRange,
subrangeToAvoid: signalSymbolRange,
)
attributedString.applyAttributesToRangeAvoidingSubrange(
attributes: [.font: highlightFont],
range: highlightRange,
subrangeToAvoid: signalSymbolRange,
)
}
@objc
func didTapMemberLabel() {
onTap?()
}
/// Takes an attributed string, its font, and color, and returns a new attributed string,
/// truncated to fit within the max width, with an ellipsis appended to the end.
private static func truncateStringUntilFits(
string: NSAttributedString,
maxWidth: CGFloat,
font: UIFont,
textColor: UIColor,
) -> NSAttributedString {
guard string.size().width > maxWidth else {
return string
}
let ellipsesUnicode = NSMutableAttributedString(string: "\u{2026}")
ellipsesUnicode.addAttribute(.font, value: font, range: ellipsesUnicode.entireRange)
ellipsesUnicode.addAttribute(
.foregroundColor,
value: textColor,
range: ellipsesUnicode.entireRange,
)
let ellipsesWidth = ellipsesUnicode.size().width
let newMaxWidth = maxWidth - ellipsesWidth
let truncatedString: NSMutableAttributedString = NSMutableAttributedString(attributedString: string)
// Since NSAttributedStrings count UTF-16 code points, we should
// use rangeOfComposedCharacterSequences to delete the total range
// for a single "visible" char to avoid breaking up emojis.
while truncatedString.size().width > newMaxWidth {
let totalCharRange = (truncatedString.string as NSString).rangeOfComposedCharacterSequences(
for:
NSRange(
location: truncatedString.length - 1,
length: 1,
),
)
truncatedString.deleteCharacters(in: totalCharRange)
}
truncatedString.append(ellipsesUnicode)
return truncatedString
}
/// Takes an attributed string & its properties, and formats it correctly to prevent wrapping of the highlighted range.
/// Any part of the attributed string outside of the highlight range can wrap as usual, but the highlighted range should
/// stay on one line and truncate using truncateStringUntilFits().
/// For example, "Jane (Engineer)" with () indicating the highlighted range, should either stay on one line width permitting, or become:
///
/// "Jane
/// (Engineer)"
///
/// If the member label is too long for the given space on the next line it should become:
///
/// "Jane
/// (Eng...)"
///
/// A long profile name might look like this:
/// "Jane Long Profile
/// Name (Engineer)"
///
/// or, if less wide,
/// "Jane
/// Long
/// Profile
/// Name
/// (Eng...)"
///
/// A truncated member label should always be on its own line.
private static func formatCapsuleString(
attributedString: NSAttributedString,
highlightRange: NSRange,
highlightFont: UIFont,
textColor: UIColor,
maxWidth: CGFloat,
) -> (NSAttributedString, NSRange)? {
let totalStringWidth = attributedString.size().width
let highlightedString = attributedString.attributedSubstring(from: highlightRange)
let highlightedStringWidth = highlightedString.size().width
let nonHighlightRange = NSRange(location: 0, length: highlightRange.location)
let nonHighlightString = attributedString.attributedSubstring(from: nonHighlightRange)
let breakString = NSAttributedString(string: "\n")
// If highlight text width or total string width is greater than line width,
// move highlight to the next line to avoid wrapping, and truncate it if needed.
if highlightedStringWidth > maxWidth || totalStringWidth > maxWidth {
let truncatedHighlightString = Self.truncateStringUntilFits(
string: highlightedString,
maxWidth: maxWidth,
font: highlightFont,
textColor: textColor,
)
if !nonHighlightString.isEmpty {
let newTotalString = nonHighlightString + breakString + truncatedHighlightString
let newHighlightRange = (newTotalString.string as NSString).range(of: truncatedHighlightString.string)
return (newTotalString, newHighlightRange)
}
return (truncatedHighlightString, truncatedHighlightString.entireRange)
}
// Everything fits on one line! Return as-is.
return (attributedString, highlightRange)
}
private func textContainerForFormattedString(
layoutManager: NSLayoutManager,
textStorage: NSTextStorage,
size: CGSize,
) -> NSTextContainer {
let textContainer = NSTextContainer(size: size)
textContainer.lineFragmentPadding = 0
textContainer.maximumNumberOfLines = self.numberOfLines
textContainer.lineBreakMode = self.lineBreakMode
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
return textContainer
}
private func calculateHorizontalOffset() -> CGFloat {
// We only need to offset the capsule & text horizontally if the edge of the view
// might cut it off because its naturally aligned.
let needsHorizontalOffset = textAlignment == .natural
if needsHorizontalOffset {
return CurrentAppContext().isRTL ? -Self.horizontalOffset : Self.horizontalOffset
}
return 0
}
override public func drawText(in rect: CGRect) {
guard let attributedText, let textColor else {
return super.drawText(in: rect)
}
owsAssertDebug(numberOfLines == 0 || numberOfLines == 1, "CVCapsule wrapping behavior undefined")
let hOffset = calculateHorizontalOffset()
let maxWidth = rect.width - (2 * Self.horizontalCapsuleInset + abs(hOffset))
let formattedStringData = CVCapsuleLabel.formatCapsuleString(
attributedString: attributedText,
highlightRange: highlightRange,
highlightFont: highlightFont,
textColor: textColor,
maxWidth: maxWidth,
)
guard let (formattedAttributedString, newHighlightRange) = formattedStringData else {
return super.drawText(in: rect)
}
let layoutManager = NSLayoutManager()
let textStorage = NSTextStorage(attributedString: formattedAttributedString)
let textContainer = textContainerForFormattedString(
layoutManager: layoutManager,
textStorage: textStorage,
size: rect.size,
)
let highlightGlyphRange = layoutManager.glyphRange(forCharacterRange: newHighlightRange, actualCharacterRange: nil)
let highlightColor = capsuleColor
layoutManager.enumerateEnclosingRects(forGlyphRange: highlightGlyphRange, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { rect, _ in
let vCapsuleOffset = -Self.verticalCapsuleInset + Self.verticalOffset
let roundedRect = rect.offsetBy(
dx: hOffset,
dy: vCapsuleOffset,
).insetBy(
dx: -Self.horizontalCapsuleInset,
dy: -Self.verticalCapsuleInset,
)
let path = UIBezierPath(roundedRect: roundedRect, cornerRadius: roundedRect.height / 2)
highlightColor.setFill()
path.fill()
layoutManager.drawGlyphs(forGlyphRange: highlightGlyphRange, at: CGPoint(x: hOffset, y: Self.verticalOffset))
}
let newNonHighlightRange = NSRange(location: 0, length: newHighlightRange.location)
let nonHighlightGlyphRange = layoutManager.glyphRange(forCharacterRange: newNonHighlightRange, actualCharacterRange: nil)
layoutManager.drawGlyphs(forGlyphRange: nonHighlightGlyphRange, at: CGPoint(x: 0, y: Self.verticalOffset))
}
override public var intrinsicContentSize: CGSize {
return labelSize(maxWidth: .greatestFiniteMagnitude)
}
public static func measureLabel(
attributedText: NSAttributedString,
font: UIFont,
highlightRange: NSRange,
highlightFont: UIFont,
presentationContext: CVCapsuleLabel.PresentationContext,
maxWidth: CGFloat,
signalSymbolRange: NSRange?,
) -> CGSize {
let label = CVCapsuleLabel(
attributedText: attributedText,
textColor: .black,
font: font,
highlightRange: highlightRange,
highlightFont: highlightFont,
axLabelPrefix: nil,
presentationContext: presentationContext,
signalSymbolRange: signalSymbolRange,
onTap: nil,
)
return label.labelSize(maxWidth: maxWidth)
}
public func labelSize(maxWidth: CGFloat) -> CGSize {
guard let attributedText, !attributedText.isEmpty else { return .zero }
let hOffset = calculateHorizontalOffset()
let maxWidthMinusInsets = maxWidth - (abs(hOffset) + Self.horizontalCapsuleInset * 2)
owsAssertDebug(numberOfLines == 0 || numberOfLines == 1, "CVCapsule wrapping behavior undefined")
let formattedStringData = CVCapsuleLabel.formatCapsuleString(
attributedString: attributedText,
highlightRange: highlightRange,
highlightFont: highlightFont,
textColor: textColor,
maxWidth: maxWidthMinusInsets,
)
guard let (formattedAttributedString, _) = formattedStringData else {
return .zero
}
let layoutManager = NSLayoutManager()
let size = CGSize(width: maxWidthMinusInsets, height: .greatestFiniteMagnitude)
let textStorage = NSTextStorage(attributedString: formattedAttributedString)
let textContainer = textContainerForFormattedString(
layoutManager: layoutManager,
textStorage: textStorage,
size: size,
)
let measureSize = layoutManager.usedRect(for: textContainer).size.ceil
let finalHeight = measureSize.height + Self.verticalOffset + Self.verticalCapsuleInset * 2
let finalWidth = measureSize.width + Self.horizontalCapsuleInset * 2 + abs(hOffset)
return CGSize(width: finalWidth, height: finalHeight)
}
override public var accessibilityLabel: String? {
get {
if let axLabelPrefix, let text = self.text {
return axLabelPrefix + text
}
return super.accessibilityLabel
}
set { super.accessibilityLabel = newValue }
}
override public var accessibilityTraits: UIAccessibilityTraits {
get {
var axTraits = super.accessibilityTraits
if onTap != nil {
axTraits.insert(.button)
}
return axTraits
}
set {
super.accessibilityTraits = newValue
}
}
}