1

I'm trying to setup a UITextView with multiple links within the text. My implementation is based on the suggestion described here. Most links work as expected, however tapping on some of them makes the app crash with the following error EXC_BREAKPOINT (code=1, subcode=0x185646694):
Crash error
Call stack

My UITextView configuration code:

private var actionableLinks = [(String, ()->Void)]() // each link = (actionableString, tapAction)

private func setupMessageText() {
    guard messageTextView != nil else { return }
        
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.paragraphSpacingBefore = 16
        
    let attributedText = NSMutableAttributedString(string: messageText, attributes: [
        .font: messageTextView.font!,
        .foregroundColor: messageTextView.textColor!,
        .paragraphStyle: paragraphStyle
    ])
        
    addActionableLinks(to: attributedText)
        
    messageTextView?.attributedText = attributedText
}
    
private func addActionableLinks(to attributedText: NSMutableAttributedString) {
    actionableLinks.forEach {
        let actionableString = $0.0
            
        if let nsRange = messageText.nsRange(of: actionableString) {
            attributedText.addAttribute(.link, value: actionableString, range: nsRange)
        }
    }
}

To handle the tap action, I've imlpemented the proper UITextViewDelegate method:

func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
    let tappedLinkString = URL.absoluteString

    if let link = actionableLinks.first(where: { $0.0 == tappedLinkString }) {
        let tapAction = link.1
        tapAction()

        return false
    }

    return true
}

My storyboard configuration for this screen (I have setup the UITextView delegate in the storyboard):
Storyboard Configuration

Any insights would be much appreciated! Thanks.

Ruben Dias
  • 41
  • 6
  • I suspect the link to be invalid. `if let nsRange = messageText.nsRange(of: actionableString), URL(string: actionableString) != nil {` might be the fix. If you can reproduce, could you provide the `actionableString` which is causing the issue? – Larme Jun 09 '21 at 10:23
  • See https://stackoverflow.com/questions/65656646/swift-crashlytics-exc-breakpoint-error-meaning & https://stackoverflow.com/questions/48154641/hashtags-in-arabic-language-crashes-the-app/48156375#48156375 since that might be the issue (there are explanations) – Larme Jun 09 '21 at 10:25
  • Thanks for your comment, and nice insight. The string I use as a link is in fact just a normal string and not a URL. I based my implementation on [this previous answer](https://stackoverflow.com/a/52262391/8200515) and nothing was mentioned there. The string for the link that is causing the crash is "new terms and conditions", and the one that succeeds is "support". – Ruben Dias Jun 09 '21 at 10:27
  • 3
    And as said (in the explaination see both links), if the "link" is `"new terms and conditions"`, converted to `URL`, with `URL(string: thatString)` internally by Apple, just before calling `textView(_:shouldInteractWith:in:)`, it's nil and make it crash., because space are invalid. Percent escape them (see second linked question), and maybe add a scheme to them: `yourApp://` as prefix. – Larme Jun 09 '21 at 10:29
  • Nice catch indeed! I had that suspicion after reading the questions you linked. I'll test it right away – Ruben Dias Jun 09 '21 at 10:31
  • What is the actual actionableString guy? – El Tomato Jun 09 '21 at 10:31

1 Answers1

3

Issue solved! Thanks to Larme for the quick insights and resources.

It was indeed a case of trying to use a bad string as a link within the UITextView that internally was being converted to a URL. Since this was a string with spaces in it, internal conversion to URL by Apple was failing.

My string "support" was linking properly and it worked, but a different string "new terms and conditions" was failing.

The solution

To solve the issue, I used percent encoding when adding the link attribute to the UITextView's attributed text.

private func addActionableLinks(to attributedText: NSMutableAttributedString) {
    actionableLinks.forEach {
        let actionableString = $0.0
            
        if let nsRange = messageText.nsRange(of: actionableString) {
            let escapedActionableString = escapedString(actionableString)
            attributedText.addAttribute(.link, value: escapedActionableString, range: nsRange)
        }
    }
}
    
private func escapedString(_ string: String) -> String {
    return string.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? string
}

I also changed the delegate method to check for a match to an escaped string:

func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
    let tappedLinkString = URL.absoluteString
        
    for (actionableString, tapAction) in actionableLinks {
        let escapedActionableString = escapedString(actionableString)
            
        if escapedActionableString == tappedLinkString {
            tapAction()
            return false
        }
    }

    return true
}

Thanks!

Ruben Dias
  • 41
  • 6
  • You could have kept your shouldInteractLogic with just: `let tappedLinkString = URL.aboluteString.removingPercentEncoding` and your `first(where:)`. – Larme Jun 09 '21 at 10:49
  • Oh, cool! I'll use that, it's way cleaner. Thanks a lot! – Ruben Dias Jun 09 '21 at 10:55