392 lines
15 KiB
Swift
392 lines
15 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|
|
}
|