Quantcast
Channel: Hasen Judy
Viewing all articles
Browse latest Browse all 50

iOS: Custom clickable regions inside attributed text views

$
0
0

Android has ClickableSpan that can be put inside a SpannableString, and you can do whatever you want when the user taps on it.

Here’s an example in Kotlin:

object : ClickableSpan() {
    override fun onClick(view: View?) {
        view?.gotoWordById(wordId)
    }
}

iOS has nothing like a ClickableSpan, but you can define your own custom attributes and put them inside an NSAttributedString.

Here’s an example of defining a custom attributes:

extension NSAttributedStringKey {
    static let wordLink = NSAttributedStringKey("WordLink") // WordLinkRef
}

The only thing we’re really defining here is the key name (make sure it’s unique; doesn’t conflict with other existing keys).

The key doesn’t even have to be defined as extension to NSAttributedStringKey but having it there makes things simpler in that it looks like any other attribute key.

The value can actually be anything, so the type system can’t help you enforce specific types of values.

Point: What we have to do is listen to tap events on the text, then figure out where the tap occured, and then looking into the attributed text to see what attributes are set at that location.

Now, to make this work, we should use UITextView instead of UILabel. Why? Because UILabel does not expose the underlying layout objects that are crucial for us to be able to map the tap location to the index within the text.

There are posts on StackOverflow that attempt to manually re-create all the objects used by the text layout engine, but in my experimentation, that method does not work very well. You can never be 100% sure that what you are doing matches exactly what the UILabel is doing internally.

The problem with UITextView is by default it will not adjust its size automatically to fit its content.

However, you can easily make it do that.

In my setup, I put the text view as the only view inside a table cell view, and set its constraints relative to superview only.

Then I just call layoutIfNeeded() after setting the attributedText.

func populate(meaning: Meaning_Entry) {
    self.textView.attributedText = meaning.formatted
    self.textView.layoutIfNeeded()
}

This populate method is basically what I call from cellForRowAt from the table view controller.

Now, we must setup a tap gesture recognizer on the text view, and inside the tap handler get the tap point.

A few gotchas here: enable user interaction, and disable selection:

textView.isUserInteractionEnabled = true
textView.isSelectable = false

Actually I’m not sure that selection has to be disabled, but in my case it’s a distraction, so better turn it off.

To go from point to index in string:

let index = textView.layoutManager.characterIndex(for: tapPoint, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

Once you have the index, you can iterate over all attributes there, or in my case I’m just interested in my specific attribute:

guard let link = textView.textStorage.attribute(.wordLink, at: index, effectiveRange: nil) as? WordLinkRef else {
    return
}
follow(wordLink: link)

I hope that can help some people.

Here’s a small gif showing the result in the app I’m developing:

gif showing screen

Here’s a more complete implementation of the relevant parts from the table cell

// This is in the table cell class

@IBOutlet var textView: UITextView!

override func awakeFromNib() {
    super.awakeFromNib()
    self.textView.isUserInteractionEnabled = true
    self.textView.isSelectable = false

    let tap = UITapGestureRecognizer(target: self, action: #selector(onTap(sender:)))
    tap.delegate = self
    tap.numberOfTapsRequired = 1
    self.textView.addGestureRecognizer(tap)
}

@objc
func onTap(sender: UITapGestureRecognizer) {
    guard let textView = sender.view as? UITextView else {
        return
    }
    let tapPoint = sender.location(in: textView)
    let layout = textView.layoutManager
    let textStorage = textView.textStorage
    let container = textView.textContainer
    let index = layout.characterIndex(for: tapPoint, in: container, fractionOfDistanceBetweenInsertionPoints: nil)
    guard let link = textStorage.attribute(.wordLink, at: index, effectiveRange: nil) as? WordLinkRef else {
        return
    }
    follow(wordLink: link)
}

Viewing all articles
Browse latest Browse all 50

Trending Articles