My SwiftUI Pain: Creating Tappable Links in Text
By: Emma K Alexandra
Tags: Mobile, Xcode, Swift, Programming, Swiftui

My SwiftUI Pain: Creating Tappable Links in Text

My solution and how you can do it too

Image credit: Author

So you think SwiftUI is cool, and you want to write your new app in it? And you know what? SwiftUI is cool. It can do all kinds of fancy things that’d be a pain in UIKit or AppKit. It’s all fun and good until you hit one of the many sharp corners laying around everywhere. Here’s the sharp corner I ran into recently:

Links in text. You know, like the ones I gratuitously added in the paragraph above. Links are ubiquitous on the web and in lots of apps too. They’re certainly critical to my current project. So let’s say you want to add some text to your app, and that text has some links within it. How do you go about doing that in SwiftUI?

Well, luckily Text and Link exist — though Link was introduced in 2020 with iOS 14/mac OS 11, etc., so it might not have the compatibility you’re looking for. Anyway, let’s take some text and break it down into these views.

Simple enough. Let’s write a View to display this text using Text and Link .

Cool, let’s see how that looks.

Attempt #1: Not quite what we want

Not quite what we want. I’d love for this text to flow naturally, as if it were one text block. Let’s try the naive thing and place it in an HStack.

And the result:

Attempt #2 It’s bad

Terrible. My text is too wide to layout on one line in an HStack, so SwiftUI tries to split the space into thirds, vertically, to try and fit my text. It doesn’t really work out.

Emma, you say, “Text views support using + to concatenate multiple Text views together!” They sure do — let’s try that.

Attempt #3 is a no go

Unfortunately, you can’t use + to concatenate a Link view to a Text view. But maybe we’re onto something. Link , in the way we’re using it, is really just a fancy Text view, right? Let’s try converting it into a Text view and adding an onTapGesture to open the URL ourselves.

Attempt #4: Cleverness punished

Once again, we’re thwarted. onTapGesture is only defined on View , so it outputs some View as its return type, not Text . So we can’t concatenate Text views when that View modifier is applied.

These are really the solutions I’d expect to work in SwiftUI, and from here I truly believe all of the reasonable options are exhausted. You could experiment around with other container types, like grids or whatever, but all of them have the same basic flaw: They aren’t designed for displaying text.

So what do we do now? Well, I think any sane person would look at UITextView or NSTextView and think, “Hey, these are robust, tested solutions to this problem” and just use a ViewRepresentable to drop down into UIKit and AppKit. That’s the solution of the sane and rational. I’m not that.

I want a pure SwiftUI solution to this problem, and, amazingly, there is one. But you’re not going to like it. It’s ugly, error-prone, and incredibly slow. And that’s pretty much my experience whenever I drop into unknown SwiftUI waters. So let’s take a look.

My first step to tackling this problem is breaking down the content a bit. Instead of having long Text views containing sentences or paragraphs, I want to have each Text view be just a word. This will allow us to have a lot of control over how much text we lay out at a time. So I created a View to break the Text down into single words:

In ContentText , a given string is broken into words by spaces (you may want to use a whitespace separator here, but for my content, spaces work just fine). Then the ForEach view is used to generate a Text view for each word.

Importantly, ContentText has a count property that allows other View s to know how many Text views are going to be output by the ForEach . This is important because when applying View modifiers to a ForEach , the View modifier is called for each View within the ForEach . That’ll be important later.

Next, I want this solution to be fairly general, so I defined an enum that can either be text or link , with the appropriate View as associated data. Then I grabbed some new text to work with from Apple’s developer site:

Alrighty, now we can jump into laying out our Text and Link views. We’ll need a View that can calculate its own height, a GeometryReader , and a ZStack .

Within the body of ContentView , the height of the ZStack (which will contain all of our content) is calculated and output to the VStack , which will properly set its frame to the height of the ZStack .

Since ContentView can lay out any text, the height is variable, so we need to calculate it to keep things around our content laying out correctly. If you’re curious about how this height calculation works Swift with Majid has a great explainer.

Now we need to actually lay out the views in our ZStack . That code looks like this:

Whoa! Let’s break it down. First, we add the zStackViews(_:) method call to our ZStack and define zStackViews(_:) . Within zStackViews , we keep track of the current horizontal and vertical positions on line 19. Then, we return a ForEach view, with the content generated by the forEachView(_:) function. forEachView generates the layout for a single ContentText or Link view.

Here within forEachView , we determine how many View s need to be rendered by us.

This is where the count property on ContentText comes in handy. Since ContentText ’s body is actually a ForEach , we’ll need to render multiple views for every ContentText — that’s line 7.

Links are just one view, so we set numberOfViewsInContent to 1 on line 10. We also extract the actual View from our Content in lines 8 and 11, and set our currently rendered number of View s to 0 .

Finally, forEachView returns our View with some alignmentGuide view modifiers. Alignment guides are used within the stack family of View s to custom align items within the stack. Here we’re using a ZStack with topLeading alignment so we have the freedom to adjust both the top and leading alignments manually.

The computeValue function of the alignment guide is returning an offset in points from the top or leading edge.

On line 29, we start setting the leading (horizontal) alignment. If the View we’re currently iterating over will fit on the current line, line 43 decreases the current horizontal offset.

If the View doesn’t fit on the current line (meaning the current offset + the width of the view is larger than the width of our ZStack , as in line 32), we return 0 as our horizontal offset (the start of a new line). Set the horizontal offset to the width of the current View , and decrease the vertical offset by the height of the View , as in lines 35-37.

That’s actually all of the offset modification we need for regular use. The alignment guide on line 47 only modifies the horizontal or vertical offset if we’ve reached the last of our View s. Since View s can be drawn multiple times, we need to clean up our offsets and prepare for another draw.

To put it all together, here’s the final code:

This solution is pure SwiftUI and supports dynamic type, as you’d expect.

Yes, there’s lots of hacking to get to this solution, and manually modifying text tends to be error-prone. You’ll also have some trouble applying view modifiers to this text. It’s possible, but not in the usual manner.

I might do another write-up that includes view modifiers, but for now, this is the only way I know of to combine Text and Link s together into a single paragraph. Using this method, you can actually add any View you want into the paragraph. I use this technique to include images, buttons, and all kinds of other types of text you might want to include in a paragraph.

Once again, I really think UIKit or AppKit would better suit just about any project when encountering this problem, at least for now. But if you’re like me and live that SwiftUI life, here you go!