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
Use hitTest to get the touch in a subclassed UITextView. This avoids the issue of messing up the standard editing functions. From the position get the character index and then check the character for the attachment.
I have altered Drew's gesture recognizer here to subclass UITapGestureRecognizer
rather than UIGestureRecognizer
.
This offers one advantage in that it only detects discrete taps as opposed to the beginning of a scroll.
import UIKit
import UIKit.UIGestureRecognizerSubclass
// Modified from: 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 index: Int? = textView.layoutManager.characterIndexForGlyph(at: glyphIndex ?? 0)
guard let characterIndex = index, 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)
}
}
The delegate method does work but ONLY if the attachment has an image in the image attribute AND if editable = NO! So if you have an image pasted in to the attributedString from somewhere else it seems the data ends up being stored in the fileWrapper and next time you put the attributedString back into the textView the image attribute is nil and the layout manager or whatever gets the image from the fileWrapper.
Somewhere in the documents it does mention that there are no methods in NSTextAttachment for persistence of the image attribute.
To test this try copy a photo from the Photo app and paste it into your textView, now if you hold down your finger on it you should see the default menu pop up. Now if you save this rich text, say into a Core Data entity and then retrieve it the image attribute will be nil but the image data will be in attachment.fileWrapper.regularFileContents
Its a pain, and I would love to know the engineers intention. So you have two options it seems.
Every time prior to putting your string back into textView you find all the attachments and recreated the image attribute like so:
attachment.image = [UIImage imageWithData:attachment.fileWrapper.regularFileContents];
Bear in mind the side effect of doing this is invalidating the fileWrapper. I want to resize the image but also keep the original so I don't loose the full resolution. I think the only way of doing this might be to subclass NSTextAttachment.
EDIT:
I figured out how to create the custom NSTextAttachments - here is a link for those interested http://ossh.com.au/design-and-technology/software-development/implementing-rich-text-with-images-on-os-x-and-ios/
EDIT 2: To customise the menu when in Edit Mode see the following Apple documents, the issue is 'touchEnded' never seems to get called so you might have to try using touchesBegan. Careful you don't interfere with the default editing behaviour though.
https://developer.apple.com/library/ios/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/AddingCustomEditMenuItems/AddingCustomEditMenuItems.html
Note that in the code below you would need to add code after // selection management
comment to determine which character was touched, check if it is the special text attachment character and
then modify the edit menu or take some other action.
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch *theTouch = [touches anyObject];
if ([theTouch tapCount] == 2 && [self becomeFirstResponder]) {
// selection management code goes here...
// bring up edit menu.
UIMenuController *theMenu = [UIMenuController sharedMenuController];
CGRect selectionRect = CGRectMake (currentSelection.x, currentSelection.y, SIDE, SIDE);
[theMenu setTargetRect:selectionRect inView:self];
[theMenu setMenuVisible:YES animated:YES];
}
}
Alternately you could add a custom menu by adding the menu item and then modifying the canPerformAction method.
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
LOG(@"canPerformAction: called");
if (action == @selector(viewImage)) {
// Check the selected character is the special text attachment character
return YES;
}
return NO;
}
Here is some addition code but its a bit fussy. Second method just disables the default edit menu if an attachment is detected.
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
FLOG(@"touchesBegan:withEvent: called");
if (self.selectedRange.location != NSNotFound) {
FLOG(@" selected location is %d", self.selectedRange.location);
int ch;
if (self.selectedRange.location >= self.textStorage.length) {
// Get the character at the location
ch = [[[self textStorage] string] characterAtIndex:self.selectedRange.location-1];
} else {
// Get the character at the location
ch = [[[self textStorage] string] characterAtIndex:self.selectedRange.location];
}
if (ch == NSAttachmentCharacter) {
FLOG(@" selected character is %d, a TextAttachment", ch);
} else {
FLOG(@" selected character is %d", ch);
}
}
}
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
FLOG(@"canPerformAction: called");
FLOG(@" selected location is %d", self.selectedRange.location);
FLOG(@" TextAttachment character is %d", NSAttachmentCharacter);
if (self.selectedRange.location != NSNotFound) {
int ch;
if (self.selectedRange.location >= self.textStorage.length) {
// Get the character at the location
ch = [[[self textStorage] string] characterAtIndex:self.selectedRange.location-1];
} else {
// Get the character at the location
ch = [[[self textStorage] string] characterAtIndex:self.selectedRange.location];
}
if (ch == NSAttachmentCharacter) {
FLOG(@" selected character is %d, a TextAttachment", ch);
return NO;
} else {
FLOG(@" selected character is %d", ch);
}
// Check for an attachment
NSTextAttachment *attachment = [[self textStorage] attribute:NSAttachmentAttributeName atIndex:self.selectedRange.location effectiveRange:NULL];
if (attachment) {
FLOG(@" attachment attribute retrieved at location %d", self.selectedRange.location);
return NO;
}
else
FLOG(@" no attachment at location %d", self.selectedRange.location);
}
return [super canPerformAction:action withSender:sender];
}
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)
}
}
SWIFT 4.2
@objc func myMethodToHandleTap(_ sender: UITapGestureRecognizer) {
let myTextView = sender.view as! UITextView
let layoutManager = myTextView.layoutManager
// location of tap in myTextView coordinates and taking the inset into account
var location = sender.location(in: myTextView)
location.x -= myTextView.textContainerInset.left;
location.y -= myTextView.textContainerInset.top;
// character index at tap location
let characterIndex = layoutManager.characterIndex(for: location, in: myTextView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
if characterIndex < myTextView.textStorage.length {
let attributeValue = myTextView.attributedText.attribute(NSAttributedString.Key.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment
if let _ = attributeValue {
print("TAPPED ATTACHMENT")
}
}
}
Swift 3 answer:
func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange) -> Bool {
return true
}
Make sure your textView isEditable = false
, isSelectable = true
, and isUserInteractionEnabled = true
. Duncan's answer did not mention isUserInteractionEnabled
, this has to be true
, otherwise it wont work.
You can do this programmatically (textView.isEditable = false), or via attributes inspector: