How to detect touch on NSTextAttachment

Michał Zygar picture Michał Zygar · Oct 11, 2013 · Viewed 10.1k times · Source

What is the best way to detect when user taps on NSTextAttachment on iOS?

I think that one of the ways would be checking for the character on carret's position whether it is NSAttachmentCharacter, but it just doesn't seem right.

I've also tried UITextViewDelegate method: -(BOOL)textView:(UITextView *)textView shouldInteractWithTextAttachment:(NSTextAttachment *)textAttachment inRange:(NSRange)characterRange but it's not invoked when textView.editable=YES

Answer

Eli Burke picture Eli Burke · Apr 25, 2019

Josh's answer is almost perfect. However, if you tap in the whitespace of your UITextView past the end of the input, glyphIndex(for:in:fractionOfDistanceThroughGlyph) will return the final glyph in the string. If this is your attachment, it will incorrectly evaluate to true.

Apple's docs say: If no glyph is under point, the nearest glyph is returned, where nearest is defined according to the requirements of selection by mouse. Clients who wish to determine whether the the point actually lies within the bounds of the glyph returned should follow this with a call to boundingRect(forGlyphRange:in:) and test whether the point falls in the rectangle returned by that method.

So, here is a tweaked version (Swift 5, XCode 10.2) that performs an additional check on the bounds of the detected glyph. I believe some of the characterIndex tests are now superfluous but they don't hurt anything.

One caveat: glyphs appear to extend to the height of the line containing them. If you have a tall portrait image attachment next to a landscape image attachment, taps on the whitespace above the landscape image will still evaluate to true.

import UIKit
import UIKit.UIGestureRecognizerSubclass

// Thanks to https://stackoverflow.com/a/52883387/658604
// and https://stackoverflow.com/a/49153247/658604

/// Recognizes a tap on an attachment, on a UITextView.
/// The UITextView normally only informs its delegate of a tap on an attachment if the text view is not editable, or a long tap is used.
/// If you want an editable text view, where you can short cap an attachment, you have a problem.
/// This gesture recognizer can be added to the text view, and will add requirments in order to recognize before any built-in recognizers.
class AttachmentTapGestureRecognizer: UITapGestureRecognizer {

    typealias TappedAttachment = (attachment: NSTextAttachment, characterIndex: Int)

    private(set) var tappedState: TappedAttachment?

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        tappedState = nil

        guard let textView = view as? UITextView else {
            state = .failed
            return
        }

        if let touch = touches.first {
            tappedState = evaluateTouch(touch, on: textView)
        }

        if tappedState != nil {
            // UITapGestureRecognizer can accurately differentiate discrete taps from scrolling
            // Therefore, let the super view evaluate the correct state.
            super.touchesBegan(touches, with: event)

        } else {
            // User didn't initiate a touch (tap or otherwise) on an attachment.
            // Force the gesture to fail.
            state = .failed
        }
    }

    /// Tests to see if the user has tapped on a text attachment in the target text view.
    private func evaluateTouch(_ touch: UITouch, on textView: UITextView) -> TappedAttachment? {
        let point = touch.location(in: textView)
        let glyphIndex: Int = textView.layoutManager.glyphIndex(for: point, in: textView.textContainer, fractionOfDistanceThroughGlyph: nil)
        let glyphRect = textView.layoutManager.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: textView.textContainer)
        guard glyphRect.contains(point) else {
            return nil
        }
        let characterIndex: Int = textView.layoutManager.characterIndexForGlyph(at: glyphIndex)
        guard characterIndex < textView.textStorage.length else {
            return nil
        }
        guard NSTextAttachment.character == (textView.textStorage.string as NSString).character(at: characterIndex) else {
            return nil
        }
        guard let attachment = textView.textStorage.attribute(.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment else {
            return nil
        }
        return (attachment, characterIndex)
    }
}