// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit public extension NSAttributedString.Key { static let currentUserMentionBackgroundColor: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundColor") static let currentUserMentionBackgroundCornerRadius: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundCornerRadius") static let currentUserMentionBackgroundPadding: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundPadding") } public class HighlightMentionBackgroundView: UIView { weak var targetLabel: UILabel? var maxPadding: CGFloat = 0 init(targetLabel: UILabel) { self.targetLabel = targetLabel super.init(frame: .zero) self.isOpaque = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Functions public func calculateMaxPadding(for attributedText: NSAttributedString) -> CGFloat { var allMentionRadii: [CGFloat?] = [] let path: CGMutablePath = CGMutablePath() path.addRect(CGRect( x: 0, y: 0, width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude )) let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString) let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil) let lines: [CTLine] = frame.lines lines.forEach { line in let runs: [CTRun] = line.ctruns runs.forEach { run in let attributes: NSDictionary = CTRunGetAttributes(run) allMentionRadii.append( attributes .value(forKey: NSAttributedString.Key.currentUserMentionBackgroundPadding.rawValue) as? CGFloat ) } } let maxRadii: CGFloat? = allMentionRadii .compactMap { $0 } .max() return (maxRadii ?? 0) } // MARK: - Drawing override public func draw(_ rect: CGRect) { guard let targetLabel: UILabel = self.targetLabel, let attributedText: NSAttributedString = targetLabel.attributedText, let context = UIGraphicsGetCurrentContext() else { return } // Need to invery the Y axis because iOS likes to render from the bottom left instead of the top left context.textMatrix = .identity context.translateBy(x: 0, y: bounds.size.height) context.scaleBy(x: 1.0, y: -1.0) // Note: Calculations MUST happen based on the 'targetLabel' size as this class has extra padding // which can result in calculations being off let path = CGMutablePath() let size = targetLabel.sizeThatFits(CGSize(width: targetLabel.bounds.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 ascent: CGFloat = 0 var descent: CGFloat = 0 var leading: CGFloat = 0 _ = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)) for run in runs { let attributes: NSDictionary = CTRunGetAttributes(run) guard let mentionBackgroundColor: UIColor = attributes.value(forKey: NSAttributedString.Key.currentUserMentionBackgroundColor.rawValue) as? UIColor else { continue } let maybeCornerRadius: CGFloat? = (attributes .value(forKey: NSAttributedString.Key.currentUserMentionBackgroundCornerRadius.rawValue) as? CGFloat) let maybePadding: CGFloat? = (attributes .value(forKey: NSAttributedString.Key.currentUserMentionBackgroundPadding.rawValue) as? CGFloat) let padding: CGFloat = (maybePadding ?? 0) let range = CTRunGetStringRange(run) var runBounds: CGRect = .zero var runAscent: CGFloat = 0 var runDescent: CGFloat = 0 runBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &runAscent, &runDescent, nil) + (padding * 2)) runBounds.size.height = (runAscent + runDescent + (padding * 2)) let xOffset: CGFloat = { switch CTRunGetStatus(run) { case .rightToLeft: return CTLineGetOffsetForStringIndex(line, range.location + range.length, nil) default: return CTLineGetOffsetForStringIndex(line, range.location, nil) } }() // HACK: This `extraYOffset` value is a hack to resolve a weird issue where the // positioning seems to be slightly off every additional line of text we add (it // doesn't seem to be related to line spacing or anything, more related to the // bold mention text being positioned slightly differently from the non-bold text) let extraYOffset: CGFloat = (CGFloat(lineIndex) * (runDescent / 12)) // Note: Changes to `origin.y` need to be inverted since the context has been flipped runBounds.origin.x = origins[lineIndex].x + rect.origin.x + self.maxPadding + xOffset - padding runBounds.origin.y = ( origins[lineIndex].y + rect.origin.y + self.maxPadding - padding - runDescent - extraYOffset ) let path = UIBezierPath(roundedRect: runBounds, cornerRadius: (maybeCornerRadius ?? 0)) mentionBackgroundColor.setFill() path.fill() } } } } extension CTFrame { var lines: [CTLine] { return ((CTFrameGetLines(self) as [AnyObject] as? [CTLine]) ?? []) } } extension CTLine { var ctruns: [CTRun] { return ((CTLineGetGlyphRuns(self) as [AnyObject] as? [CTRun]) ?? []) } }