isak.me - a blog by isak solheim

Bi-weekly updates about things that interest me, things I have built and things I have learned.

SwiftUI Text with Clickable Links

In this article, I will show you how to create a custom SwiftUI component TextWithClickableLinks, that finds and makes any links clickable inside the given text input.

Note: This implementation uses AttributedString, which requires iOS15+

Detecting URLs

In a helper file DetectURLs.swift, create a function detectURLs with the following code:

import SwiftUI

/// Detects URLs in the given text and splits it into parts.
func detectURLs(in text: String) -> [(String, URL?)] {
    let types: NSTextCheckingResult.CheckingType = .link
    let detector = try? NSDataDetector(types: types.rawValue)

    let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) ?? []

    var results: [(String, URL?)] = []
    var lastIndex = text.startIndex

    for match in matches {
        if let range = Range(match.range, in: text) {
            if range.lowerBound > lastIndex {
                let nonLinkText = String(text[lastIndex..<range.lowerBound])
                results.append((nonLinkText, nil))
            }
            let linkText = String(text[range])
            results.append((linkText, match.url))
            lastIndex = range.upperBound
        }
    }

    if lastIndex < text.endIndex {
        let remainingText = String(text[lastIndex..<text.endIndex])
        results.append((remainingText, nil))
    }

    return results
}

This function will detect any URLs in the text input and returns a list containing the original string, split up in parts:

let input = "Visit isak.me for more posts on SwiftUI!"
let output = [("Visit ", nil), ("isak.me", Optional("http://isak.me")), (" for more posts on SwiftUI!", nil)]

To create a view rendering the text with stylized and clickable links, we utilize AttributedString. AttributedString is a value type for a string with associated attributes for portions of its text. We create a computed variable attributedString, which uses our previously created detectURLs function, and loops through the result, giving appropriate attribute to any links found along the way.

private var attributedString: AttributedString {
    var attributedString = AttributedString(text)
    let parts = detectURLs(in: text)

    for part in parts {
        if let url = part.1, let range = attributedString.range(of: part.0) {
            attributedString[range].link = url
            attributedString[range].foregroundColor = .blue
            attributedString[range].underlineStyle = .single
        }
    }

    return attributedString
}

This attributedString variable can be rendered in a normal Text view.

Text(attributedString)

Usage

I’ve added maxFrameWidth and padding to the component to allow for more more flexible styling of the view.

The entire component ends up looking like the following:

import SwiftUI

/// A view that displays text with clickable links.
/// Clickable links are only supported on iOS 15 and later.
struct TextWithClickableLinks: View {
    let text: String
    var customFont: Font
    var multilineTextAlignment: TextAlignment
    var maxFrameWidth: CGFloat?
    var padding: CGFloat

    var body: some View {
        Text(attributedString)
            .multilineTextAlignment(multilineTextAlignment)
            .frame(maxWidth: maxFrameWidth)
            .padding(padding)
    }

    private var attributedString: AttributedString {
        var attributedString = AttributedString(text)
        let parts = detectURLs(in: text)

        for part in parts {
            if let url = part.1, let range = attributedString.range(of: part.0) {
                attributedString[range].link = url
                attributedString[range].foregroundColor = .blue
                attributedString[range].underlineStyle = .single
            }
        }

        return attributedString
    }
}

#Preview {
    TextWithClickableLinks(
        text: "Visit isak.me/blog for more posts on SwiftUI!",
        multilineTextAlignment: .center,
        maxFrameWidth: 300,
        padding: 16
    )
}