mirror of
https://github.com/MessageKit/MessageKit.git
synced 2026-02-06 19:03:19 +00:00
582 lines
18 KiB
Swift
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?)
|
|
}
|