-
-
Save mjm/0581781f85db45b05e8e2c5c33696f88 to your computer and use it in GitHub Desktop.
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 | |
} | |
} |
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))
}
}
}
@mjm Can you improve the code adding long press callback as well to this component?
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)
}
}
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?
@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...
I did discover that if you're using .textSelection(.enabled)
modifier then this may not work.
@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.