Some time ago, I got a very specific task: to create UILabel that handles basic HTML tags <b>
, <i>
, <u>
, <a>
, etc. The links should have normal and active states. It must work in both frameworks: UIKit and SwiftUI in the same way. The result should look like this:
GitHub:
In this article, I’d like to describe step-by-step how to make this feature, so bear with me and enjoy the process :). But before diving into coding let’s describe the task in detail:
<a>
, <b>
, <i>
with links.textColor
, numberOfLines
, fontSize
, textAlignment
, lineBreakMode
, etc. They should work as expected even with HTML.
Before, implementing this feature we need a plan. Let’s break it into four steps:
enumerateAttributes
to determine links in the attributed stringaddLink
methods
We are not going to reinvent the wheel, so let’s borrow a ready-to-use solution from the ancient
As you can see we cannot have such states as hover or visited but additionally, we added an inactive state. So, to make the links tappable with states we are going to create a model called LinkAttributes. All the states basically are described as
struct LinkAttributes {
var attributes: [NSAttributedString.Key: Any]
var activeAttributes: [NSAttributedString.Key: Any]
var inactiveAttributes: [NSAttributedString.Key: Any]
}
UILabel should show text with links. The text also is going to be NSAttributedString
. So let’s make another struct to cover all cases:
struct AttributedTextWithLink {
var text: String
var attributes: [NSAttributedString.Key: Any]
var link: String?
var linkAttributes: LinkAttributes?
}
As you can see, the attributes will be our styles for text.
Also, we’re going to have a special struct for HTML links. Let’s call it UniversalLabelLink with two properties: linkAttributes
(see below), and
struct UniversalLabelLink {
var linkAttributes: LinkAttributes
var textCheckingResult: NSTextCheckingResult
}
NSTextCheckingResult will perform a special service for our links. Looking ahead we going to use it for storing the links' string ranges and their URLs.
All these three models will help us to implement UILabel with tappable links and visual states. Let’s create the UILabel. If you remember the task, the label should have a tap delegate or closure for the links.
As you can see we prepared a basic configuration for the UILabel. So let’s take a look at the setup part:
In the setup part of UniversalLabel, we have a general function setupAttributes which just sets attributedText
for a specific range. Yes, it will be our links. This function will use our two other functions which will prepare our states: normal and active.
That was easy. Now it is going to be more interesting. We need to make links tappable. Somehow on tap on a particular link, it should be found in the text. Let’s take a look at the code snippet:
On tap gesture, the sender
Let’s take a look at the last guy and its apple documentation:
“An object that coordinates the layout and display of text characters”.
Eventually, we need an index of that tapped character in the string.
Using the
Since UILabel can have links in the text and identifies these links on tap we can start with HTML tags. Converting HTML strings to the text is pretty easy using, again NSAttributedString. Let’s take a look at this code snippet:
All we need is to make an attributed string from the string data with a specific option document type as HTML. It will allow us to use a cool method in an attributed string:
func enumerateAttributes(
in enumerationRange: NSRange,
options opts: NSAttributedString.EnumerationOptions = [],
using block: ([NSAttributedString.Key : Any], NSRange, UnsafeMutablePointer<ObjCBool>
) -> Void)
In the completion of this function, we need a couple of things: attributes and range. So we can identify each link in the text using the attributes option, like this:
if let link = attributes[.link], let url = link as? URL { ... }
At this point, we know almost everything about our HTML, so let’s add this property to our UniversalLabel.
The last step is to finalize our UIKit component. To combine and use all implementations which we have done above, we need to extend our label with several methods.
Concat is a general method that takes the TextWithLinks struct and builds an attributed string with links. All the pieces of strings have their own attributes. Additionally, this method stores each link in the array. It’s needed to get a particular link config after identifying the link from the text and then to use link attributes and URL on tap.
The other two methods are all about styling. I would say it is the most tricky part. Basically, all the links and other pieces of the string should be substituted with the attributed strings with new styles.
For example, the normal link color is configured from the outside, using textColor
property of UILabel. The link of active state (while pressing on the link) has the same textColor
but with alpha, or it also could be configured separately from the outside.
The function prepareAttributedTextWithLinks
identifies the link and normal text and adjusts the styles for them. From the textAttributes, we can catch underline style from HTML and set them to the text attributes as [.underlineStyle]
.
So it means UILabel with HTML will be with links and underlined text. But what about <b>
, <i>
HTML tags? You cannot catch them so easily from attributes.
It was one of the most surprising parts to handle italic and bold text from HTML. I thought it would be the same story as with [.underlineStyle]
but it is not. How to solve it?
To catch italic and bold styles we need to know about the font from NSAttributedString. UIFont itself has an interesting part called
To know about font-weight
we have even to dive deeper using the same UIFontDescriptor. The weight could be fetched from UIFont.Weight
using rawValue
as CGFloat.
As you can see in the implementation of prepareTextAttributes
we substitute the UIFont and eventually return the new paragraph style that we are using in our enumerateAttributes
block:
The UniversalLabel is done and we can use it in UIKit, but I got a task to use this label in SwiftUI as well which complicates some things.
Let’s start with an obvious part and create mutatingWrapper
which stores for us UILabel’s attributes like fontSize
, textAlignment
, numberOfLines
, etc. We’re going to use them from the SwiftUI view.
Also, this view has to have a set of methods to mutate mutatingWrapper:
Let’s prepare and test the HTML string with all tags which I got in the task:
let html = """
<h1>Message Title</h1>
<p>The message has <a href="http://maxkalik.com">HTML link</a>. The text supports <i>italic format</i> and <u>underlined style</u> and <b>bold text</b>. All this message could be wrapped in paragraph tag and can include <a href="http://apple.com">multiple links<a/>.</p>
"""
As you can see the HTML string has even <h1>
tag. I supposed it should be handled as well. But regarding SwiftUI implementation I thought the code could be like this, only a few lines:
I was so happy to see that it worked! All basic HTML tags work correctly and even the header. But wait, the links are not tappable! After some time I figured out why. Let’s take a look at the height of the Label view. The height is not the same as the text block height. So, we need somehow to know the height of the Label dynamically right from the UIViewRepresentable.
For this reason, we need this binding property called dynamicHeight
.
@Binding var dynamicHeight: CGFloat
It’s going to bind the height value from self with the height state in the SwfitUI View.
The result will be like this:
At the beginning of the article, I presented the result with an expandable message component which shows us how easy to use the label and how flexible it is. So the final SwiftUI implementation looks like this:
I’m not going to go through all this code line by line. I just would like to highlight one important thing. Now it is clear how the height state bound from the Label works in this situation. We even don’t need to have something like onPreferenceChange
with ViewSizePreferenceKey
to read the height of the view. Everything happens inside of the UIViewRepresentable.
We got a universal solution — UILabel with tappable and configurable links. The label perfectly understands HTML tags and it behaves as on the web, even more, the links are still tappable and configurable with the states normal and active. It can be used in UIKit and SwiftUI.
During the time of slow transitioning from UIKit to SwiftUI sometimes we expect some native ready-to-use solution. SwiftUI already understands markdown and HTML but it is only from iOS 15 and it is even with limited usage. The task is getting more complicated if we need such a feature for UIKit and SwiftUI with the same behavior.
To build UILabel like this at the first sight seems a trivial task but when we develop it, all the time we encounter problems: taping on a link, HTML configuration, font styling, etc.
This article is not only for engineers, it is also useful for managers because it shows how deep we — developers can go just to make some small feature and how much time it could take.
I will appreciate it if you come up with any suggestions in the comments, and also feel free to fork the repo as well.
GitHub:
Thanks for reading! 🚀
Also published here.