Created
May 29, 2018 01:16
-
-
Save rnystrom/d9613cf099aad129fd0e086b99827736 to your computer and use it in GitHub Desktop.
Interactive NSAttributedString link highlighting with TextKit
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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