Skip to content

Instantly share code, notes, and snippets.

@mjm
Created May 21, 2020 03:56
Show Gist options
  • Save mjm/0581781f85db45b05e8e2c5c33696f88 to your computer and use it in GitHub Desktop.
Save mjm/0581781f85db45b05e8e2c5c33696f88 to your computer and use it in GitHub Desktop.
Tappable links in SwiftUI Text view
import SwiftUI
private let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
struct LinkColoredText: View {
enum Component {
case text(String)
case link(String, URL)
}
let text: String
let components: [Component]
init(text: String, links: [NSTextCheckingResult]) {
self.text = text
let nsText = text as NSString
var components: [Component] = []
var index = 0
for result in links {
if result.range.location > index {
components.append(.text(nsText.substring(with: NSRange(location: index, length: result.range.location - index))))
}
components.append(.link(nsText.substring(with: result.range), result.url!))
index = result.range.location + result.range.length
}
if index < nsText.length {
components.append(.text(nsText.substring(from: index)))
}
self.components = components
}
var body: some View {
components.map { component in
switch component {
case .text(let text):
return Text(verbatim: text)
case .link(let text, _):
return Text(verbatim: text)
.foregroundColor(.accentColor)
}
}.reduce(Text(""), +)
}
}
struct LinkedText: View {
let text: String
let links: [NSTextCheckingResult]
init (_ text: String) {
self.text = text
let nsText = text as NSString
// find the ranges of the string that have URLs
let wholeString = NSRange(location: 0, length: nsText.length)
links = linkDetector.matches(in: text, options: [], range: wholeString)
}
var body: some View {
LinkColoredText(text: text, links: links)
.font(.body) // enforce here because the link tapping won't be right if it's different
.overlay(LinkTapOverlay(text: text, links: links))
}
}
private struct LinkTapOverlay: UIViewRepresentable {
let text: String
let links: [NSTextCheckingResult]
func makeUIView(context: Context) -> LinkTapOverlayView {
let view = LinkTapOverlayView()
view.textContainer = context.coordinator.textContainer
view.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.didTapLabel(_:)))
tapGesture.delegate = context.coordinator
view.addGestureRecognizer(tapGesture)
return view
}
func updateUIView(_ uiView: LinkTapOverlayView, context: Context) {
let attributedString = NSAttributedString(string: text, attributes: [.font: UIFont.preferredFont(forTextStyle: .body)])
context.coordinator.textStorage = NSTextStorage(attributedString: attributedString)
context.coordinator.textStorage!.addLayoutManager(context.coordinator.layoutManager)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIGestureRecognizerDelegate {
let overlay: LinkTapOverlay
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: .zero)
var textStorage: NSTextStorage?
init(_ overlay: LinkTapOverlay) {
self.overlay = overlay
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = .byWordWrapping
textContainer.maximumNumberOfLines = 0
layoutManager.addTextContainer(textContainer)
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
let location = touch.location(in: gestureRecognizer.view!)
let result = link(at: location)
return result != nil
}
@objc func didTapLabel(_ gesture: UITapGestureRecognizer) {
let location = gesture.location(in: gesture.view!)
guard let result = link(at: location) else {
return
}
guard let url = result.url else {
return
}
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
private func link(at point: CGPoint) -> NSTextCheckingResult? {
guard !overlay.links.isEmpty else {
return nil
}
let indexOfCharacter = layoutManager.characterIndex(
for: point,
in: textContainer,
fractionOfDistanceBetweenInsertionPoints: nil
)
return overlay.links.first { $0.range.contains(indexOfCharacter) }
}
}
}
private class LinkTapOverlayView: UIView {
var textContainer: NSTextContainer!
override func layoutSubviews() {
super.layoutSubviews()
var newSize = bounds.size
newSize.height += 20 // need some extra space here to actually get the last line
textContainer.size = newSize
}
}
@arbyruns
Copy link

arbyruns commented Jun 13, 2023

I did discover that if you're using .textSelection(.enabled) modifier then this may not work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment