// // StatusContentLabel.swift // Tusker // // Created by Shadowfacts on 8/25/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import SwiftSoup class StatusContentLabel: UILabel { override var text: String? { didSet { parseHTML() } } override var attributedText: NSAttributedString? { didSet { updateTextStorage() } } override var numberOfLines: Int { didSet { textContainer.maximumNumberOfLines = numberOfLines } } override var lineBreakMode: NSLineBreakMode { didSet { textContainer.lineBreakMode = lineBreakMode } } public var lineSpacing: CGFloat = 0 public var minimumLineHeight: CGFloat = 0 private var _customizing = true private lazy var textStorage = NSTextStorage() private lazy var layoutManager = NSLayoutManager() private lazy var textContainer = NSTextContainer() private var heightCorrection: CGFloat = 0 private var selectedElement: (range: NSRange, url: URL)? private var links: [NSRange: URL] = [:] override init(frame: CGRect) { super.init(frame: frame) _customizing = false setupLabel() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) _customizing = false setupLabel() } override func awakeFromNib() { super.awakeFromNib() updateTextStorage() } override func drawText(in rect: CGRect) { let range = NSRange(location: 0, length: textStorage.length) textContainer.size = rect.size let newOrigin = textOrigin(in: rect) layoutManager.drawBackground(forGlyphRange: range, at: newOrigin) layoutManager.drawGlyphs(forGlyphRange: range, at: newOrigin) } override var intrinsicContentSize: CGSize { let superSize = super.intrinsicContentSize textContainer.size = CGSize(width: superSize.width, height: .greatestFiniteMagnitude) let size = layoutManager.usedRect(for: textContainer) return CGSize(width: ceil(size.width), height: ceil(size.height)) } private func element(at location: CGPoint) -> (range: NSRange, url: URL)? { guard textStorage.length > 0 else { return nil } var correctLocation = location correctLocation.y -= heightCorrection let boundingRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: textStorage.length), in: textContainer) guard boundingRect.contains(correctLocation) else { return nil } let index = layoutManager.glyphIndex(for: correctLocation, in: textContainer) for (range, url) in links { if index >= range.location && index <= range.location + range.length { return (range, url) } } return nil } private func updateAttributesWhenSelected(_ isSelected: Bool) { guard let selectedElement = selectedElement else { return } var attributes = textStorage.attributes(at: 0, effectiveRange: nil) attributes[.foregroundColor] = isSelected ? nil : UIColor.blue textStorage.addAttributes(attributes, range: selectedElement.range) setNeedsDisplay() } private func onTouch(_ touch: UITouch) -> Bool { let location = touch.location(in: self) var avoidSuperCall = false switch touch.phase { case .began, .moved: if let element = element(at: location) { if element.range.location != selectedElement?.range.location || element.range.length != selectedElement?.range.length { updateAttributesWhenSelected(false) selectedElement = element updateAttributesWhenSelected(true) } avoidSuperCall = true } else { updateAttributesWhenSelected(false) selectedElement = nil } case .ended: guard let selectedElement = selectedElement else { return avoidSuperCall } print("tapped \(selectedElement)") let when = DispatchTime.now() + Double(Int64(0.25 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) DispatchQueue.main.asyncAfter(deadline: when) { self.updateAttributesWhenSelected(false) self.selectedElement = nil } case .cancelled: updateAttributesWhenSelected(false) selectedElement = nil case .stationary: break } return avoidSuperCall } override func touchesBegan(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } super.touchesBegan(touches, with: event) } override func touchesMoved(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } super.touchesMoved(touches, with: event) } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } super.touchesCancelled(touches, with: event) } override func touchesEnded(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } super.touchesEnded(touches, with: event) } private func setupLabel() { textStorage.addLayoutManager(layoutManager) layoutManager.addTextContainer(textContainer) textContainer.lineFragmentPadding = 0 textContainer.lineBreakMode = lineBreakMode textContainer.maximumNumberOfLines = numberOfLines isUserInteractionEnabled = true } private func parseHTML() { if _customizing { return } guard let text = text else { return } let doc = try! SwiftSoup.parse(text) let body = doc.body()! let (attributedText, links) = attributedTextForHTMLNode(body) self.links = links let mutAttrString = NSMutableAttributedString(attributedString: attributedText) mutAttrString.addAttribute(.font, value: font, range: NSRange(location: 0, length: mutAttrString.length)) self.attributedText = mutAttrString } private func attributedTextForHTMLNode(_ node: Node) -> (NSAttributedString, [NSRange: URL]) { switch node { case let node as TextNode: return (NSAttributedString(string: node.text()), [:]) case let node as Element: var links = [NSRange: URL]() let attributed = NSMutableAttributedString() node.getChildNodes().forEach { child in let (text, childLinks) = attributedTextForHTMLNode(child) childLinks.forEach { range, url in let newRange = NSRange(location: range.location + attributed.length, length: range.length) links[newRange] = url } attributed.append(text) } switch node.tagName() { case "br": attributed.append(NSAttributedString(string: "\n")) case "a": if let link = try? node.attr("href"), let url = URL(string: link) { let linkRange = NSRange(location: 0, length: attributed.length) let linkAttributes: [NSAttributedString.Key: Any] = [ .foregroundColor: UIColor.blue ] attributed.setAttributes(linkAttributes, range: linkRange) links[linkRange] = url } default: break } return (attributed, links) default: fatalError("Unexpected node type: \(type(of: node))") } } private func updateTextStorage() { guard !_customizing else { return } guard let attributedText = attributedText, attributedText.length > 0 else { links = [:] textStorage.setAttributedString(NSAttributedString()) setNeedsDisplay() return } // is this necessary? let mutAttrString = addLineBreak(attributedText) // let mutAttrString = NSMutableAttributedString(attributedString: attributedText) textStorage.setAttributedString(mutAttrString) _customizing = true text = attributedText.string _customizing = false setNeedsDisplay() } private func addLineBreak(_ attrString: NSAttributedString) -> NSMutableAttributedString { let mutAttrString = NSMutableAttributedString(attributedString: attrString) var range = NSRange(location: 0, length: 0) var attributes = mutAttrString.attributes(at: 0, effectiveRange: &range) let paragraphStyle = attributes[.paragraphStyle] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() paragraphStyle.lineBreakMode = .byWordWrapping paragraphStyle.alignment = textAlignment paragraphStyle.lineSpacing = lineSpacing paragraphStyle.minimumLineHeight = minimumLineHeight > 0 ? minimumLineHeight : self.font.pointSize * 1.14 attributes[.paragraphStyle] = paragraphStyle mutAttrString.setAttributes(attributes, range: range) return mutAttrString } private func textOrigin(in rect: CGRect) -> CGPoint { let usedRect = layoutManager.usedRect(for: textContainer) heightCorrection = (rect.height - usedRect.height) / 2 let glyphOriginY = heightCorrection > 0 ? rect.origin.y + heightCorrection : rect.origin.y return CGPoint(x: rect.origin.x, y: glyphOriginY) } }