Skip to content

Instantly share code, notes, and snippets.

@mjm
Created May 21, 2020 03:56
Show Gist options
  • Star 41 You must be signed in to star a gist
  • Fork 7 You must be signed in to fork a gist
  • 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
}
}
@tomichj
Copy link

tomichj commented Sep 14, 2020

Thanks for this, it was very helpful.

@adkuba
Copy link

adkuba commented Nov 22, 2020

Thank you!
I noticed that when text changes, you can't tap on newly generated links, because overlay is not refreshing.
I made small change to the code, it works but be aware that this is probably not an ideal solution!

struct LinkedText: View {
    let text: String
    let links: [NSTextCheckingResult]
    
    @State var displayOverlay = true
    
    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(displayOverlay ? LinkTapOverlay(text: text, links: links) : nil)
            .onChange(of: links, perform: { value in
                self.displayOverlay = false
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    self.displayOverlay = true
                }
            })
    }
}

@metsfan
Copy link

metsfan commented Dec 18, 2020

This is genius :) I was wondering today if there's any way this could be possible. Very well done!

@mjm
Copy link
Author

mjm commented Dec 18, 2020

@adkuba That's strange, based on what I know of how this stuff works (which could be wrong/incomplete) the overlay should be updating when the text changes. But I can't really say for sure: in the app where I'm using this, I'm not sure I would trigger that behavior, I would likely end up re-rendering things higher up in the hierarchy in my particular case.

@metsfan I did try a lot of other ways to do this before finally getting here. And it's quite hacky, with many limitations. I'd very much like it if Apple added a way to do this to SwiftUI itself.

@metsfan
Copy link

metsfan commented Dec 18, 2020

I think eventually they will add attributed string support to SwiftUI but its just not top priority I guess.

@filipkrzyz
Copy link

How do you use it? Let's say I want to have a Text, where things indicated by brackets are coloured tappable link.
"I accept the [terms and conditions] and [privacy policy]"

@mjm
Copy link
Author

mjm commented May 22, 2021

How do you use it? Let's say I want to have a Text, where things indicated by brackets are coloured tappable link.
"I accept the [terms and conditions] and [privacy policy]"

@filipkrzyz This won't work as-is for that case. This is meant for taking a string that has URLs in it and making those URLs become tappable links. It uses NSDataDetector to find the locations of the URLs in the string. You could adapt this in some way to let you annotate where the links are and what URLs they go to, but that's not currently what this does.

@hyouuu
Copy link

hyouuu commented Aug 2, 2021

Thank you this is great! One issue I found is that I was using .background(FrameGetter()) to get Text's size, but using LinkedText the height was wrong, but I don't have enough knowledge to find out the cause - could you help? The FrameGetter is this:

struct FrameGetter: View {
    var color: Color = .clear
    var body: some View {
        GeometryReader { geometry in
            Rectangle()
                .fill(color)
                .preference(key: FramePrefKey.self, value: geometry.frame(in: .local))
        }
    }
}

@sandeep-maganti
Copy link

@mjm Can you improve the code adding long press callback as well to this component?

@coreyd303
Copy link

coreyd303 commented Jul 27, 2022

FWIW the AttributedString .init(markdown: initializer will handle this automagically now :)

struct LinkedText: View {

    private let stringWithAttributes: AttributedString

    init (_ text: String) {
        if let attrStr = try? AttributedString(markdown: text) {
            stringWithAttributes = attrStr
        } else {
            stringWithAttributes = AttributedString(text)
        }
    }

    var body: some View {
        Text(stringWithAttributes)
    }
}

@cshireman
Copy link

This works great as long as you don't change the font size. When using any size that's different from the default, the layout manager is reporting an incorrect index. Any idea how to account for this?

@coreyd303
Copy link

@cshireman were using this like...

LinkedText("something")
    .font(.noah(.regular, size: 25))

// and

LinkedText("something else")
    .font(.system(size: 14, weight: .semibold))

and they both work. Without more info I am not sure how to help...

@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