Inline Links in Text in iOS Apps
Today, I was working on a client project. As usual a couple of designers had done some high fidelity designs in Sketch that I was riffing off. In the designs on the Sign Up screen near the bottom there was the classic notice stating that by signing up you are agreeing to the User Agreement and Privacy Policy.
There are two interesting things about this. First and foremost the phrases User Agreement and Privacy Policy are visually called out using blue like they would be if they were anchor tags (a.k.a. links) in a website. Secondly, both these phrases are inline with other text. If I was doing this on a website this would be trivial as I would just put two anchor tags inline in a paragraph tag with the rest of the text. However, on iOS things aren’t so straight forward.
Assumptions
My assumptions going into this were that I was going to have to somehow detect a tap on each of those phrases and then present a new modal View Controller containing a web view that loads the User Agreement or Privacy Policy respectively. I knew off hand how to do all of these things except for the detection of the phrases being tapped. Though, I did have enough knowledge to know that with some work it could be done.
Research
I started out searching for “UILabel tappable text”. It led me to a variety of half baked attempts at solving this with code that seemed way more complicated then it should need to be in my mind. I am talking like 60+ line solutions. So, I kept digging and stumbled across a post referencing the existence of some sort of NSAttributedString
link feature. I thought this was interesting but sadly the post did not give me much direction in terms of actually making it happens. So, I searched a bit more and found an example of applying the NSAttributedString.Key.link
attribute to an NSMutableAttributedString
. An example is as follows:
let attributedString = NSMutableAttributedString(string: “by signing up, you are agreeing to Float’s User Agreement and Privacy Policy.”)
attributedString.addAttribute(NSAttributeString.Key.link, value: “https://example.com/policy”, range: NSRange(location: 43, length: 14)
When I applied the above and then assigned it to the UILabel
‘s attributedText
property I got excited because it resulted in the User Agreement phrase showing up as blue with an underline. This was close to what I wanted design wise and gave me hope that it might actually do a thing. Sadly, tapping on what I believed was a link had no impact. So, I dug further and tried a number of various properties on UILabel
such as isUserInteractionEnabled
but no luck.
After searching a bit more I stumbled upon an article that referenced further some delegate methods from UITextViewDelegate
that people were using with the NSAttributedString.Key.link
to handle opening them in a browser. This gave me a new path to explore.
UITextView
So, step one I converted my UILabel
to a UITextView
. When I initially did this the view was UITextView
was not visible and inspecting the view I could see that there was layout constraint issues. So, I gave it a hard coded height using auto layout constraints. Magically, it was visible and the layout constraint issue went away. So, now I knew that it was not being sized based on it’s content. After a quick search I was able to find out it is because it has a isScrollEnabled
property that is by default set to true
. So, I flipped it to false
and got rid of the explicit height constraint. Like magic it was there and visible just liked I wanted with the correct styling.
Next I attempted to tap the link. But, to my surprise the keyboard popped up. Of course that is because there is another property isEditable
that is by default true
. So, after flipping that to false
when I tapped the link it actually opened Safari to the URL of the link.
Finally some success! At this point my UITextView
was configured as follows:
textView.isEditable = false
textView.isScrollEnabled = false
textView.backgroundColor = UIColor.clear
Breaking Assumptions
At this point it was actually functioning though in a different fashion than I had originally assumed it would. This made me question my initial assumptions of the need for having a separate view be presented modally containing a web view that would load the content.
The default behavior I was seeing is actually pretty much inline with those concepts. It is basically modally presenting Safari with the content loaded in Safari. My immediate next thought was well how easy is it for the user to get back to the app where they left off in either scenario. In the separate view scenario they would have to hit a close button to dismiss the modal view and go back to the Sign Up view. In the Safari scenario the user simply hits the back to last app button in the upper left.
So, turns out that the default behavior experience feels great, is minimal effort and models how most apps on macOS handle links as well. At this point I am pretty confident this the ideal path and I am driving full force ahead.
Another Problem
Turns out there is one problem. I have been localizing this application up to this point using NSLocalizedString
. Looking at the example:
let attributedString = NSMutableAttributedString(string: “by signing up, you are agreeing to Float’s User Agreement and Privacy Policy.”)
attributedString.addAttribute(NSAttributeString.Key.link, value: “https://example.com/policy”, range: NSRange(location: 43, length: 14)
We are clearly coupling the link to a specific starting location and length within the string. So, sadly this breaks localization because different languages will have different starting locations and different lengths, and with some googling there is no built in way for localization to support NSAttributedString
.
Solution
But, wait there is still hope. At least in my opinion the above linking strategy is by far the best solution I have been able to find. So, if your app does not need localization you can use it straight away. If on the other hand your app does need localization. Don’t worry I have an idea.
The thought is to actually use a subset of Markdown in the localized string content and then write a method that will fetch a NSLocalizedString
and parse the subset of Markdown into an NSAttributedString
. This would in theory resolve the localization issue and potentially give localization a decent bit more functionality in terms of representing text.
In fact this can trivially be done with existing Markdown parsing libraries such as iwasrobbed/Down. You could use it as follows to get your attributed string with links.
let down = Down(markdownString: NSLocalizedString(“USER_AGREEMENT_NOTICE”, comment: “User Agreement Notice”))
let attributedString = try? down.toAttributedString()
The only down side to this approach, at least with this Markdown library, is that the NSAttributedString
it produces has the links styled with the NSAttributedString.Key.link
attribute as well as the underline style. This makes it look different than the designs. With a quick read of the iwasrobbed/Down README.md and some spelunking of the DownAttributedStringRenderable.swift file I quickly discovered that the toAttributedString()
method has a toAttriubtedString(stylesheet:)
variant that allows passing a String
of CSS in to control the styling. In my case this just meant that I need to remove the text-decoration
on anchor tags.
I thought about making a library to support this. However, now that I have seen how trivial it is to implement I decided I would just implement a helper method to do this. The helper I now write looks as follows:
func localizedMarkdownString(_ key: String, comment: String) -> NSAttributedString? {
let down = Down(markdownString: NSLocalizedString(key, comment: comment))
return try? Down.toAttributedString(stylesheet: "a { text-decoration: none; }")
}
That’s it! Relish in the beauty of localized Markdown and NSAttributedString
coming together so harmoniously to solve this problem.