swifttextswiftuihyperlinkunderline

SwiftUI Text - how can I create a hyperlink and underline a weblink in a string


In SwiftUI, if I have a weblink in between a string, how can I create a hyperlink and have it underlined in my view.

Note, the "messageContent" string won't always be the same.

For example

struct MessageModel {
    var messageContent: String = "Test of hyperlink www.google.co.uk within a text message"
}


struct Content: View {
    
    var message: MessageModel
        
    var body: some View {
        
        VStack {
            Text(message.messageContent)
        }
        
    }
}

An image of what I want to achieve is shown in this image. "www.google.co.uk" is a tappable hyperlink and is underlined

Example of what I want to achieve



UPDATE ON WHAT I"M TRYING TO ACHIVE

I've created the below piece of test code to show what I'm trying to achieve, because as stated above, "messageContent" won't always be the same string.

Whilst the below is not perfect to deal with all cases and handle errors, etc.., this hopefully a better idea of what I'm trying to achieve. Only trouble is this doesn't seem to work.

It produces the underline for the hyperlink, but text doesn't show in Markdown format - see image attached.

Result

import SwiftUI

struct HyperlinkAndUnderlineText: View {
    
    var message: MessagesModel = MessagesModel(messageContent: "Test of hyperlink www.google.co.uk within a text message")
    
    @State var messageContentAfterSplitting: [SplitMessage] = []
    
    var body: some View {
            
            CustomText(inputText: messageContentAfterSplitting)
        
        .onAppear() {
            messageContentAfterSplitting = splitMessage(message: message)
        }
    }
}





struct MessagesModel {
    var messageContent: String = ""
}


struct SplitMessage {
    var content: String = ""
    var type: contentType = .text
}

enum contentType {
    case text
    case url
}





func splitMessage(message: MessagesModel) -> [SplitMessage] {
    
    func detectIfMessageContainsUrl(message: String) -> [String]? {
        
        let urlDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
        let matches = urlDetector.matches(in: message, options: [], range: NSRange(location: 0, length: message.utf16.count))
        
        var urls: [String] = []
        
        for (index, match) in matches.enumerated() {
            guard let range = Range(match.range, in: message) else { continue }
            let url = message[range]
            
            urls.append(String(url))
            
            if index == matches.count - 1 {
                return urls
            }
        }
        return []
        
    }
    
    let urlsFoundInMessage = detectIfMessageContainsUrl(message: message.messageContent)
    
    
    
    
    func getComponents(urlsFoundInMessage: [String]) -> [String] {
        
        var componentsEitherSideOfUrl: [String] = []
        
        for (index,url) in urlsFoundInMessage.enumerated() {
            componentsEitherSideOfUrl = message.messageContent.components(separatedBy: url)
            
            if index == urlsFoundInMessage.count - 1 {
                return componentsEitherSideOfUrl
            }
        }
        
        return []
    }
    
    let componentsEitherSideOfUrl = getComponents(urlsFoundInMessage: urlsFoundInMessage!)
    
    
    
    
    func markdown(urlsFoundInMessage: [String]) -> [String] {
        
        var markdownUrlsArray: [String] = []
        
        for (index, url) in urlsFoundInMessage.enumerated() {
            
            let placeholderText = "[\(url)]"
            
            var url2: String
            if url.hasPrefix("https://www.") {
                url2 = "(\(url.replacingOccurrences(of: "https://www.", with: "https://")))"
            } else if url.hasPrefix("www.") {
                url2 = "(\(url.replacingOccurrences(of: "www.", with: "https://")))"
            } else {
                url2 = "(\(url))"
            }
            
            let markdownUrl = placeholderText + url2
            
            markdownUrlsArray.append(markdownUrl)
            
            if index == urlsFoundInMessage.count - 1 {
                return markdownUrlsArray
            }
        }
        
        return []
        
    }
    
    let markdownUrls = markdown(urlsFoundInMessage: urlsFoundInMessage!)
    
    
    
    
    func recombineStrings(componentsEitherSideOfUrl: [String], markdownUrls: [String]) -> [SplitMessage] {
        
        var text = SplitMessage()
        var textAsArray: [SplitMessage] = []
        
        
        for i in 0...2 {
            if i.isMultiple(of: 2) {
                if i == 0 {
                    text.content = componentsEitherSideOfUrl[i]
                    text.type = .text
                    textAsArray.append(text)
                } else {
                    text.content = componentsEitherSideOfUrl[i-1]
                    text.type = .text
                    textAsArray.append(text)
                }
            } else {
                text.content = markdownUrls[i-1]
                text.type = .url
                textAsArray.append(text)
            }
        }
        
        return textAsArray
    }
    
    
    let recombinedStringArray = recombineStrings(componentsEitherSideOfUrl: componentsEitherSideOfUrl, markdownUrls: markdownUrls)
    
    return recombinedStringArray
    
}




func CustomText(inputText: [SplitMessage]) -> Text {
    
    var output = Text("")
    
    for input in inputText {
                
        let text: Text
        
        text = Text(input.content)
            .underline(input.type == .url ? true : false, color: .blue)
        
        output = output + text
        
    }
    
    return output
    
    
}

Solution

  • This was the final solution I used. Should work for a variety of string inputs.

    import SwiftUI
    
    
    struct HyperlinkAndUnderlineTextView: View {
        
        var body: some View {
            
            ScrollView {
                
                VStack (alignment: .leading, spacing: 30) {
                    
                    Group {
                        CustomTextWithHyperlinkAndUnderline("Test of a hyperlink www.google.co.uk within a text message", .blue)
                        CustomTextWithHyperlinkAndUnderline("www.google.co.uk hyperlink at the start of a text message", .blue)
                        CustomTextWithHyperlinkAndUnderline("Test of hyperlink at the end of a text message www.google.co.uk", .blue)
                        CustomTextWithHyperlinkAndUnderline("www.google.co.uk", .blue)
                        CustomTextWithHyperlinkAndUnderline("This is 1 hyperlink www.google.co.uk.  This is a 2nd hyperlink www.apple.com", .blue)
                        CustomTextWithHyperlinkAndUnderline("This is 1 hyperlink www.google.co.uk.  This is a 2nd hyperlink www.apple.com.  This is text after it.", .blue)
                        CustomTextWithHyperlinkAndUnderline("This is 1 hyperlink www.google.co.uk.  This is a 2nd hyperlink www.apple.com.  This is a 3rd hyperlink www.microsoft.com",  .blue)
                        CustomTextWithHyperlinkAndUnderline("This is 1 hyperlink www.google.co.uk.  This is a 2nd hyperlink www.apple.com.  This is a 3rd hyperlink www.microsoft.com.  This is text after it.", .blue)
                        CustomTextWithHyperlinkAndUnderline("www.google.co.uk is a hyperlink at the start of a text message.  www.apple.com is the 2nd hyperlink within the same text message.", .blue)
                        CustomTextWithHyperlinkAndUnderline("This is a test of another type of url which will get processed google.co.uk", .blue)
                    }
                    
                    Group {
                        CustomTextWithHyperlinkAndUnderline("google.co.uk", .blue)
                        CustomTextWithHyperlinkAndUnderline("Pure text with no hyperlink", .blue)
                        CustomTextWithHyperlinkAndUnderline("Emoji test 🙂", .blue)
                    }
                }
            }
        }
    }
    
    
    
    struct SplitMessageContentWithType {
        var content: String = ""
        var type: contentType = .text
    }
    
    enum contentType {
        case text
        case url
    }
    
    
    
    //Function to produce a text view where all urls and clickable and underlined
    func CustomTextWithHyperlinkAndUnderline(_ inputString: String, _ underlineColor: Color) -> Text {
        
        let inputText: [SplitMessageContentWithType] = splitMessage(inputString)
        
    
        var output = Text("")
        
        for input in inputText {
            let text: Text
            
            text = Text(.init(input.content))
                .underline(input.type == .url ? true : false, color: underlineColor)
            
            output = output + text
        }
        
        return output
        
    }
    
    
    
    func splitMessage(_ inputString: String) -> [SplitMessageContentWithType] {
        
        
        //1) Function to detect if the input string contains any urls and returns the ones found as an array of strings
        func detectIfInputStringContainsUrl(inputString: String) -> [String] {
            
            let urlDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
            let matches = urlDetector.matches(in: inputString, options: [], range: NSRange(location: 0, length: inputString.utf16.count))
            
            var urls: [String] = []
            
            for match in matches {
                guard let range = Range(match.range, in: inputString) else { continue }
                let url = inputString[range]
                
                urls.append(String(url))
            }
            return urls
            
        }
        let urlsFoundInInputString = detectIfInputStringContainsUrl(inputString: inputString)
        print("\n \nurlsFoundInInputString are: \(urlsFoundInInputString)")
        
        
        
        //2) Function to get the string components either side of a url from the inputString.  Returns these components as an array of strings
        func getStringComponentsSurroundingUrls(urlsFoundInInputString: [String]) -> [String] {
            
            var stringComponentsSurroundingUrls: [String] = []
            
            for (index, url) in urlsFoundInInputString.enumerated() {
                
                let splitInputString = inputString.components(separatedBy: url)
                
                //This code handles the case of an input string with 2 hyperlinks inside it (e.g. This is 1 hyperlink www.google.co.uk.  This is a 2nd hyperlink www.apple.com.  This is text after it.)
                //In the 1st pass of the for loop, this will return splitInputString = ["This is 1 hyperlink ", ".  This is a 2nd hyperlink www.apple.com.  This is text after it."]
                //Because the last element in the array contains either "www" or "http", we only append the contents of the first (prefix(1)) to stringComponentsSurroundingUrls (i.e "This is 1 hyperlink ")
                //In the 2nd pass of the for loop, this will return splitInputString = ["This is 1 hyperlink www.google.co.uk.  This is a 2nd hyperlink ", ".  This is text after it."]
                //Beacuse the last element in the array does not contain a hyperlink, we append both elements to stringComponentsSurroundingUrls
                if splitInputString.last!.contains("www") || splitInputString.last!.contains("http") {
                    stringComponentsSurroundingUrls.append(contentsOf: inputString.components(separatedBy: url).prefix(1))
                } else {
                    stringComponentsSurroundingUrls.append(contentsOf: inputString.components(separatedBy: url))
                }
                
                
                //At this point in the code, in the above example, stringComponentsSurroundingUrls = ["This is 1 hyperlink ",
                //                                                                                    "This is 1 hyperlink www.google.co.uk.  This is a 2nd hyperlink ",
                //                                                                                    ".  This is text after it."]
                //We now iterate through this array of string, to complete another split.  This time we separate out by any elements by urlsFoundInInputString[index-1]
                //At the end of this for loop, stringComponentsSurroundingUrls = ["This is 1 hyperlink ",
                //                                                                ".  This is a 2nd hyperlink ",
                //                                                                ".  This is text after it."]
                if index == urlsFoundInInputString.count - 1 {
                    for (index, stringComponent) in stringComponentsSurroundingUrls.enumerated() {
                        if index != 0 {
                            let stringComponentFurtherSeparated = stringComponent.components(separatedBy: urlsFoundInInputString[index-1])
                            stringComponentsSurroundingUrls.remove(at: index)
                            stringComponentsSurroundingUrls.insert(stringComponentFurtherSeparated.last!, at: index)
                        }
                    }
                }
            }
            
            return stringComponentsSurroundingUrls
        }
        
        var stringComponentsSurroundingUrls: [String]
        //If there no no urls found in the inputString, simply set stringComponentsSurroundingUrls equal to the input string as an array, else call the function to find the string comoponents surrounding the Urls found
        if urlsFoundInInputString == [] {
            stringComponentsSurroundingUrls = [inputString]
        } else {
            stringComponentsSurroundingUrls = getStringComponentsSurroundingUrls(urlsFoundInInputString: urlsFoundInInputString)
        }
        print("\n \nstringComponentsSurroundingUrls are: \(stringComponentsSurroundingUrls)")
        
        
        
        
        //3)Function to markdown the urls found to follow a format of [placeholderText](hyperlink) such as [Google](https://google.com) so SwiftUI markdown can render it as a hyperlink
        func markdown(urlsFoundInInputString: [String]) -> [String] {
            
            var markdownUrlsArray: [String] = []
            
            for url in urlsFoundInInputString {
                let placeholderText = "[\(url)]"
                
                var hyperlink: String
                if url.hasPrefix("https://www.") {
                    hyperlink = "(\(url.replacingOccurrences(of: "https://www.", with: "https://")))"
                } else if url.hasPrefix("www.") {
                    hyperlink = "(\(url.replacingOccurrences(of: "www.", with: "https://")))"
                } else {
                    hyperlink = "(http://\(url))"
                }
                
                let markdownUrl = placeholderText + hyperlink
                
                markdownUrlsArray.append(markdownUrl)
            }
            
            return markdownUrlsArray
            
        }
        
        let markdownUrls = markdown(urlsFoundInInputString: urlsFoundInInputString)
        print("\n \nmarkdownUrls is: \(markdownUrls)")
        
        
        
        //4) Function to combine stringComponentsSurroundingUrls and markdownUrls back together
        func recombineStringComponentsAndMarkdownUrls(stringComponentsSurroundingUrls: [String], markdownUrls: [String]) -> [SplitMessageContentWithType] {
            
            var text = SplitMessageContentWithType()
            var text2 = SplitMessageContentWithType()
            var splitMessageContentWithTypeAsArray: [SplitMessageContentWithType] = []
            
            //Saves each string component and url as either .text or .url type so in the CustomTextWithHyperlinkAndUnderline() function, we can underline all .url types
            for (index, stringComponents) in stringComponentsSurroundingUrls.enumerated() {
                text.content = stringComponents
                text.type = .text
                splitMessageContentWithTypeAsArray.append(text)
                
                if index <= (markdownUrls.count - 1) {
                    text2.content = markdownUrls[index]
                    text2.type = .url
                    splitMessageContentWithTypeAsArray.append(text2)
                }
            }
            
            return splitMessageContentWithTypeAsArray
        }
        
        
        let recombineStringComponentsAndMarkdownUrls = recombineStringComponentsAndMarkdownUrls(stringComponentsSurroundingUrls: stringComponentsSurroundingUrls, markdownUrls: markdownUrls)
        print("\n \nrecombineStringComponentsAndMarkdownUrls is: \(recombineStringComponentsAndMarkdownUrls)")
        
        return recombineStringComponentsAndMarkdownUrls
        
    }