Skip to content

Instantly share code, notes, and snippets.

@kwylez
Last active January 1, 2018 08:29
Show Gist options
  • Save kwylez/e58b195a360cb63c60ebb6fc9dfb13b1 to your computer and use it in GitHub Desktop.
Save kwylez/e58b195a360cb63c60ebb6fc9dfb13b1 to your computer and use it in GitHub Desktop.
//
// ContextLabel.swift
// ContextLabel
//
// Created by Cory D. Wiles
// Copyright (c) 2017 Cory D. Wiles. All rights reserved.
//
import Foundation
import UIKit
fileprivate func < <T : Comparable>(lhs: T?, rhs: T?) -> Bool {
switch (lhs, rhs) {
case let (l?, r?):
return l < r
case (nil, _?):
return true
default:
return false
}
}
fileprivate func <= <T : Comparable>(lhs: T?, rhs: T?) -> Bool {
switch (lhs, rhs) {
case let (l?, r?):
return l <= r
default:
return !(rhs < lhs)
}
}
open class ContextLabelData: NSObject {
var attributedString: NSAttributedString
var linkResults: [LinkResult]
var userInfo: [NSObject: AnyObject]?
// MARK: Initializers
init(attributedString: NSAttributedString, linkResults: [LinkResult]) {
self.attributedString = attributedString
self.linkResults = linkResults
super.init()
}
}
public struct LinkResult {
public let detectionType: ContextLabel.LinkDetectionType
public let range: NSRange
public let text: String
public let textLink: TextLink?
}
public struct TouchResult {
public let linkResult: LinkResult?
public let touches: Set<UITouch>
public let event: UIEvent?
public let state: UIGestureRecognizerState
}
public struct TextLink {
public let text: String
public let range: NSRange?
public let options: NSString.CompareOptions
public let object: Any?
public let action: ()->()
public init(text: String, range: NSRange? = nil, options: NSString.CompareOptions = [], object: Any? = nil, action: @escaping ()->()) {
self.text = text
self.range = range
self.options = options
self.object = object
self.action = action
}
}
open class ContextLabel: UILabel, NSLayoutManagerDelegate, UIGestureRecognizerDelegate {
public enum LinkDetectionType {
case none
case userHandle
case hashtag
case url
case textLink
}
let hashtagRegex = "(?<=\\s|^)#(\\w*[A-Za-z&_-]+\\w*)"
let userHandleRegex = "(?<=\\s|^)@(\\w*[A-Za-z&_-]+\\w*)"
// MARK: - Closures
public var foregroundColor: (LinkResult) -> UIColor = { (linkResult) in
switch linkResult.detectionType {
case .userHandle:
return UIColor(red: 71.0/255.0, green: 90.0/255.0, blue: 109.0/255.0, alpha: 1.0)
case .hashtag:
return UIColor(red: 151.0/255.0, green: 154.0/255.0, blue: 158.0/255.0, alpha: 1.0)
case .url:
return UIColor(red: 45.0/255.0, green: 113.0/255.0, blue: 178.0/255.0, alpha: 1.0)
case .textLink:
return UIColor(red: 45.0/255.0, green: 113.0/255.0, blue: 178.0/255.0, alpha: 1.0)
default:
return .black
}
}
public var foregroundHighlightedColor: (LinkResult) -> UIColor? = { (linkResult) in
return nil
}
public var underlineStyle: (LinkResult) -> (NSUnderlineStyle) = { _ in
return .styleNone
}
public var didTouch: (TouchResult) -> Void = { _ in }
public var didCopy: (String!) -> Void = { _ in }
// MARK: - Properties
var edgeInsets: UIEdgeInsets = .zero
// LineSpacing
public var lineSpacing: CGFloat?
public var lineHeightMultiple: CGFloat?
public var canCopy: Bool = false {
didSet {
longPressGestureRecognizer.isEnabled = canCopy
}
}
// Autolayout
open var preferedHeight: CGFloat? {
didSet {
invalidateIntrinsicContentSize()
}
}
open var preferedWidth: CGFloat? {
didSet {
invalidateIntrinsicContentSize()
}
}
// Automatic detection of links, hashtags and usernames. When this is enabled links
// are coloured using the textColor property above
public var automaticLinkDetectionEnabled: Bool = true {
didSet {
setContextLabelDataWithText(nil)
}
}
// linkDetectionTypes
public var linkDetectionTypes: [LinkDetectionType] = [.userHandle, .hashtag, .url, .textLink] {
didSet {
setContextLabelDataWithText(nil)
}
}
// Array of link texts
public var textLinks: [TextLink]? {
didSet {
if let textLinks = textLinks {
if let contextLabelData = contextLabelData {
// Add linkResults for textLinks
let linkResults = linkResultsForTextLinks(textLinks)
contextLabelData.linkResults += linkResults
// Addd attributes for textLinkResults
let attributedString = addLinkAttributesTo(contextLabelData.attributedString, with: linkResults)
contextLabelData.attributedString = attributedString
// Set attributedText
attributedText = contextLabelData.attributedString
}
}
}
}
// Selected linkResult
fileprivate var selectedLinkResult: LinkResult?
// Cachable Object to encapsulate all relevant data to restore ContextLabel values
public var contextLabelData: ContextLabelData? {
didSet {
if let contextLabelData = contextLabelData {
// Set attributedText
attributedText = contextLabelData.attributedString
// Set the string on the storage
textStorage?.setAttributedString(contextLabelData.attributedString)
}
}
}
public lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
let _recognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressGestureRecognized(_:)))
_recognizer.delegate = self
return _recognizer
}()
// Specifies the space in which to render text
fileprivate lazy var textContainer: NSTextContainer = {
let _textContainer = NSTextContainer()
_textContainer.lineFragmentPadding = 0
_textContainer.maximumNumberOfLines = self.numberOfLines
_textContainer.lineBreakMode = self.lineBreakMode
_textContainer.size = CGSize(width: self.bounds.width, height: self.bounds.height)
return _textContainer
}()
// Used to control layout of glyphs and rendering
fileprivate lazy var layoutManager: NSLayoutManager = {
let _layoutManager = NSLayoutManager()
_layoutManager.delegate = self
_layoutManager.addTextContainer(self.textContainer)
return _layoutManager
}()
// Backing storage for text that is rendered by the layout manager
fileprivate lazy var textStorage: NSTextStorage? = {
let _textStorage = NSTextStorage()
_textStorage.addLayoutManager(self.layoutManager)
return _textStorage
}()
// MARK: - Properties override
open override var frame: CGRect {
didSet {
let widthPadding: CGFloat = self.edgeInsets.right + self.edgeInsets.left
let heightPadding: CGFloat = self.edgeInsets.top + self.edgeInsets.bottom
textContainer.size = CGSize(width: self.bounds.width, height: self.bounds.height)
}
}
open override var bounds: CGRect {
didSet {
let widthPadding: CGFloat = self.edgeInsets.right + self.edgeInsets.left
let heightPadding: CGFloat = self.edgeInsets.top + self.edgeInsets.bottom
textContainer.size = CGSize(width: self.bounds.width, height: self.bounds.height)
}
}
open override var numberOfLines: Int {
didSet {
textContainer.maximumNumberOfLines = numberOfLines
}
}
open override var text: String! {
didSet {
setContextLabelDataWithText(text)
}
}
open override var textColor: UIColor! {
didSet {
_textColor = textColor
}
}
fileprivate var _textColor = UIColor.black
// MARK: - Initializations
public required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
public override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
public convenience init(frame: CGRect, didTouch: @escaping (TouchResult) -> Void) {
self.init(frame: frame)
self.didTouch = didTouch
setup()
}
// MARK: - Override Properties
open override var canBecomeFirstResponder: Bool {
return canCopy
}
// MARK: - Override Methods
open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return action == #selector(copy(_:)) && canCopy
}
open override func copy(_ sender: Any?) {
UIPasteboard.general.string = text
didCopy(text)
}
open override var intrinsicContentSize : CGSize {
var width = super.intrinsicContentSize.width + self.edgeInsets.right + self.edgeInsets.left
var height = super.intrinsicContentSize.height + self.edgeInsets.top + self.edgeInsets.bottom
if let preferedWidth = preferedWidth {
width = preferedWidth
}
if let preferedHeight = preferedHeight {
height = preferedHeight
}
return CGSize(width: width, height: height)
}
open override func layoutSubviews() {
super.layoutSubviews()
textContainer.size = CGSize(width: self.bounds.width, height: CGFloat.greatestFiniteMagnitude)
}
open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let linkResult = linkResult(with: touches) {
selectedLinkResult = linkResult
didTouch(TouchResult(linkResult: linkResult, touches: touches, event: event, state: .began))
} else {
selectedLinkResult = nil
didTouch(TouchResult(linkResult: nil, touches: touches, event: event, state: .began))
}
addLinkAttributesToLinkResult(withTouches: touches, highlighted: true)
super.touchesBegan(touches, with: event)
}
open override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if let linkResult = linkResult(with: touches) {
if linkResult.range.location != selectedLinkResult?.range.location {
if let selectedLinkResult = selectedLinkResult, let attributedText = attributedText {
self.attributedText = addLinkAttributesTo(attributedText, with: [selectedLinkResult], highlighted: false)
}
}
selectedLinkResult = linkResult
addLinkAttributesToLinkResult(withTouches: touches, highlighted: true)
didTouch(TouchResult(linkResult: linkResult, touches: touches, event: event, state: .changed))
} else {
if let selectedLinkResult = selectedLinkResult, let attributedText = attributedText {
self.attributedText = addLinkAttributesTo(attributedText, with: [selectedLinkResult], highlighted: false)
}
selectedLinkResult = nil
didTouch(TouchResult(linkResult: nil, touches: touches, event: event, state: .changed))
}
super.touchesMoved(touches, with: event)
}
open override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
addLinkAttributesToLinkResult(withTouches: touches, highlighted: false)
if let selectedLinkResult = selectedLinkResult {
didTouch(TouchResult(linkResult: selectedLinkResult, touches: touches, event: event, state: .ended))
selectedLinkResult.textLink?.action()
} else {
didTouch(TouchResult(linkResult: nil, touches: touches, event: event, state: .ended))
}
selectedLinkResult = nil
super.touchesEnded(touches, with: event)
}
open override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
addLinkAttributesToLinkResult(withTouches: touches, highlighted: false)
didTouch(TouchResult(linkResult: nil, touches: touches, event: event, state: .cancelled))
super.touchesCancelled(touches, with: event)
}
// MARK: - Methods
func addAttributes(_ attributes: Dictionary<String, AnyObject>, range: NSRange) {
if let contextLabelData = contextLabelData {
let mutableAttributedString = NSMutableAttributedString(attributedString: contextLabelData.attributedString)
mutableAttributedString.addAttributes(attributes, range: range)
contextLabelData.attributedString = mutableAttributedString
attributedText = contextLabelData.attributedString
}
}
open func setContextLabelDataWithText(_ text: String?) {
var text = text
if text == nil {
text = self.text
}
if let text = text {
self.contextLabelData = contextLabelDataWithText(text)
}
}
open func contextLabelDataWithText(_ text: String?) -> ContextLabelData? {
if let text = text {
let mutableAttributedString = NSMutableAttributedString(string: text, attributes: attributesFromProperties())
let _linkResults = linkResults(in: mutableAttributedString)
let attributedString = addLinkAttributesTo(mutableAttributedString, with: _linkResults)
return ContextLabelData(attributedString: attributedString, linkResults: _linkResults)
}
return nil
}
open func setText(_ text:String, withTextLinks textLinks: [TextLink]) {
self.textLinks = textLinks
self.contextLabelData = contextLabelDataWithText(text)
}
open func attributesFromProperties() -> [String : Any] {
// Shadow attributes
let shadow = NSShadow()
if self.shadowColor != nil {
shadow.shadowColor = self.shadowColor
shadow.shadowOffset = self.shadowOffset
} else {
shadow.shadowOffset = CGSize(width: 0, height: -1);
shadow.shadowColor = nil;
}
// Color attributes
var color = _textColor
if self.isEnabled == false {
color = .lightGray
} else if self.isHighlighted {
if self.highlightedTextColor != nil {
color = self.highlightedTextColor!
}
}
// Paragraph attributes
let mutableParagraphStyle = NSMutableParagraphStyle()
mutableParagraphStyle.alignment = self.textAlignment
// LineSpacing
if let lineSpacing = lineSpacing {
mutableParagraphStyle.lineSpacing = lineSpacing
}
// LineHeightMultiple
if let lineHeightMultiple = lineHeightMultiple {
mutableParagraphStyle.lineHeightMultiple = lineHeightMultiple
}
// Attributes dictionary
var attributes: [String : Any] = [
NSShadowAttributeName: shadow,
NSParagraphStyleAttributeName: mutableParagraphStyle,
NSForegroundColorAttributeName: color
]
if let font = self.font {
attributes[NSFontAttributeName] = font
}
return attributes
}
fileprivate func attributesWithTextColor(_ textColor: UIColor) -> [String: Any] {
var attributes = attributesFromProperties()
attributes[NSForegroundColorAttributeName] = textColor
return attributes
}
fileprivate func attributesWithTextColor(_ textColor: UIColor, underlineStyle: NSUnderlineStyle) -> [String: Any] {
var attributes = attributesWithTextColor(textColor)
attributes[NSUnderlineStyleAttributeName] = underlineStyle.rawValue
return attributes
}
fileprivate func setup() {
lineBreakMode = .byTruncatingTail
// Attach the layou manager to the container and storage
textContainer.layoutManager = self.layoutManager
// Make sure user interaction is enabled so we can accept touches
isUserInteractionEnabled = true
// Establish the text store with our current text
setContextLabelDataWithText(nil)
addGestureRecognizer(longPressGestureRecognizer)
}
// Returns array of link results for all special words, user handles, hashtags and urls
fileprivate func linkResults(in attributedString: NSAttributedString) -> [LinkResult] {
var linkResults = [LinkResult]()
if let textLinks = textLinks {
linkResults += linkResultsForTextLinks(textLinks)
}
if linkDetectionTypes.contains(.userHandle) {
linkResults += linkResultsForUserHandles(inString: attributedString.string)
}
if linkDetectionTypes.contains(.hashtag) {
linkResults += linkResultsForHashtags(inString: attributedString.string)
}
if linkDetectionTypes.contains(.url) {
linkResults += linkResultsForURLs(inAttributedString: attributedString)
}
return linkResults
}
// TEST: testLinkResultsForTextLinksWithoutEmojis()
// TEST: testLinkResultsForTextLinksWithEmojis()
// TEST: testLinkResultsForTextLinksWithMultipleOccuranciesWithoutRange()
// TEST: testLinkResultsForTextLinksWithMultipleOccuranciesWithRange()
internal func linkResultsForTextLinks(_ textLinks: [TextLink]) -> [LinkResult] {
var linkResults = [LinkResult]()
for textLink in textLinks {
let linkType = LinkDetectionType.textLink
let matchString = textLink.text
let range = textLink.range ?? NSMakeRange(0, text.characters.count)
var searchRange = range
var matchRange = NSRange()
if text.characters.count >= range.location + range.length {
while matchRange.location != NSNotFound {
matchRange = NSString(string: text).range(of: matchString, options: textLink.options, range: searchRange)
if matchRange.location != NSNotFound && (matchRange.location + matchRange.length) <= (range.location + range.length) {
linkResults.append(LinkResult(detectionType: linkType, range: matchRange, text: matchString, textLink: textLink))
// Remaining searchRange
let location = matchRange.location + matchRange.length
let length = text.characters.count - location
searchRange = NSMakeRange(location, length)
} else {
break
}
}
}
}
return linkResults
}
// TEST: testLinkResultsForUserHandlesWithoutEmojis()
// TEST: testLinkResultsForUserHandlesWithEmojis()
internal func linkResultsForUserHandles(inString string: String) -> [LinkResult] {
return linkResults(for: .userHandle, regexPattern: userHandleRegex, string: string)
}
// TEST: testLinkResultsForHashtagsWithoutEmojis()
// TEST: testLinkResultsForHashtagsWithEmojis()
internal func linkResultsForHashtags(inString string: String) -> [LinkResult] {
return linkResults(for: .hashtag, regexPattern: hashtagRegex, string: string)
}
fileprivate func linkResults(for linkType: LinkDetectionType, regexPattern: String, string: String) -> [LinkResult] {
var linkResults = [LinkResult]()
guard let regex = try? NSRegularExpression(pattern: regexPattern, options: .caseInsensitive) else {
return linkResults
}
// Run the expression and get matches
let nsString = text as NSString
let matches = regex.matches(in: text, options: .reportCompletion, range: NSMakeRange(0, nsString.length))
// Add all our ranges to the result
for match in matches {
let matchRange = match.range
let matchString = NSString(string: text).substring(with: matchRange)
if matchRange.length > 1 {
linkResults.append(LinkResult(detectionType: linkType, range: matchRange, text: matchString, textLink: nil))
}
}
return linkResults
}
fileprivate func linkResultsForURLs(inAttributedString attributedString: NSAttributedString) -> [LinkResult] {
var linkResults = [LinkResult]()
// Use a data detector to find urls in the text
let plainText = attributedString.string
let dataDetector: NSDataDetector?
do {
dataDetector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
} catch _ as NSError {
dataDetector = nil
}
if let dataDetector = dataDetector {
let matches = dataDetector.matches(in: plainText, options: NSRegularExpression.MatchingOptions.reportCompletion, range: NSMakeRange(0, plainText.characters.count))
// Add a range entry for every url we found
for match in matches {
let matchRange = match.range
// If there's a link embedded in the attributes, use that instead of the raw text
var realURL = attributedString.attribute(NSLinkAttributeName, at: matchRange.location, effectiveRange: nil)
if realURL == nil {
if let range = plainText.rangeFromNSRange(matchRange) {
realURL = plainText.substring(with: range)
}
}
if match.resultType == .link {
if let matchString = realURL as? String {
linkResults.append(LinkResult(detectionType: .url, range: matchRange, text: matchString, textLink: nil))
}
}
}
}
return linkResults
}
fileprivate func addLinkAttributesTo(_ attributedString: NSAttributedString, with linkResults: [LinkResult], highlighted: Bool = false) -> NSAttributedString {
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
for linkResult in linkResults {
let textColor = foregroundColor(linkResult)
let highlightedTextColor = foregroundHighlightedColor(linkResult)
let color = (highlighted) ? highlightedTextColor ?? self.highlightedTextColor(textColor) : textColor
let attributes = attributesWithTextColor(color, underlineStyle: self.underlineStyle(linkResult))
mutableAttributedString.setAttributes(attributes, range: linkResult.range)
}
return mutableAttributedString
}
fileprivate func addLinkAttributesToLinkResult(withTouches touches: Set<UITouch>!, highlighted: Bool) {
if let linkResult = linkResult(with: touches), let attributedText = attributedText {
self.attributedText = addLinkAttributesTo(attributedText, with: [linkResult], highlighted: highlighted)
}
}
fileprivate func linkResult(with touches: Set<UITouch>!) -> LinkResult? {
if let touchLocation = touches.first?.location(in: self), let touchedLink = linkResult(at: touchLocation) {
return touchedLink
}
return nil
}
fileprivate func linkResult(at location: CGPoint) -> LinkResult? {
// Find the tapped character location and compare it to the specified range
let boundsWidth: CGFloat = self.bounds.width + self.edgeInsets.left + self.edgeInsets.right
let boundsHeight: CGFloat = self.bounds.height + self.edgeInsets.top + self.edgeInsets.bottom
let locationOfTouchInLabel = location
let textBoundingBox = layoutManager.usedRect(for: textContainer)
let textContainerOffset = CGPoint(x: (boundsWidth - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,
y: (boundsHeight - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y);
let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x,
y: locationOfTouchInLabel.y - textContainerOffset.y);
let characterIndex = layoutManager.characterIndex(for: locationOfTouchInTextContainer,
in: textContainer,
fractionOfDistanceBetweenInsertionPoints: nil)
if characterIndex <= textStorage?.length {
if let linkResults = contextLabelData?.linkResults {
for linkResult in linkResults {
let rangeLocation = linkResult.range.location
let rangeLength = linkResult.range.length
if rangeLocation <= characterIndex &&
(rangeLocation + rangeLength - 1) >= characterIndex {
let glyphRange = layoutManager.glyphRange(forCharacterRange: NSMakeRange(rangeLocation, rangeLength), actualCharacterRange: nil)
let boundingRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
if boundingRect.contains(location) {
return linkResult
}
}
}
}
}
return nil
}
fileprivate func highlightedTextColor(_ textColor: UIColor) -> UIColor {
return textColor.withAlphaComponent(0.5)
}
// MARK: Actions
func longPressGestureRecognized(_ sender: UILongPressGestureRecognizer) {
if let superview = superview, canCopy, sender.state == .began {
becomeFirstResponder()
let menu = UIMenuController.shared
menu.setTargetRect(frame, in: superview)
menu.setMenuVisible(true, animated: true)
}
}
}
extension ContextLabel {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
extension ContextLabel {
override open func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect {
let insetBounds: CGRect = UIEdgeInsetsInsetRect(bounds, self.edgeInsets)
var rect = super.textRect(forBounds: insetBounds, limitedToNumberOfLines: numberOfLines)
rect.origin.x -= self.edgeInsets.left
rect.origin.y -= self.edgeInsets.top
rect.size.width += (self.edgeInsets.left + self.edgeInsets.right)
rect.size.height += (self.edgeInsets.top + self.edgeInsets.bottom)
return rect
}
override open func drawText(in rect: CGRect) {
super.drawText(in: UIEdgeInsetsInsetRect(rect, self.edgeInsets))
}
}
extension String {
func rangeFromNSRange(_ nsRange : NSRange) -> Range<String.Index>? {
if let from16 = utf16.index(utf16.startIndex, offsetBy: nsRange.location, limitedBy: utf16.endIndex) {
if let to16 = utf16.index(from16, offsetBy: nsRange.length, limitedBy: utf16.endIndex) {
if let from = String.Index(from16, within: self), let to = String.Index(to16, within: self) {
return from ..< to
}
}
}
return nil
}
func NSRangeFromRange(_ range : Range<String.Index>) -> NSRange {
let utf16view = self.utf16
let from = String.UTF16View.Index(range.lowerBound, within: utf16view)
let to = String.UTF16View.Index(range.upperBound, within: utf16view)
return NSMakeRange(utf16view.distance(from: from, to: from), utf16view.distance(from: from, to: to))
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment