Files
2024-07-13 20:57:10 +06:00

582 lines
18 KiB
Swift

// MIT License
//
// Copyright (c) 2017-2019 MessageKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
// MARK: - MessageLabel
open class MessageLabel: UILabel {
// MARK: Lifecycle
// MARK: - Initializers
public override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}
// MARK: Open
// MARK: - Public Properties
open weak var delegate: MessageLabelDelegate?
open internal(set) var addressAttributes: [NSAttributedString.Key: Any] = defaultAttributes
open internal(set) var dateAttributes: [NSAttributedString.Key: Any] = defaultAttributes
open internal(set) var phoneNumberAttributes: [NSAttributedString.Key: Any] = defaultAttributes
open internal(set) var urlAttributes: [NSAttributedString.Key: Any] = defaultAttributes
open internal(set) var transitInformationAttributes: [NSAttributedString.Key: Any] = defaultAttributes
open internal(set) var hashtagAttributes: [NSAttributedString.Key: Any] = defaultAttributes
open internal(set) var mentionAttributes: [NSAttributedString.Key: Any] = defaultAttributes
open internal(set) var customAttributes: [NSRegularExpression: [NSAttributedString.Key: Any]] = [:]
open var enabledDetectors: [DetectorType] = [] {
didSet {
setTextStorage(attributedText, shouldParse: true)
}
}
open override var attributedText: NSAttributedString? {
didSet {
setTextStorage(attributedText, shouldParse: true)
}
}
open override var text: String? {
didSet {
setTextStorage(attributedText, shouldParse: true)
}
}
// swiftlint:disable:next implicitly_unwrapped_optional
open override var font: UIFont! {
didSet {
setTextStorage(attributedText, shouldParse: false)
}
}
// swiftlint:disable:next implicitly_unwrapped_optional
open override var textColor: UIColor! {
didSet {
setTextStorage(attributedText, shouldParse: false)
}
}
open override var lineBreakMode: NSLineBreakMode {
didSet {
textContainer.lineBreakMode = lineBreakMode
if !isConfiguring { setNeedsDisplay() }
}
}
open override var numberOfLines: Int {
didSet {
textContainer.maximumNumberOfLines = numberOfLines
if !isConfiguring { setNeedsDisplay() }
}
}
open override var textAlignment: NSTextAlignment {
didSet {
setTextStorage(attributedText, shouldParse: false)
}
}
open var textInsets: UIEdgeInsets = .zero {
didSet {
if !isConfiguring { setNeedsDisplay() }
}
}
open override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize
size.width += textInsets.horizontal
size.height += textInsets.vertical
return size
}
// MARK: - Open Methods
open override func drawText(in rect: CGRect) {
let insetRect = rect.inset(by: textInsets)
textContainer.size = CGSize(width: insetRect.width, height: insetRect.height)
let origin = insetRect.origin
let range = layoutManager.glyphRange(for: textContainer)
layoutManager.drawBackground(forGlyphRange: range, at: origin)
layoutManager.drawGlyphs(forGlyphRange: range, at: origin)
}
open func handleGesture(_ touchLocation: CGPoint) -> Bool {
guard let index = stringIndex(at: touchLocation) else { return false }
for (detectorType, ranges) in rangesForDetectors {
for (range, value) in ranges {
if range.contains(index) {
handleGesture(for: detectorType, value: value)
return true
}
}
}
return false
}
// MARK: Public
public static var defaultAttributes: [NSAttributedString.Key: Any] = {
[
NSAttributedString.Key.foregroundColor: UIColor.darkText,
NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue,
NSAttributedString.Key.underlineColor: UIColor.darkText,
]
}()
public func setAttributes(_ attributes: [NSAttributedString.Key: Any], detector: DetectorType) {
switch detector {
case .phoneNumber:
phoneNumberAttributes = attributes
case .address:
addressAttributes = attributes
case .date:
dateAttributes = attributes
case .url:
urlAttributes = attributes
case .transitInformation:
transitInformationAttributes = attributes
case .mention:
mentionAttributes = attributes
case .hashtag:
hashtagAttributes = attributes
case .custom(let regex):
customAttributes[regex] = attributes
}
if isConfiguring {
attributesNeedUpdate = true
} else {
updateAttributes(for: [detector])
}
}
// MARK: - Public Methods
public func configure(block: () -> Void) {
isConfiguring = true
block()
if attributesNeedUpdate {
updateAttributes(for: enabledDetectors)
}
attributesNeedUpdate = false
isConfiguring = false
setNeedsDisplay()
}
// MARK: Internal
internal lazy var rangesForDetectors: [DetectorType: [(NSRange, MessageTextCheckingType)]] = [:]
internal var messageLabelFont: UIFont?
// MARK: Private
// MARK: - Private Properties
private lazy var layoutManager: NSLayoutManager = {
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(self.textContainer)
return layoutManager
}()
private lazy var textContainer: NSTextContainer = {
let textContainer = NSTextContainer()
textContainer.lineFragmentPadding = 0
textContainer.maximumNumberOfLines = self.numberOfLines
textContainer.lineBreakMode = self.lineBreakMode
textContainer.size = self.bounds.size
return textContainer
}()
private lazy var textStorage: NSTextStorage = {
let textStorage = NSTextStorage()
textStorage.addLayoutManager(self.layoutManager)
return textStorage
}()
private var isConfiguring = false
private var attributesNeedUpdate = false
// MARK: - Private Methods
private func setTextStorage(_ newText: NSAttributedString?, shouldParse: Bool) {
guard let newText = newText, newText.length > 0 else {
textStorage.setAttributedString(NSAttributedString())
setNeedsDisplay()
return
}
let style = paragraphStyle(for: newText)
let range = NSRange(location: 0, length: newText.length)
let mutableText = NSMutableAttributedString(attributedString: newText)
mutableText.addAttribute(.paragraphStyle, value: style, range: range)
if shouldParse {
rangesForDetectors.removeAll()
let results = parse(text: mutableText)
setRangesForDetectors(in: results)
}
for (detector, rangeTuples) in rangesForDetectors {
if enabledDetectors.contains(detector) {
let attributes = detectorAttributes(for: detector)
rangeTuples.forEach { range, _ in
mutableText.addAttributes(attributes, range: range)
}
}
}
let modifiedText = NSAttributedString(attributedString: mutableText)
textStorage.setAttributedString(modifiedText)
if !isConfiguring { setNeedsDisplay() }
}
private func paragraphStyle(for text: NSAttributedString) -> NSParagraphStyle {
guard text.length > 0 else { return NSParagraphStyle() }
var range = NSRange(location: 0, length: text.length)
let existingStyle = text.attribute(.paragraphStyle, at: 0, effectiveRange: &range) as? NSMutableParagraphStyle
let style = existingStyle ?? NSMutableParagraphStyle()
style.lineBreakMode = lineBreakMode
style.alignment = textAlignment
return style
}
private func updateAttributes(for detectors: [DetectorType]) {
guard let attributedText = attributedText, attributedText.length > 0 else { return }
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText)
for detector in detectors {
guard let rangeTuples = rangesForDetectors[detector] else { continue }
for (range, _) in rangeTuples {
// This will enable us to attribute it with our own styles, since `UILabel` does not provide link attribute overrides like `UITextView` does
if detector.textCheckingType == .link {
mutableAttributedString.removeAttribute(NSAttributedString.Key.link, range: range)
}
let attributes = detectorAttributes(for: detector)
mutableAttributedString.addAttributes(attributes, range: range)
}
let updatedString = NSAttributedString(attributedString: mutableAttributedString)
textStorage.setAttributedString(updatedString)
}
}
private func detectorAttributes(for detectorType: DetectorType) -> [NSAttributedString.Key: Any] {
switch detectorType {
case .address:
return addressAttributes
case .date:
return dateAttributes
case .phoneNumber:
return phoneNumberAttributes
case .url:
return urlAttributes
case .transitInformation:
return transitInformationAttributes
case .mention:
return mentionAttributes
case .hashtag:
return hashtagAttributes
case .custom(let regex):
return customAttributes[regex] ?? MessageLabel.defaultAttributes
}
}
private func detectorAttributes(for checkingResultType: NSTextCheckingResult.CheckingType) -> [NSAttributedString.Key: Any] {
switch checkingResultType {
case .address:
return addressAttributes
case .date:
return dateAttributes
case .phoneNumber:
return phoneNumberAttributes
case .link:
return urlAttributes
case .transitInformation:
return transitInformationAttributes
default:
fatalError(MessageKitError.unrecognizedCheckingResult)
}
}
private func setupView() {
numberOfLines = 0
lineBreakMode = .byWordWrapping
}
// MARK: - Parsing Text
private func parse(text: NSAttributedString) -> [NSTextCheckingResult] {
guard enabledDetectors.isEmpty == false else { return [] }
let range = NSRange(location: 0, length: text.length)
var matches = [NSTextCheckingResult]()
// Get matches of all .custom DetectorType and add it to matches array
let regexs = enabledDetectors
.filter { $0.isCustom }
.map { parseForMatches(with: $0, in: text, for: range) }
.joined()
matches.append(contentsOf: removeOverlappingResults(Array(regexs)))
// Get all Checking Types of detectors, except for .custom because they contain their own regex
let detectorCheckingTypes = enabledDetectors
.filter { !$0.isCustom }
.reduce(0) { $0 | $1.textCheckingType.rawValue }
if detectorCheckingTypes > 0, let detector = try? NSDataDetector(types: detectorCheckingTypes) {
let detectorMatches = detector.matches(in: text.string, options: [], range: range)
matches.append(contentsOf: detectorMatches)
}
guard enabledDetectors.contains(.url) else {
return matches
}
// Enumerate NSAttributedString NSLinks and append ranges
var results: [NSTextCheckingResult] = matches
text.enumerateAttribute(NSAttributedString.Key.link, in: range, options: []) { value, range, _ in
guard let url = value as? URL else { return }
let result = NSTextCheckingResult.linkCheckingResult(range: range, url: url)
results.append(result)
}
return results
}
private func parseForMatches(
with detector: DetectorType,
in text: NSAttributedString,
for range: NSRange) -> [NSTextCheckingResult]
{
switch detector {
case .custom(let regex):
return regex.matches(in: text.string, options: [], range: range)
default:
fatalError("You must pass a .custom DetectorType")
}
}
private func removeOverlappingResults(_ results: [NSTextCheckingResult]) -> [NSTextCheckingResult]
{
var filteredResults: [NSTextCheckingResult] = []
for result in results {
let overlappingResults = results.filter { $0.range.intersection(result.range)?.length ?? 0 > 0 }
if overlappingResults.count <= 1 {
filteredResults.append(result)
continue
}
guard !filteredResults.contains(where: { $0.range == result.range }) else { continue }
let maxRangeResult = overlappingResults.max { $0.range.upperBound - $0.range.lowerBound < $1.range.upperBound - $1.range.lowerBound }
if let maxRangeResult {
filteredResults.append(maxRangeResult)
}
}
return filteredResults
}
private func setRangesForDetectors(in checkingResults: [NSTextCheckingResult]) {
guard checkingResults.isEmpty == false else { return }
for result in checkingResults {
switch result.resultType {
case .address:
var ranges = rangesForDetectors[.address] ?? []
let tuple: (NSRange, MessageTextCheckingType) = (result.range, .addressComponents(result.addressComponents))
ranges.append(tuple)
rangesForDetectors.updateValue(ranges, forKey: .address)
case .date:
var ranges = rangesForDetectors[.date] ?? []
let tuple: (NSRange, MessageTextCheckingType) = (result.range, .date(result.date))
ranges.append(tuple)
rangesForDetectors.updateValue(ranges, forKey: .date)
case .phoneNumber:
var ranges = rangesForDetectors[.phoneNumber] ?? []
let tuple: (NSRange, MessageTextCheckingType) = (result.range, .phoneNumber(result.phoneNumber))
ranges.append(tuple)
rangesForDetectors.updateValue(ranges, forKey: .phoneNumber)
case .link:
var ranges = rangesForDetectors[.url] ?? []
let tuple: (NSRange, MessageTextCheckingType) = (result.range, .link(result.url))
ranges.append(tuple)
rangesForDetectors.updateValue(ranges, forKey: .url)
case .transitInformation:
var ranges = rangesForDetectors[.transitInformation] ?? []
let tuple: (NSRange, MessageTextCheckingType) = (result.range, .transitInfoComponents(result.components))
ranges.append(tuple)
rangesForDetectors.updateValue(ranges, forKey: .transitInformation)
case .regularExpression:
guard
let text = text, let regex = result.regularExpression,
let range = Range(result.range, in: text) else { return }
let detector = DetectorType.custom(regex)
var ranges = rangesForDetectors[detector] ?? []
let tuple: (NSRange, MessageTextCheckingType) = (
result.range,
.custom(pattern: regex.pattern, match: String(text[range])))
ranges.append(tuple)
rangesForDetectors.updateValue(ranges, forKey: detector)
default:
fatalError("Received an unrecognized NSTextCheckingResult.CheckingType")
}
}
}
// MARK: - Gesture Handling
private func stringIndex(at location: CGPoint) -> Int? {
guard textStorage.length > 0 else { return nil }
var location = location
location.x -= textInsets.left
location.y -= textInsets.top
let index = layoutManager.glyphIndex(for: location, in: textContainer)
let lineRect = layoutManager.lineFragmentUsedRect(forGlyphAt: index, effectiveRange: nil)
var characterIndex: Int?
if lineRect.contains(location) {
characterIndex = layoutManager.characterIndexForGlyph(at: index)
}
return characterIndex
}
// swiftlint:disable cyclomatic_complexity
private func handleGesture(for detectorType: DetectorType, value: MessageTextCheckingType) {
switch value {
case .addressComponents(let addressComponents):
var transformedAddressComponents = [String: String]()
guard let addressComponents = addressComponents else { return }
addressComponents.forEach { key, value in
transformedAddressComponents[key.rawValue] = value
}
handleAddress(transformedAddressComponents)
case .phoneNumber(let phoneNumber):
guard let phoneNumber = phoneNumber else { return }
handlePhoneNumber(phoneNumber)
case .date(let date):
guard let date = date else { return }
handleDate(date)
case .link(let url):
guard let url = url else { return }
handleURL(url)
case .transitInfoComponents(let transitInformation):
var transformedTransitInformation = [String: String]()
guard let transitInformation = transitInformation else { return }
transitInformation.forEach { key, value in
transformedTransitInformation[key.rawValue] = value
}
handleTransitInformation(transformedTransitInformation)
case .custom(let pattern, let match):
guard let match = match else { return }
switch detectorType {
case .hashtag:
handleHashtag(match)
case .mention:
handleMention(match)
default:
handleCustom(pattern, match: match)
}
}
}
// swiftlint:enable cyclomatic_complexity
private func handleAddress(_ addressComponents: [String: String]) {
delegate?.didSelectAddress(addressComponents)
}
private func handleDate(_ date: Date) {
delegate?.didSelectDate(date)
}
private func handleURL(_ url: URL) {
delegate?.didSelectURL(url)
}
private func handlePhoneNumber(_ phoneNumber: String) {
delegate?.didSelectPhoneNumber(phoneNumber)
}
private func handleTransitInformation(_ components: [String: String]) {
delegate?.didSelectTransitInformation(components)
}
private func handleHashtag(_ hashtag: String) {
delegate?.didSelectHashtag(hashtag)
}
private func handleMention(_ mention: String) {
delegate?.didSelectMention(mention)
}
private func handleCustom(_ pattern: String, match: String) {
delegate?.didSelectCustom(pattern, match: match)
}
}
// MARK: - MessageTextCheckingType
internal enum MessageTextCheckingType {
case addressComponents([NSTextCheckingKey: String]?)
case date(Date?)
case phoneNumber(String?)
case link(URL?)
case transitInfoComponents([NSTextCheckingKey: String]?)
case custom(pattern: String, match: String?)
}