Skip to content

Instantly share code, notes, and snippets.

@rnystrom
Created May 29, 2018 01:16
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rnystrom/d9613cf099aad129fd0e086b99827736 to your computer and use it in GitHub Desktop.
Save rnystrom/d9613cf099aad129fd0e086b99827736 to your computer and use it in GitHub Desktop.
Interactive NSAttributedString link highlighting with TextKit
import UIKit
public extension CGSize {
func snapped(scale: CGFloat) -> CGSize {
var size = self
size.width = ceil(size.width * scale) / scale
size.height = ceil(size.height * scale) / scale
return size
}
func resized(inset: UIEdgeInsets) -> CGSize {
var size = self
size.width += inset.left + inset.right
size.height += inset.top + inset.bottom
return size
}
}
internal extension NSLayoutManager {
func size(textContainer: NSTextContainer, width: CGFloat, scale: CGFloat) -> CGSize {
textContainer.size = CGSize(width: width, height: 0)
let bounds = usedRect(for: textContainer)
return bounds.size.snapped(scale: scale)
}
func render(
size: CGSize,
textContainer: NSTextContainer,
scale: CGFloat,
backgroundColor: UIColor? = nil
) -> CGImage? {
textContainer.size = size
UIGraphicsBeginImageContextWithOptions(size, backgroundColor != nil, scale)
defer { UIGraphicsEndImageContext() }
if let backgroundColor = backgroundColor {
backgroundColor.setFill()
UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
}
let range = glyphRange(for: textContainer)
drawBackground(forGlyphRange: range, at: .zero)
drawGlyphs(forGlyphRange: range, at: .zero)
return UIGraphicsGetImageFromCurrentImageContext()?.cgImage
}
}
class TextKitView: UIView {
let layoutManager: NSLayoutManager
let textContainer: NSTextContainer
let storage: NSTextStorage
init(text: NSAttributedString) {
textContainer = NSTextContainer()
textContainer.exclusionPaths = []
textContainer.maximumNumberOfLines = 0
textContainer.lineFragmentPadding = 0
layoutManager = NSLayoutManager()
layoutManager.allowsNonContiguousLayout = false
layoutManager.hyphenationFactor = 0
layoutManager.showsInvisibleCharacters = false
layoutManager.showsControlCharacters = false
layoutManager.usesFontLeading = true
layoutManager.addTextContainer(textContainer)
storage = NSTextStorage(attributedString: text)
storage.addLayoutManager(layoutManager)
super.init(frame: .zero)
layer.contentsGravity = kCAGravityTopLeft
layer.contentsScale = UIScreen.main.scale
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func fitAndDraw(width: CGFloat) {
let scale = UIScreen.main.scale
let size = layoutManager.size(textContainer: textContainer, width: width, scale: scale)
layer.contents = layoutManager.render(size: size, textContainer: textContainer, scale: scale)
frame = CGRect(origin: frame.origin, size: size)
}
public func attributes(at point: CGPoint) -> (attrs: [NSAttributedStringKey: Any], index: Int)? {
var fractionDistance: CGFloat = 1.0
let index = layoutManager.characterIndex(
for: point,
in: textContainer,
fractionOfDistanceBetweenInsertionPoints: &fractionDistance
)
if index != NSNotFound,
fractionDistance < 1.0,
let attrs = layoutManager.textStorage?.attributes(at: index, effectiveRange: nil) {
return (attrs, index)
}
return nil
}
}
class ViewController : UIViewController {
let highlightLayer = CAShapeLayer()
let textView: TextKitView = {
let text = NSMutableAttributedString(string: "This is some text with a ", attributes: [
.font: UIFont.systemFont(ofSize: 18),
.foregroundColor: UIColor.black,
])
text.append(NSMutableAttributedString(string: "link that spans multiple lines and opens", attributes: [
.font: UIFont.boldSystemFont(ofSize: 18),
.foregroundColor: UIColor.black,
.link: URL(string: "http://google.com")!
]))
text.append(NSMutableAttributedString(string: " to some website.", attributes: [
.font: UIFont.systemFont(ofSize: 18),
.foregroundColor: UIColor.black,
]))
let view = TextKitView(text: text)
view.layer.borderColor = UIColor.red.withAlphaComponent(0.2).cgColor
view.layer.borderWidth = 1
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
view.addSubview(textView)
let long = UILongPressGestureRecognizer(target: self, action: #selector(onTap(gesture:)))
long.minimumPressDuration = 0.1
textView.addGestureRecognizer(long)
highlightLayer.fillColor = UIColor.black.withAlphaComponent(0.3).cgColor
textView.layer.addSublayer(highlightLayer)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let width = view.bounds.width - 40
textView.frame = CGRect(x: 20, y: 100, width: width, height: 0)
textView.fitAndDraw(width: width)
}
@objc func onTap(gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began, .possible:
let point = gesture.location(in: textView)
guard let result = textView.attributes(at: point), result.attrs[.link] != nil else { return }
print("tapped link")
let maxLen = textView.storage.length
var min = result.index
var max = result.index
textView.storage.enumerateAttributes(in: NSRange(location: 0, length: result.index), options: .reverse) { (attrs, range, stop) in
if attrs[.link] != nil && min > 0 {
min = range.location
} else {
stop.pointee = true
}
}
textView.storage.enumerateAttributes(in: NSRange(location: result.index, length: maxLen - result.index), options: []) { (attrs, range, stop) in
if attrs[.link] != nil && max < maxLen {
max = range.location + range.length
} else {
stop.pointee = true
}
}
print("tapped \(result.index) with range from \(min) to \(max) with len \(max - min)")
let range = NSRange(location: min, length: max - min)
let path = UIBezierPath()
textView.layoutManager.enumerateEnclosingRects(forGlyphRange: range, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textView.textContainer) { (rect, stop) in
print("rect: \(rect)")
path.append(UIBezierPath(roundedRect: rect.insetBy(dx: -2, dy: -2), cornerRadius: 4))
}
highlightLayer.path = path.cgPath
case .changed: break
case .cancelled, .ended, .failed:
highlightLayer.path = nil
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment