Make clickable a hashtag(#) preceding a word

88 Views Asked by At

In SwiftUI I want to modify the code below to detect a # preceding a word and add a highlight to it and make it clickable. The code below detects links inside of text blocks, changes their color to blue, and detects clicks to specifically the link only and not the rest of the text. For example, the modification should detect "#Channel1" inside the string "Hello Hello #Channel1 cool" and make it yellow and clickable.

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(.blue)
            }
        }.reduce(Text(""), +)
    }
}

struct LinkedText: View {
    @EnvironmentObject var popRoot: PopToRoot
    let text: String
    let istip: Bool
    let isMessage: Bool?
    let links: [NSTextCheckingResult]
    
    init (_ text: String, tip: Bool, isMess: Bool?) {
        self.text = text
        self.istip = tip
        self.isMessage = isMess
        let nsText = text as NSString
        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)
            .overlay(LinkTapOverlay(text: text, isTip: istip, isMessage: isMessage, links: links))
    }
}

private struct LinkTapOverlay: UIViewRepresentable {
    @EnvironmentObject var popRoot: PopToRoot
    @EnvironmentObject var viewModel: ExploreViewModel
    let text: String
    let isTip: Bool
    let isMessage: Bool?
    let links: [NSTextCheckingResult]
    
    func makeUIView(context: Context) -> LinkTapOverlayView {
        let view = LinkTapOverlayView(frame: .zero, text: text, overlay: self)
        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)

        let longPressGesture = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.didLongPressLabel(_:)))
        longPressGesture.delegate = context.coordinator
        view.addGestureRecognizer(longPressGesture)

        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)
        }
        
        @objc func didLongPressLabel(_ gesture: UILongPressGestureRecognizer) {
            if gesture.state == .began {
                //long press
            }
        }

        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 {
    private var overlay: LinkTapOverlay?
    var textContainer: NSTextContainer!
    var text: String?
    
    override init(frame: CGRect) {
        super.init(frame: frame)

        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPressLabel(_:)))
        addGestureRecognizer(longPressGesture)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    convenience init(frame: CGRect, text: String, overlay: LinkTapOverlay) {
        self.init(frame: frame)
        self.text = text
        self.overlay = overlay
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()

        var newSize = bounds.size
        newSize.height += 20
        textContainer.size = newSize
    }

    @objc func didLongPressLabel(_ gesture: UILongPressGestureRecognizer) {
        if gesture.state == .began {
            //long press
        }
    }
}
2

There are 2 best solutions below

3
MatBuompy On

I tried solving it and got close, you are surely better then me at managing Strings. What I did was to build a function to find hashtags in a String first:

func findHashtags(in text: String) -> [String] {
    do {
        let regex = try NSRegularExpression(pattern: "#\\w+", options: [])
        let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
        return matches.map {
            String(text[Range($0.range, in: text)!])
        }
    } catch {
        print("Error while parsing hashtags: \(error)")
        return []
    }
}

Then I modified the LinkColoredText init and body to make the hastags yellow and detect them:

struct LinkColoredText: View {
    enum Component {
        case text(String)
        case link(String, URL)
        case hashtag(String)
    }

    let text: String
    let components: [Component]

    init(text: String, links: [NSTextCheckingResult], hashtags: [String]) {
        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
        }
        
        /// You can do this much better, I'm not good at string
        for hashtag in hashtags {
            let range = nsText.range(of: hashtag)
            if range.location != NSNotFound {
                components.append(.hashtag(hashtag))
            }
        }
        
        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(.blue)
            case .hashtag(let text):
                return Text(verbatim: text)
                    .foregroundColor(.yellow) // Set color to yellow for hashtags
            }
        }.reduce(Text(""), +)
    }
}

Then I also added the hashtag array to LinkedText:

struct LinkedText: View {
    let text: String
    let istip: Bool
    let isMessage: Bool?
    let links: [NSTextCheckingResult]
    let hashtags: [String]
    
    init (_ text: String, tip: Bool, isMess: Bool?) {
            self.text = text
            self.istip = tip
            self.isMessage = isMess
            let nsText = text as NSString
            let wholeString = NSRange(location: 0, length: nsText.length)
            links = linkDetector.matches(in: text, options: [], range: wholeString)
            hashtags = findHashtags(in: text)
        }
    
    var body: some View {
        LinkColoredText(text: text, links: links, hashtags: hashtags)
            .overlay(LinkTapOverlay(text: text, isTip: istip, isMessage: isMessage, links: links, hashtags: hashtags))
    }
}

Added the tapGesture in LinkTapOverlay for hashtags:

let tapGestureHashtag = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.didTapHashtag(_:)))
    tapGestureHashtag.delegate = context.coordinator
    view.addGestureRecognizer(tapGestureHashtag)

And added these two functions to the Coordinator class of course:

@objc func didTapHashtag(_ gesture: UITapGestureRecognizer) {
            let location = gesture.location(in: gesture.view!)
            guard let result = hashtag(at: location) else {
                return
            }
            
            print("Tap on hastag")
        }

/// This does not work as expected unfortunately
        private func hashtag(at point: CGPoint) -> String? {
            guard !overlay.hashtags.isEmpty else {
                return nil
            }

            let indexOfCharacter = layoutManager.characterIndex(
                for: point,
                in: textContainer,
                fractionOfDistanceBetweenInsertionPoints: nil
            )

            for hashtag in overlay.hashtags {
                if let range = overlay.text.range(of: hashtag) {
                    let nsRange = NSRange(range, in: overlay.text)
                    if nsRange.contains(indexOfCharacter) {
                        return hashtag
                    }
                }
            }

            return nil
        }

The result is this:

Yellow hashtag

You don't need to tell me it is not exactly what you wanted but I just wanted to give it a try and help you. I'm pretty sure that figuring out how to handle the hashtags in the LinkColoredText init will solve most of the issues since it will no longer repeat hasthags in Texts.

1
Jaxon Steinhower On

I was able to build on MatBuompy's solution and get the code fully working. The following structure will identify links, make them blue, and detect taps on links and open their url. The code will also identify words preceded with hashtags, make them purple+bold, and detect clicks on them. The struct will also identify long press gestures and allow you to do a custom action. You can have as many links and hashtags as you want. This works for iOS 15+. The final result looks like:

photo

import SwiftUI

private let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)

struct LinkColoredText: View {
    @Environment(\.colorScheme) var colorScheme
    enum Component {
        case text(String)
        case link(String, URL)
        case hashtag(String, String)
    }

    let text: String
    let components: [Component]

    init(text: String, links: [NSTextCheckingResult], hashtags: [String]) {
        self.text = text
        let nsText = text as NSString

        var components: [Component] = []
        var index = 0
        for result in links {
            if result.range.location > index {
                
                let sub_str = nsText.substring(with: NSRange(location: index, length: result.range.location - index))
                
                if findHashtags(in: sub_str).isEmpty {
                    components.append(.text(nsText.substring(with: NSRange(location: index, length: result.range.location - index))))
                } else {
                    let words = extractWordsAndSpaces(from: sub_str)
                    var offset = 0
                    for word in words {
                        if !findHashtags(in: String(word)).isEmpty && containsWhiteSpace(at: offset - 1, in: sub_str) {
                            components.append(.hashtag(nsText.substring(with: NSRange(location: index + offset, length: word.count)), String(word)))
                        } else {
                            components.append(.text(nsText.substring(with: NSRange(location: index + offset, length: word.count))))
                        }
                        offset += word.count
                    }
                }
            }
            components.append(.link(nsText.substring(with: result.range), result.url!))
            index = result.range.location + result.range.length
        }
        if index < nsText.length {
            let sub_str = nsText.substring(from: index)
            var offset = 0
            
            if findHashtags(in: sub_str).isEmpty {
                components.append(.text(sub_str))
            } else {
                let words = extractWordsAndSpaces(from: sub_str)

                for word in words {
                    if !findHashtags(in: String(word)).isEmpty && containsWhiteSpace(at: offset - 1, in: sub_str){
                        components.append(.hashtag(nsText.substring(with: NSRange(location: index + offset, length: word.count)), String(word)))
                        
                    } else {
                        components.append(.text(nsText.substring(with: NSRange(location: index + offset, length: word.count))))
                    }
                    offset += word.count
                }
            }
        }
        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(.blue)
            case .hashtag(let text, _):
                return Text(verbatim: text)
                    .foregroundColor(.indigo).bold()
            }
        }.reduce(Text(""), +)
    }
}

struct LinkedText: View {
    let text: String
    let istip: Bool
    let isMessage: Bool?
    let links: [NSTextCheckingResult]
    let hashtags: [String]
    
    init (_ text: String, tip: Bool, isMess: Bool?) {
        self.text = text
        self.istip = tip
        self.isMessage = isMess
        let nsText = text as NSString
        let wholeString = NSRange(location: 0, length: nsText.length)
        links = linkDetector.matches(in: text, options: [], range: wholeString)
        hashtags = findHashtags(in: text)
    }
    
    var body: some View {
        LinkColoredText(text: text, links: links, hashtags: hashtags)
            .overlay(LinkTapOverlay(text: text, isTip: istip, isMessage: isMessage, links: links, hashtags: hashtags))
    }
}

private struct LinkTapOverlay: UIViewRepresentable {
    let text: String
    let isTip: Bool
    let isMessage: Bool?
    let links: [NSTextCheckingResult]
    let hashtags: [String]
    
    func makeUIView(context: Context) -> LinkTapOverlayView {
        let view = LinkTapOverlayView(frame: .zero, text: text, overlay: self)
        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)

        let longPressGesture = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.didLongPressLabel(_:)))
        longPressGesture.delegate = context.coordinator
        view.addGestureRecognizer(longPressGesture)

        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)
        }

        @objc func didTapLabel(_ gesture: UITapGestureRecognizer) {
            let location = gesture.location(in: gesture.view!)
            if let result = link(at: location) {
                guard let url = result.url else {
                    return
                }
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
            } else if let result = hashtag(at: location) {
                
            }
        }
        
        @objc func didLongPressLabel(_ gesture: UILongPressGestureRecognizer) {
            if gesture.state == .began {

            }
        }

        private func link(at point: CGPoint) -> NSTextCheckingResult? {
            guard !overlay.links.isEmpty else {
                return nil
            }
            let indexOfCharacter = layoutManager.characterIndex(
                for: point,
                in: textContainer,
                fractionOfDistanceBetweenInsertionPoints: nil
            )

            for i in 0..<overlay.links.count {
                if indexOfCharacter >= overlay.links[i].range.location && indexOfCharacter <= (overlay.links[i].range.location + overlay.links[i].range.length) {
                    return overlay.links[i]
                }
            }
            
            return nil
        }
        
        private func hashtag(at point: CGPoint) -> String? {
            guard !overlay.hashtags.isEmpty else {
                return nil
            }
            let indexOfCharacter = layoutManager.characterIndex(
                for: point,
                in: textContainer,
                fractionOfDistanceBetweenInsertionPoints: nil
            )
            let words = extractWordsAndSpaces(from: overlay.text)
            var offset = 0

            for i in 0..<words.count {
                if indexOfCharacter >= offset && indexOfCharacter <= (offset + words[i].count){
                    if !findHashtags(in: words[i]).isEmpty {
                        if i == 0 {
                            return words[i]
                        } else if words[i - 1].hasSuffix(" "){
                            return words[i]
                        }
                    }
                    return nil
                }
                offset += words[i].count
            }
            return nil
        }
    }
}

private class LinkTapOverlayView: UIView {
    private var overlay: LinkTapOverlay?
    var textContainer: NSTextContainer!
    var text: String?
    
    override init(frame: CGRect) {
        super.init(frame: frame)

        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPressLabel(_:)))
        addGestureRecognizer(longPressGesture)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    convenience init(frame: CGRect, text: String, overlay: LinkTapOverlay) {
        self.init(frame: frame)
        self.text = text
        self.overlay = overlay
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()

        var newSize = bounds.size
        newSize.height += 20
        textContainer.size = newSize
    }

    @objc func didLongPressLabel(_ gesture: UILongPressGestureRecognizer) {
        if gesture.state == .began {

        }
    }
}

func findHashtags(in text: String) -> [String] {
   do {
       let regex = try NSRegularExpression(pattern: "#\\w+", options: [])
       let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
       return matches.map {
           String(text[Range($0.range, in: text)!])
       }
   } catch {
       return []
   }
}

func extractWordsAndSpaces(from sentence: String) -> [String] {
    let pattern = try! NSRegularExpression(pattern: "\\S+|\\s+", options: [])

    let matches = pattern.matches(in: sentence, options: [], range: NSRange(location: 0, length: sentence.utf16.count))

    let resultArray = matches.map { (match) -> String in
        let range = Range(match.range, in: sentence)!
        return String(sentence[range])
    }
    return resultArray
}

func containsWhiteSpace(at index: Int, in text: String) -> Bool {
    if index == -1 {
        return true
    }
    guard index >= 0 && index < text.count else {
        return false
    }

    let charAtIndex = text[text.index(text.startIndex, offsetBy: index)]
    return charAtIndex.isWhitespace
}