Tusker/Tusker/Views/StatusContentLabel.swift

287 lines
10 KiB
Swift

//
// 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<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesBegan(touches, with: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesMoved(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesCancelled(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, 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)
}
}