676 lines
23 KiB
Swift
676 lines
23 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import LibSignalClient
|
|
public import SignalServiceKit
|
|
|
|
public class CVTextLabel: NSObject {
|
|
|
|
// MARK: -
|
|
|
|
public struct MentionItem: Equatable {
|
|
public let mentionAci: Aci
|
|
public let range: NSRange
|
|
|
|
public init(mentionAci: Aci, range: NSRange) {
|
|
self.mentionAci = mentionAci
|
|
self.range = range
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public struct ReferencedUserItem: Equatable {
|
|
public let address: SignalServiceAddress
|
|
public let range: NSRange
|
|
|
|
public init(address: SignalServiceAddress, range: NSRange) {
|
|
self.address = address
|
|
self.range = range
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public struct UnrevealedSpoilerItem: Equatable {
|
|
public let spoilerId: Int
|
|
public let interactionUniqueId: String
|
|
public let interactionIdentifier: InteractionSnapshotIdentifier
|
|
public let range: NSRange
|
|
|
|
public init(
|
|
spoilerId: Int,
|
|
interactionUniqueId: String,
|
|
interactionIdentifier: InteractionSnapshotIdentifier,
|
|
range: NSRange
|
|
) {
|
|
self.spoilerId = spoilerId
|
|
self.interactionUniqueId = interactionUniqueId
|
|
self.interactionIdentifier = interactionIdentifier
|
|
self.range = range
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public enum Item: Equatable, CustomStringConvertible {
|
|
case dataItem(dataItem: TextCheckingDataItem)
|
|
case mention(mentionItem: MentionItem)
|
|
case referencedUser(referencedUserItem: ReferencedUserItem)
|
|
case unrevealedSpoiler(UnrevealedSpoilerItem)
|
|
|
|
public var range: NSRange {
|
|
switch self {
|
|
case .dataItem(let dataItem):
|
|
return dataItem.range
|
|
case .mention(let mentionItem):
|
|
return mentionItem.range
|
|
case .referencedUser(let referencedUserItem):
|
|
return referencedUserItem.range
|
|
case .unrevealedSpoiler(let item):
|
|
return item.range
|
|
}
|
|
}
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .dataItem:
|
|
return ".dataItem"
|
|
case .mention:
|
|
return ".mention"
|
|
case .referencedUser:
|
|
return ".referencedUser"
|
|
case .unrevealedSpoiler:
|
|
return ".unrevealedSpoiler"
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum LinkifyStyle {
|
|
case linkAttribute
|
|
case underlined(bodyTextColor: UIColor)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public struct Config {
|
|
public let text: CVTextValue
|
|
public let displayConfig: HydratedMessageBody.DisplayConfiguration
|
|
public let font: UIFont
|
|
public let textColor: UIColor
|
|
public let selectionStyling: [NSAttributedString.Key: Any]
|
|
public let textAlignment: NSTextAlignment
|
|
public let lineBreakMode: NSLineBreakMode
|
|
public let numberOfLines: Int
|
|
public let cacheKey: String
|
|
public let items: [Item]
|
|
public let linkifyStyle: CVTextLabel.LinkifyStyle
|
|
|
|
public init(
|
|
text: CVTextValue,
|
|
displayConfig: HydratedMessageBody.DisplayConfiguration,
|
|
font: UIFont,
|
|
textColor: UIColor,
|
|
selectionStyling: [NSAttributedString.Key: Any],
|
|
textAlignment: NSTextAlignment,
|
|
lineBreakMode: NSLineBreakMode,
|
|
numberOfLines: Int = 0,
|
|
cacheKey: String? = nil,
|
|
items: [Item],
|
|
linkifyStyle: CVTextLabel.LinkifyStyle
|
|
) {
|
|
self.text = text
|
|
self.displayConfig = displayConfig
|
|
self.font = font
|
|
self.textColor = textColor
|
|
self.selectionStyling = selectionStyling
|
|
self.textAlignment = textAlignment
|
|
self.lineBreakMode = lineBreakMode
|
|
self.numberOfLines = numberOfLines
|
|
|
|
if let cacheKey = cacheKey {
|
|
self.cacheKey = cacheKey
|
|
} else {
|
|
self.cacheKey = "\(text.cacheKey),\(displayConfig.sizingCacheKey),\(font.fontName),\(font.pointSize),\(numberOfLines),\(lineBreakMode.rawValue),\(textAlignment.rawValue)"
|
|
}
|
|
|
|
self.items = items
|
|
self.linkifyStyle = linkifyStyle
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private let label = Label()
|
|
|
|
public var view: UIView { label }
|
|
|
|
public override init() {
|
|
label.backgroundColor = .clear
|
|
label.isOpaque = false
|
|
|
|
super.init()
|
|
}
|
|
|
|
public func configureForRendering(config: Config, spoilerAnimationManager: SpoilerAnimationManager) {
|
|
AssertIsOnMainThread()
|
|
label.config = config
|
|
label.spoilerAnimationManager = spoilerAnimationManager
|
|
spoilerAnimationManager.prepareViewForRendering(view)
|
|
}
|
|
|
|
public func setIsCellVisible(_ isCellVisible: Bool) {
|
|
label.setIsCellVisible(isCellVisible)
|
|
}
|
|
|
|
public func reset() {
|
|
label.config = nil
|
|
label.reset()
|
|
}
|
|
|
|
public class Measurement: CVMeasurementObject {
|
|
public let size: CGSize
|
|
public let lastLineRect: CGRect?
|
|
|
|
init(size: CGSize, lastLineRect: CGRect?) {
|
|
self.size = size
|
|
self.lastLineRect = lastLineRect
|
|
}
|
|
|
|
static let empty = { Measurement(size: .zero, lastLineRect: nil) }()
|
|
|
|
// MARK: - Equatable
|
|
|
|
public static func == (lhs: Measurement, rhs: Measurement) -> Bool {
|
|
lhs.size == rhs.size && lhs.lastLineRect == rhs.lastLineRect
|
|
}
|
|
}
|
|
|
|
public static func measureSize(config: Config, maxWidth: CGFloat) -> Measurement {
|
|
guard config.text.isEmpty.negated else {
|
|
return .empty
|
|
}
|
|
let attributedString = Label.formatAttributedString(config: config)
|
|
|
|
let layoutManager = NSLayoutManager()
|
|
let textContainer = NSTextContainer(size: CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
|
|
|
|
layoutManager.addTextContainer(textContainer)
|
|
textContainer.lineFragmentPadding = 0
|
|
textContainer.lineBreakMode = config.lineBreakMode
|
|
textContainer.maximumNumberOfLines = config.numberOfLines
|
|
|
|
// 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.
|
|
return withExtendedLifetime(textStorage) {
|
|
let glyphRange = layoutManager.glyphRange(for: textContainer)
|
|
var lastLineRect: CGRect?
|
|
if glyphRange.location != NSNotFound,
|
|
glyphRange.length > 0 {
|
|
let lastGlyphIndex = glyphRange.length - 1
|
|
lastLineRect = layoutManager.lineFragmentUsedRect(forGlyphAt: lastGlyphIndex,
|
|
effectiveRange: nil,
|
|
withoutAdditionalLayout: true)
|
|
}
|
|
|
|
let size = layoutManager.usedRect(for: textContainer).size.ceil
|
|
return Measurement(size: size, lastLineRect: lastLineRect)
|
|
}
|
|
}
|
|
|
|
// MARK: - Gestures
|
|
|
|
public func itemForGesture(sender: UIGestureRecognizer) -> Item? {
|
|
label.itemForGesture(sender: sender)
|
|
}
|
|
|
|
public func animate(selectedItem: Item) {
|
|
label.animate(selectedItem: selectedItem)
|
|
}
|
|
|
|
// MARK: - Linkification
|
|
|
|
public static func linkifyData(
|
|
attributedText: NSMutableAttributedString,
|
|
linkifyStyle: LinkifyStyle,
|
|
items: [CVTextLabel.Item]
|
|
) {
|
|
|
|
// Sort so that we can detect overlap.
|
|
let items = items.sorted {
|
|
$0.range.location < $1.range.location
|
|
}
|
|
|
|
for item in items {
|
|
let range = item.range
|
|
|
|
switch item {
|
|
case .mention, .referencedUser, .unrevealedSpoiler:
|
|
// Do nothing; these are already styled.
|
|
continue
|
|
case .dataItem(let dataItem):
|
|
guard let link = dataItem.url.absoluteString.nilIfEmpty else {
|
|
owsFailDebug("Could not build data link.")
|
|
continue
|
|
}
|
|
|
|
switch linkifyStyle {
|
|
case .linkAttribute:
|
|
attributedText.addAttribute(.link, value: link, range: range)
|
|
case .underlined(let bodyTextColor):
|
|
attributedText.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
|
|
attributedText.addAttribute(.underlineColor, value: bodyTextColor, range: range)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate class Label: UIView {
|
|
|
|
fileprivate var config: Config? {
|
|
didSet {
|
|
reset()
|
|
apply(config: config)
|
|
}
|
|
}
|
|
|
|
fileprivate var spoilerAnimationManager: SpoilerAnimationManager? {
|
|
didSet {
|
|
if spoilerAnimationManager == nil, let oldValue, self.isAnimatingSpoilers {
|
|
self.isAnimatingSpoilers = false
|
|
oldValue.removeViewAnimator(self)
|
|
} else {
|
|
updateSpoilerAnimationState()
|
|
}
|
|
}
|
|
}
|
|
|
|
private lazy var textStorage = NSTextStorage()
|
|
private lazy var layoutManager = NSLayoutManager()
|
|
private lazy var textContainer = NSTextContainer()
|
|
|
|
private var animationTimer: Timer?
|
|
|
|
// MARK: -
|
|
|
|
override public init(frame: CGRect) {
|
|
AssertIsOnMainThread()
|
|
|
|
super.init(frame: frame)
|
|
|
|
textStorage.addLayoutManager(layoutManager)
|
|
layoutManager.addTextContainer(textContainer)
|
|
|
|
isUserInteractionEnabled = true
|
|
addInteraction(UIDragInteraction(delegate: self))
|
|
contentMode = .redraw
|
|
}
|
|
|
|
@available(*, unavailable, message: "Unimplemented")
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
super.init(coder: aDecoder)
|
|
}
|
|
|
|
override var frame: CGRect {
|
|
didSet {
|
|
// Ensure the text container size is kept in sync;
|
|
// this is used to compute spoiler positions.
|
|
textContainer.size = bounds.size
|
|
|
|
if oldValue != frame, isAnimatingSpoilers, let spoilerAnimationManager {
|
|
spoilerAnimationManager.didUpdateAnimationState(for: self)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var isCellVisible = false
|
|
|
|
fileprivate func setIsCellVisible(_ isCellVisible: Bool) {
|
|
self.isCellVisible = isCellVisible
|
|
updateSpoilerAnimationState()
|
|
}
|
|
|
|
fileprivate func reset() {
|
|
AssertIsOnMainThread()
|
|
|
|
animationTimer?.invalidate()
|
|
animationTimer = nil
|
|
updateSpoilerAnimationState()
|
|
}
|
|
|
|
private func apply(config: Config?) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let config = config else {
|
|
reset()
|
|
return
|
|
}
|
|
updateTextStorage(config: config)
|
|
}
|
|
|
|
open override func draw(_ rect: CGRect) {
|
|
super.draw(rect)
|
|
|
|
textContainer.size = bounds.size
|
|
let glyphRange = layoutManager.glyphRange(for: textContainer)
|
|
layoutManager.drawBackground(forGlyphRange: glyphRange, at: .zero)
|
|
layoutManager.drawGlyphs(forGlyphRange: glyphRange, at: .zero)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate func updateTextStorage(config: Config) {
|
|
AssertIsOnMainThread()
|
|
|
|
textContainer.lineFragmentPadding = 0
|
|
textContainer.lineBreakMode = config.lineBreakMode
|
|
textContainer.maximumNumberOfLines = config.numberOfLines
|
|
textContainer.size = bounds.size
|
|
|
|
guard config.text.isEmpty.negated else {
|
|
reset()
|
|
textStorage.setAttributedString(NSAttributedString())
|
|
setNeedsDisplay()
|
|
return
|
|
}
|
|
|
|
let attributedString = Self.formatAttributedString(config: config)
|
|
textStorage.setAttributedString(attributedString)
|
|
setNeedsDisplay()
|
|
|
|
updateSpoilerAnimationState()
|
|
}
|
|
|
|
fileprivate static func formatAttributedString(config: Config) -> NSMutableAttributedString {
|
|
let attributedString: NSMutableAttributedString
|
|
switch config.text {
|
|
case .text(let text):
|
|
attributedString = NSMutableAttributedString(string: text)
|
|
config.displayConfig.searchRanges?.apply(
|
|
attributedString,
|
|
isDarkThemeEnabled: Theme.isDarkThemeEnabled
|
|
)
|
|
case .attributedText(let attributedText):
|
|
attributedString = NSMutableAttributedString(attributedString: attributedText)
|
|
config.displayConfig.searchRanges?.apply(
|
|
attributedString,
|
|
isDarkThemeEnabled: Theme.isDarkThemeEnabled
|
|
)
|
|
case .messageBody(let messageBody):
|
|
// This will internally apply search ranges, no need to handle separately.
|
|
let attributedText = messageBody.asAttributedStringForDisplay(
|
|
config: config.displayConfig,
|
|
isDarkThemeEnabled: Theme.isDarkThemeEnabled
|
|
)
|
|
attributedString = (attributedText as? NSMutableAttributedString) ?? NSMutableAttributedString(attributedString: attributedText)
|
|
}
|
|
|
|
// 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.
|
|
attributedString.addDefaultAttributeToEntireString(.font, value: config.font)
|
|
|
|
// Set a default text color based on the passed in config
|
|
attributedString.addDefaultAttributeToEntireString(.foregroundColor, value: config.textColor)
|
|
|
|
CVTextLabel.linkifyData(
|
|
attributedText: attributedString,
|
|
linkifyStyle: config.linkifyStyle,
|
|
items: config.items
|
|
)
|
|
|
|
var range = NSRange(location: 0, length: 0)
|
|
var attributes = attributedString.attributes(at: 0, effectiveRange: &range)
|
|
|
|
let paragraphStyle = attributes[.paragraphStyle] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
|
|
paragraphStyle.lineBreakMode = config.lineBreakMode
|
|
paragraphStyle.alignment = config.textAlignment
|
|
attributes[.paragraphStyle] = paragraphStyle
|
|
attributedString.setAttributes(attributes, range: range)
|
|
return attributedString
|
|
}
|
|
|
|
fileprivate func updateAttributesForSelection(selectedItem: Item? = nil) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let config = config else {
|
|
reset()
|
|
return
|
|
}
|
|
guard let selectedItem = selectedItem else {
|
|
apply(config: config)
|
|
return
|
|
}
|
|
|
|
switch selectedItem {
|
|
case .mention, .referencedUser, .dataItem:
|
|
textStorage.addAttributes(config.selectionStyling, range: selectedItem.range)
|
|
case .unrevealedSpoiler:
|
|
// Don't apply anything for spoilers.
|
|
return
|
|
}
|
|
|
|
setNeedsDisplay()
|
|
}
|
|
|
|
fileprivate func item(at location: CGPoint) -> Item? {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let config = self.config else {
|
|
return nil
|
|
}
|
|
guard textStorage.length > 0 else {
|
|
return nil
|
|
}
|
|
|
|
guard let characterIndex = textContainer.characterIndex(
|
|
of: location,
|
|
textStorage: textStorage,
|
|
layoutManager: layoutManager
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
for item in config.items {
|
|
if item.range.contains(characterIndex) {
|
|
return item
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Animation
|
|
|
|
public func animate(selectedItem: Item) {
|
|
AssertIsOnMainThread()
|
|
|
|
updateAttributesForSelection(selectedItem: selectedItem)
|
|
self.animationTimer?.invalidate()
|
|
self.animationTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in
|
|
self?.updateAttributesForSelection()
|
|
}
|
|
}
|
|
|
|
// MARK: Spoiler
|
|
|
|
private var isAnimatingSpoilers = false
|
|
|
|
private func updateSpoilerAnimationState() {
|
|
let wantsToAnimate: Bool
|
|
if isCellVisible, let config {
|
|
switch config.text {
|
|
case .text, .attributedText:
|
|
wantsToAnimate = false
|
|
case .messageBody(let body):
|
|
wantsToAnimate = body.hasSpoilerRangesToAnimate
|
|
}
|
|
} else {
|
|
wantsToAnimate = false
|
|
}
|
|
|
|
guard let spoilerAnimationManager else {
|
|
return
|
|
}
|
|
guard isAnimatingSpoilers != wantsToAnimate else {
|
|
if isAnimatingSpoilers {
|
|
spoilerAnimationManager.didUpdateAnimationState(for: self)
|
|
}
|
|
return
|
|
}
|
|
if wantsToAnimate {
|
|
spoilerAnimationManager.addViewAnimator(self)
|
|
} else {
|
|
spoilerAnimationManager.removeViewAnimator(self)
|
|
}
|
|
self.isAnimatingSpoilers = wantsToAnimate
|
|
}
|
|
|
|
// MARK: - Gestures
|
|
|
|
public func itemForGesture(sender: UIGestureRecognizer) -> Item? {
|
|
AssertIsOnMainThread()
|
|
|
|
let location = sender.location(in: self)
|
|
guard let selectedItem = item(at: location) else {
|
|
return nil
|
|
}
|
|
|
|
return selectedItem
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public override func updateConstraints() {
|
|
super.updateConstraints()
|
|
|
|
deactivateAllConstraints()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension CVTextLabel.Label: SpoilerableViewAnimator {
|
|
|
|
var spoilerableView: UIView? {
|
|
return self
|
|
}
|
|
|
|
func spoilerFrames() -> [SpoilerFrame] {
|
|
guard let config else { return [] }
|
|
switch config.text {
|
|
case .text, .attributedText:
|
|
return []
|
|
case .messageBody(let messageBody):
|
|
return Self.spoilerFrames(
|
|
messageBody: messageBody,
|
|
displayConfig: config.displayConfig,
|
|
textContainer: textContainer,
|
|
textStorage: textStorage,
|
|
layoutManager: layoutManager,
|
|
bounds: self.bounds.size
|
|
)
|
|
}
|
|
}
|
|
|
|
var spoilerFramesCacheKey: Int {
|
|
var hasher = Hasher()
|
|
hasher.combine("CVTextLabel.Label")
|
|
hasher.combine(config?.text)
|
|
config?.displayConfig.hashForSpoilerFrames(into: &hasher)
|
|
// Order matters. 100x10 is not the same hash value as 10x100.
|
|
hasher.combine(textContainer.size.width)
|
|
hasher.combine(textContainer.size.height)
|
|
return hasher.finalize()
|
|
}
|
|
|
|
// Every input here should be represented in the cache key above.
|
|
private static func spoilerFrames(
|
|
messageBody: HydratedMessageBody,
|
|
displayConfig: HydratedMessageBody.DisplayConfiguration,
|
|
textContainer: NSTextContainer,
|
|
textStorage: NSTextStorage,
|
|
layoutManager: NSLayoutManager,
|
|
bounds: CGSize
|
|
) -> [SpoilerFrame] {
|
|
let spoilerRanges = messageBody.spoilerRangesForAnimation(config: displayConfig)
|
|
return textContainer.boundingRects(
|
|
ofCharacterRanges: spoilerRanges,
|
|
rangeMap: \.range,
|
|
textStorage: textStorage,
|
|
layoutManager: layoutManager,
|
|
transform: { rect, spoilerRange in
|
|
return .init(
|
|
frame: rect,
|
|
color: spoilerRange.color,
|
|
style: spoilerRange.isSearchResult ? .highlight : .standard
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension CVTextLabel.Label: UIDragInteractionDelegate {
|
|
public func dragInteraction(_ interaction: UIDragInteraction,
|
|
itemsForBeginning session: UIDragSession) -> [UIDragItem] {
|
|
guard nil != self.config else {
|
|
owsFailDebug("Missing config.")
|
|
return []
|
|
}
|
|
let location = session.location(in: self)
|
|
guard let selectedItem = self.item(at: location) else {
|
|
return []
|
|
}
|
|
|
|
switch selectedItem {
|
|
case .mention:
|
|
// We don't let users drag mentions yet.
|
|
return []
|
|
case .referencedUser:
|
|
// Dragging is not applicable to referenced users
|
|
return []
|
|
case .unrevealedSpoiler:
|
|
// Dragging is not applicable for spoilers.
|
|
return []
|
|
case .dataItem(let dataItem):
|
|
animate(selectedItem: selectedItem)
|
|
|
|
let itemProvider = NSItemProvider(object: dataItem.snippet as NSString)
|
|
let dragItem = UIDragItem(itemProvider: itemProvider)
|
|
|
|
let glyphRange = self.layoutManager.glyphRange(forCharacterRange: selectedItem.range,
|
|
actualCharacterRange: nil)
|
|
var textLineRects = [NSValue]()
|
|
self.layoutManager.enumerateEnclosingRects(forGlyphRange: glyphRange,
|
|
withinSelectedGlyphRange: NSRange(location: NSNotFound,
|
|
length: 0),
|
|
in: self.textContainer) { (rect, _) in
|
|
textLineRects.append(NSValue(cgRect: rect))
|
|
}
|
|
let previewParameters = UIDragPreviewParameters(textLineRects: textLineRects)
|
|
let preview = UIDragPreview(view: self, parameters: previewParameters)
|
|
dragItem.previewProvider = { preview }
|
|
|
|
return [dragItem]
|
|
}
|
|
}
|
|
}
|