Categories
Foundation iOS Swift UIKit

Opening hyperlinks in UILabel on iOS

UILabel supports displaying attributed strings with link attributes but what it does not support is allowing to tap on hyperlinks and opening those in Safari. An Alternative way is using an UITextView which does support opening hyperlinks but on the other hand it is a more heavy view component and therefore might not be the best choice when we just need to display some text with hyperlinks. This time lets create a simple UILabel subclass which adds support for opening hyperlinks and custom hyperlink styles.

Creating NSAttributedStrings with hyperlinks

Before we jump into implementing an UILabel subclass HyperlinkLabel, let’s first take a look on how to create a NSAttributedString with hyperlinks. In the example app we will have several labels with different configurations: default and custom link styles and left, centre, right text alignments. UITextView has a linkTextAttributes property but in our UILabel subclass we’ll need to implement custom link styling ourselves. The approach we are going to take is creating a new NSAttributedString.Key value named hyperlink and adding text attributes when the attribute is present.

extension NSAttributedString.Key {
static let hyperlink = NSAttributedString.Key("hyperlink")
}

Let’s now create a convenience method which creates NSAttributedString and sets it to the HyperlinkLabel.

private extension HyperlinkLabel {
static func banner(withAlignment alignment: NSTextAlignment, customStyling: Bool, tapHandler: @escaping (URL) -> Void) -> HyperlinkLabel {
let attributedString = NSMutableAttributedString(string: "Check this webpage: %0$@. Link to %1$@ on the App Store. Finally link to %2$@.")
let replacements = [("Augmented Code", URL(string: "https://augmentedcode.io")!),
("SignalPath", URL(string: "https://geo.itunes.apple.com/us/app/signalpath/id1210488485?mt=12")!),
("GitHub", URL(string: "https://github.com/laevandus")!)]
replacements.enumerated().forEach { index, value in
let linkAttribute: NSAttributedString.Key = customStyling ? .hyperlink : .link
let attributes: [NSAttributedString.Key: Any] = [
linkAttribute: value.1
]
let urlAttributedString = NSAttributedString(string: value.0, attributes: attributes)
let range = (attributedString.string as NSString).range(of: "%\(index)$@")
attributedString.replaceCharacters(in: range, with: urlAttributedString)
}
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = alignment
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: attributedString.length))
let label = HyperlinkLabel()
label.attributedText = attributedString
label.translatesAutoresizingMaskIntoConstraints = false
label.didTapOnURL = tapHandler
return label
}
}

The final NSAttributedString is created by first initializing a mutable version with a format string. The format string follows the familiar format specifiers like we used with NSString and String APIs when dealing with re-orderable arguments. Format specifiers are replaced with new instances of NSAttributedStrings where the string value equals to a hyperlink name and the URL value is stored on the NSAttributedString.Key.link or NSAttributedString.Key.hyperlink attribute. The former gives us the default link style defined by Apple and the latter our custom link style.

class ViewController: UIViewController {
@IBOutlet weak var stackView: UIStackView!
private lazy var resultLabel: UILabel = .sectionTitle("Tap on the label…")
override func viewDidLoad() {
super.viewDidLoad()
stackView.addArrangedSubview(resultLabel)
stackView.addArrangedSubview(UILabel.sectionTitle("Left alignment"))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .left, customStyling: false, tapHandler: didTap))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .left, customStyling: true, tapHandler: didTap))
stackView.addArrangedSubview(UILabel.sectionTitle("Center alignment"))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .center, customStyling: false, tapHandler: didTap))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .center, customStyling: true, tapHandler: didTap))
stackView.addArrangedSubview(UILabel.sectionTitle("Right alignment"))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .right, customStyling: false, tapHandler: didTap))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .right, customStyling: true, tapHandler: didTap))
}
private func didTap(_ url: URL) {
// In the example app we just print the result and do not open it in Safari
resultLabel.text = "Did tap on: \(url)"
}
}

Creating HyperlinkLabel which supports tapping on hyperlinks

UILabel does not provide access to its text layout and therefore it is not possible to know which hyperlink was tapped. For finding the tapped hyperlink we’ll need to use our own NSLayoutManager, NSTextStorage, and NSTextContainer. If we configure those properly we can figure out which character was tapped and therefore if it was part of the hyperlink. If it is, then we can let UIApplication to open the tapped URL. Let’s contain this in a function which gives us a configured NSTextStorage.

private func preparedTextStorage() -> NSTextStorage? {
guard let attributedText = attributedText, attributedText.length > 0 else { return nil }
// Creates and configures a text storage which matches with the UILabel's configuration.
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: bounds.size)
textContainer.lineFragmentPadding = 0
let textStorage = NSTextStorage(string: "")
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
textContainer.lineBreakMode = lineBreakMode
textContainer.size = textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines).size
textStorage.setAttributedString(attributedText)
return textStorage
}

Now we can use this function when handling UITouch events. We’ll add another private function which will take current UITouches and figure out which hyperlink was tapped. The general flow consists of creating a NSTextStorage with the function we just defined, then asking from the NSLayoutManager which character index was tapped. NSLayoutManager returns the closest character index of the touch. Therefore, we’ll need to go one step deeper and ask for the actual bounding rectangle of the glyphs representing the character and then verifying if the touch location was inside the glyph’s bounding rectangle. This is important when dealing with different text alignments and when tapping on the free space around characters. After figuring out which character was tapped we’ll need to check for the hyperlink attributes. If the tapped character has either attribute set, then we can conclude that we tapped on a hyperlink. This function can then be called in touchesEnded and if we tapped on a hyperlink, then we can open it. One thing to note is that userInteractionEnabled needs to be set to true before UILabel can handle touch events.

private func url(at touches: Set<UITouch>) -> URL? {
guard let attributedText = attributedText, attributedText.length > 0 else { return nil }
guard let touchLocation = touches.sorted(by: { $0.timestamp < $1.timestamp } ).last?.location(in: self) else { return nil }
guard let textStorage = preparedTextStorage() else { return nil }
let layoutManager = textStorage.layoutManagers[0]
let textContainer = layoutManager.textContainers[0]
let characterIndex = layoutManager.characterIndex(for: touchLocation, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
guard characterIndex >= 0, characterIndex != NSNotFound else { return nil }
// Glyph index is the closest to the touch, therefore also validate if we actually tapped on the glyph rect
let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: characterIndex, length: 1), actualCharacterRange: nil)
let characterRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
guard characterRect.contains(touchLocation) else { return nil }
// Link styled by Apple
if let url = textStorage.attribute(.link, at: characterIndex, effectiveRange: nil) as? URL {
return url
}
// Custom link style
return textStorage.attribute(.hyperlink, at: characterIndex, effectiveRange: nil) as? URL
}
var didTapOnURL: (URL) -> Void = { url in
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: { success in
if success {
print("Opened URL \(url) successfully")
}
else {
print("Failed to open URL \(url)")
}
})
}
else {
print("Can't open the URL: \(url)")
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
if let url = self.url(at: touches) {
didTapOnURL(url)
}
else {
super.touchesEnded(touches, with: event)
}
}

Next thing we need to do is overriding attributedText property and handling the custom style of our custom hyperlink attribute. If the attributed text has this attribute set, then we will apply custom hyperlink text attributes. The same way as Apple’s link attribute works, when the attributed string gets displayed then custom styling is used. Secondly, we’ll set the UILabel’s font to the attributed string’s ranges which do not have a font attribute set. UILabel internally use UILabel’s font when font attributes are not set, so we want to force the same behaviour when the stored attributed string is set to our own NSTextStorage. If we do not do this, then NSAttributedString just uses its default font and the displayed string is not going to be equal to the string set to NSTextStorage. This in turn will lead to invalid character index calculations because fonts are different.

override var attributedText: NSAttributedString? {
get {
return super.attributedText
}
set {
super.attributedText = {
guard let newValue = newValue else { return nil }
// Apply custom hyperlink attributes
let text = NSMutableAttributedString(attributedString: newValue)
text.enumerateAttribute(.hyperlink, in: NSRange(location: 0, length: text.length), options: .longestEffectiveRangeNotRequired) { (value, subrange, _) in
guard let value = value else { return }
assert(value is URL)
text.addAttributes(hyperlinkAttributes, range: subrange)
}
// Fill in font attributes when not set
text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length), options: .longestEffectiveRangeNotRequired) { (value, subrange, _) in
guard value == nil, let font = font else { return }
text.addAttribute(.font, value: font, range: subrange)
}
return text
}()
}
}

Summary

We created a UILabel subclass which has a capability of opening tapped links. Useful, if we need just a label for displaying some text along with hyperlinks. Please take a look on the full implementation available here: HyperlinkLabel.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Project

UILabelHyperlinks (Xcode 12.3)

Categories
Foundation iOS Swift UIKit

Adding custom attribute to NSAttributedString on iOS

NSAttributedString is used for displaying rich text on Apple platforms. There are many attributes available for styling the text. Let’s see how to add new attribute and how to draw it in UITextView.

Drawing custom attribute

Defining a new attribute is just a matter of extending NSAttributedString.Key and defining a new static variable. Difficult part is actually drawing the custom attribute. Apple’s documentation says that best way for it is to subclass NSLayoutManager and overriding drawGlyphs(forGlyphRange:at:). This is what we are doing here as well. Whenever glyphs are drawn, we check if the glyph range contains the custom attribute, if it does, then we get the rects for glyphs containing the custom attribute. When we know rects, we can draw the custom styling. In this case, we’ll mimic tokens in text and therefore go for rectangle with rounded corners. Should be noted that enumerating custom attribute, longestEffectiveRangeNotRequired should be specified. Otherwise, if we have two different tokens next to each-other, then this attribute is considered as one.

UITextView has by default text container inset set. For being able to draw the custom styling exactly behind the text, this offset must be taken account. Easiest is to propagate insets to layout manager what we are doing here.

extension NSAttributedString.Key {
    static let token = NSAttributedString.Key("Token")
}

final class TokenLayoutManager: NSLayoutManager {
    var textContainerOriginOffset: CGSize = .zero
    
    override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
        let characterRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
        textStorage?.enumerateAttribute(.token, in: characterRange, options: .longestEffectiveRangeNotRequired, using: { (value, subrange, _) in
            guard let token = value as? String, !token.isEmpty else { return }
            let tokenGlypeRange = glyphRange(forCharacterRange: subrange, actualCharacterRange: nil)
            drawToken(forGlyphRange: tokenGlypeRange)
        })
        super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
    }
    
    private func drawToken(forGlyphRange tokenGlypeRange: NSRange) {
        guard let textContainer = textContainer(forGlyphAt: tokenGlypeRange.location, effectiveRange: nil) else { return }
        let withinRange = NSRange(location: NSNotFound, length: 0)
        enumerateEnclosingRects(forGlyphRange: tokenGlypeRange, withinSelectedGlyphRange: withinRange, in: textContainer) { (rect, _) in
            let tokenRect = rect.offsetBy(dx: self.textContainerOriginOffset.width, dy: self.textContainerOriginOffset.height)
            UIColor(hue: 175.0/360.0, saturation: 0.24, brightness: 0.88, alpha: 1).setFill()
            UIBezierPath(roundedRect: tokenRect, cornerRadius: 4).fill()
        }
    }
}

Using custom layout manager in UITextView requires a little bit of setup what we can put into UITextView’s subclass.

final class TokenTextView: UITextView {
    init(frame: CGRect) {
        let layoutManager = TokenLayoutManager()
        let textStorage = NSTextStorage()
        textStorage.addLayoutManager(layoutManager)
        let textContainer = NSTextContainer()
        textContainer.heightTracksTextView = true
        textContainer.widthTracksTextView = true
        layoutManager.addTextContainer(textContainer)
        super.init(frame: frame, textContainer: textContainer)
        updateLayoutManager()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override var textContainerInset: UIEdgeInsets {
        didSet {
            updateLayoutManager()
        }
    }
    
    private func updateLayoutManager() {
        guard let layoutManager = layoutManager as? TokenLayoutManager else { return }
        layoutManager.textContainerOriginOffset = CGSize(width: textContainerInset.left, height: textContainerInset.top)
        layoutManager.invalidateDisplay(forCharacterRange: NSRange(location: 0, length: attributedText.length))
    }
}
let textView = TokenTextView(frame: .zero)
textView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(textView)
NSLayoutConstraint.activate([
    textView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
    textView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
    textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
    textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
])
        
let string = "The quick brown fox jumps over the lazy dog"
let attributedString = NSMutableAttributedString(string: string)
let value = "value"
attributedString.addAttribute(.token, value: value, range: NSRange(location: 4, length: 5))
textView.attributedText = attributedString

Summary

We defined a new attribute by extending NSAttributedString.Key. Then we created a new NSLayoutManager subclass and added custom styling to the attribute. Lastly, we configured the UITextView to use custom layout manager and set text with custom attribute to it.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example project

NSAttributedStringCustomAttribute Xcode 11.2, Swift 5.1