How to detect touch on NSTextAttachment - ios

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

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)
}
}

Apple make this really difficult. As others point out, the delegate method is called, but only when isEditable is false, or when the user does a tap and hold on the attachment. If you want to be informed about a simple tap interaction during editing, forget it.
I went down the touchesBegan: and hitTest: paths, both with problems. The touches methods are called after the UITextView has already handled the interaction, and the hitTest: is too crude, because it messes with the first responder status and so forth.
My solution in the end was gesture recognizers. Apple are using those internally, which explains why touchesBegan: is not really viable in the first place: the gesture recognizers have already handled the event.
I created a new gesture recognizer class for use with a UITextView. It simply checks for the location of the tap, and if it is an attachment, it handles it. I make all the other gesture recognizers subordinate to my one, so we get first look at the events, and the others only come into play if our one fails.
The gesture recognizer class is below, along with an extension for adding it to UITextView. I add it in my UITextView subclass in awakeFromNib, like this. (You needn't use a subclass if you don't have one.)
override func awakeFromNib() {
super.awakeFromNib()
let recognizer = AttachmentTapGestureRecognizer(target: self, action: #selector(handleAttachmentTap(_:)))
add(recognizer)
and I handle the action by calling the existing UITextViewDelegate method textView(_:,shouldInteractWith:,in:,interaction:). You could just as easily put the handling code directly in the action, rather than using the delegate.
#IBAction func handleAttachmentTap(_ sender: AttachmentTapGestureRecognizer) {
let _ = delegate?.textView?(self, shouldInteractWith: sender.attachment!, in: NSRange(location: sender.attachmentCharacterIndex!, length: 1), interaction: .invokeDefaultAction)
}
Here is the main class.
import UIKit
import UIKit.UIGestureRecognizerSubclass
/// 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: UIGestureRecognizer {
/// Character index of the attachment just tapped
private(set) var attachmentCharacterIndex: Int?
/// The attachment just tapped
private(set) var attachment: NSTextAttachment?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
attachmentCharacterIndex = nil
attachment = nil
let textView = view as! UITextView
if touches.count == 1, let touch = touches.first, touch.tapCount == 1 {
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)
if let characterIndex = index, characterIndex < textView.textStorage.length {
if NSAttachmentCharacter == (textView.textStorage.string as NSString).character(at: characterIndex) {
attachmentCharacterIndex = characterIndex
attachment = textView.textStorage.attribute(.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment
state = .recognized
} else {
state = .failed
}
}
} else {
state = .failed
}
}
}
extension UITextView {
/// Add an attachment recognizer to a UITTextView
func add(_ attachmentRecognizer: AttachmentTapGestureRecognizer) {
for other in gestureRecognizers ?? [] {
other.require(toFail: attachmentRecognizer)
}
addGestureRecognizer(attachmentRecognizer)
}
}
This same approach could presumably be used for taps on links.

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.
Create your own custom NSTextAttachment and include methods for archiving the image and other settings (PLEASE SHOW ME HOW TOO WHEN YOU FIGURE THIS ONE OUT)
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];
}

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:

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)
}
}

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")
}
}
}

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 found the common way to solve this kind of clicking attachment in attributed string is using UITextView . But the UITextView is so complicated to disable all the menu, the zoom and so on . So I make a simple version of UILabel wrapper to support attachment clicking . It's simple and easy to use&change . the demo is here:
self.attachmentLabel.attributeText = attributeText;
self.attachmentLabel.numberOflines = 2;
self.attachmentLabel.selectBlock = ^(NSInteger attachmentIndex) {
NSLog(#"attachment:%ld called",attachmentIndex);
};
https://github.com/ximmyxiao/TestAttributeStringClick
Hope it will helps:)

Related

Make image clickable in UITextView

I am trying to create a notetaking app where you can click on images as well as edit text normally.
Currently, I have placed a tap gesture recognizer over the UITextView which calls:
#IBAction func onImageTap(_ sender: UITapGestureRecognizer) {
if sender.state == .ended {
let textView = sender.view as! UITextView
let layoutManager = textView.layoutManager
// location of tap in textView coordinates
var location = sender.location(in: textView)
location.x -= textView.textContainerInset.left;
location.y -= textView.textContainerInset.top;
// character index at tap location
let characterIndex = layoutManager.characterIndex(for: location, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
// if index is valid
if characterIndex < textView.textStorage.length {
// check if the tap location has the image attribute
let attributeValue = textView.attributedText.attribute(NSAttributedString.Key.fileType, at: characterIndex, effectiveRange: nil) as? String
// if location does have custom attribute, extract the url of the PDF and perform segue to display PDF
if let value = attributeValue {
if value == "PDF" {
storedFileName = textView.attributedText.attribute(NSAttributedString.Key.fileName, at: characterIndex, effectiveRange: nil) as? String
performSegue(withIdentifier: "displayPDF", sender: textView)
}
} else {
textView.isEditable = true
textView.becomeFirstResponder()
}
}
}
}
This makes all of the images clickable. However, then I stop being able to click anywhere else in the UITextView to edit the text.
What do I do so that the images remain clickable but, if I click anywhere else in the UITextView, I am able to start editing the text?
Assuming you have added the image to the UITextView as an NSAttachment, as of iOS 9.0 you can check whether or not a given NSRange contains an attachment:
For instance, if the image was located at the 25th character:
let characterIndex = 25
let range = NSRange(location: characterIndex, length: 1)
// Using an IBOutlet in the current view controller called 'textView'
if textView.attributedText.containsAttachments(in: range) {
// Do some activity
}

UITextView with Gesture Recognizer - Conditionally Forward Touch to Parent View

I have a UITextView embedded in a UITableViewCell.
The text view has scrolling disabled, and grows in height with the text in it.
The text view has a link-like section of text that is attributed with a different color and underlined, and I have a tap gesture recognizer attached to the text view that detects whether the user tapped on the "link" portion of the text or not (This is accomplished using the text view's layoutManager and textContainerInset to detect whether the tap falls within the 'link' or not. It's basically a custom hit test function).
I want the table view cell to receive the tap and become selected when the user "misses" the link portion of the text view, but can't figure out how to do it.
The text view has userInteractionEnabled set to true. However, this does not block the touches from reaching the table view cell when there is no gesture recognizer attached.
Conversely, if I set it to false, for some reason cell selection stops altogether, even when tapping outside of the text view's bounds (but the gesture recognizer still works... WHY?).
What I've Tried
I have tried overriding gestureRecognizer(_ :shouldReceive:), but even when I return false, the table view cell does not get selected...
I have also tried implementing gestureRecognizerShouldBegin(_:), but there too, even if I perform my hit test and return false, the cell does not get the tap.
How can I forward the missed taps back to the cell, to highlight it?
After trying Swapnil Luktuke's answer(to the extent that I understood it, at least) to no avail, and every possible combination of:
Implementing the methods of UIGestureRecognizerDelegate,
Overriding UITapGestureRecognizer,
Conditionally calling ignore(_:for:), etc.
(perhaps in my desperation I missed something obvious, but who knows...)
...I gave up and decided to follow the suggestion by #danyapata in the comments to my question, and subclass UITextView.
Partly based on code found on this Medium post, I came up with this UITextView subclass:
import UIKit
/**
Detects taps on subregions of its attributed text that correspond to custom,
named attributes.
- note: If no tap is detected, the behavior is equivalent to a text view with
`isUserInteractionEnabled` set to `false` (i.e., touches "pass through"). The
same behavior doesn't seem to be easily implemented using just stock
`UITextView` and gesture recognizers (hence the need to subclass).
*/
class LinkTextView: UITextView {
private var tapHandlersByName: [String: [(() -> Void)]] = [:]
/**
Adds a custom block to be executed wjhen a tap is detected on a subregion
of the **attributed** text that contains the attribute named accordingly.
*/
public func addTapHandler(_ handler: #escaping(() -> Void), forAttribute attributeName: String) {
var handlers = tapHandlersByName[attributeName] ?? []
handlers.append(handler)
tapHandlersByName[attributeName] = handlers
}
// MARK: - Initialization
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
commonSetup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func awakeFromNib() {
super.awakeFromNib()
commonSetup()
}
private func commonSetup() {
self.delaysContentTouches = false
self.isScrollEnabled = false
self.isEditable = false
self.isUserInteractionEnabled = true
}
// MARK: - UIView
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let attributeName = self.attributeName(at: point), let handlers = tapHandlersByName[attributeName], handlers.count > 0 else {
return nil // Ignore touch
}
return self // Claim touch
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
// find attribute name
guard let touch = touches.first, let attributeName = self.attributeName(at: touch.location(in: self)) else {
return
}
// Execute all handlers for that attribute, once:
tapHandlersByName[attributeName]?.forEach({ (handler) in
handler()
})
}
// MARK: - Internal Support
private func attributeName(at point: CGPoint) -> String? {
let location = CGPoint(
x: point.x - self.textContainerInset.left,
y: point.y - self.textContainerInset.top)
let characterIndex = self.layoutManager.characterIndex(
for: location,
in: self.textContainer,
fractionOfDistanceBetweenInsertionPoints: nil)
guard characterIndex < self.textStorage.length else {
return nil
}
let firstAttributeName = tapHandlersByName.allKeys.first { (attributeName) -> Bool in
if self.textStorage.attribute(NSAttributedStringKey(rawValue: attributeName), at: characterIndex, effectiveRange: nil) != nil {
return true
}
return false
}
return firstAttributeName
}
}
As ususal, I'll wait a couple of days before accepting my own answer, just in case something better shows up...
Keep all your views active (i.e. user interaction enabled).
Loop through the text view's gestures and disable the ones you do not need.
Loop through the table view's gestureRecognisers array, and make them depend on the text view's custom tap gesture using requireGestureRecognizerToFail.
If its a static table view, you can do this in view did load. For a dynamic table view, do this in 'willDisplayCell' for the text view cell.

how to detect which textview is being edited swift

I've got two textviews on my view and I want them both to be editable.
But each one pertains to a different record in my database.
How would I be able to detect which textView is being edited?
Here is my code so far
func textViewDidChange(textView: UITextView) { //Handle the text changes here
if(textView.textAlignment == .Center){
PFUser.currentUser()!["bio"] = textView.text
PFUser.currentUser()!.saveInBackground()
}
else{
PFUser.currentUser()!["displayName"] = textView.text
PFUser.currentUser()!.saveInBackground()
}
}
What I'm currently doing is detecting if the view is right aligned or center aligned to be able to tell them apart.
This works but it's not ideal as I'd like to have both of them center aligned. But I'm unaware which field in the textView object would contain an ID or some method of identification as to which textView the function was called for.
As long as you have properties that refer to the two text views you can simply see which one was passed to your delegate and act accordingly:
func textViewDidChange(textView: UITextView) { //Handle the text changes here
guard let currentUser = PFUser.currentUser() else {
return
}
if (textView == self.bioTextView){
currentUser["bio"] = textView.text
currentUser.saveInBackground()
} else {
currentUser["displayName"] = textView.text
currentUser.saveInBackground()
}
}

Method for word determination within UITextView.text and modifying word with prefix using Swift 2.2, iOS 9.3 & Xcode 7?

Using a TextView for user message entry it is editable and selectable during user editing.
One of the buttons below the field, toggles the TextView between editing and hash-tagging mode.
When toggled to tag, the TextView has it's editable & selectable properties disabled, and I have a function to detect taps and returns the character position within the text.
I need to determine the word, if any, tapped on, and modify the word in the UITextView.text by prefixing it with a # unless it already has two hashes it which case it removes the hashes.
I'm using regular expressions for the logic.
I have not been able to find a high level method for determining the word of the character tapped on.
I have searched through the Apple's Dev. Lib. and sites like raywenderlich and Grok Swift, but cannot find the method I am sure must be there.
I could implement by testing if the current charcter is a valid word divider if not then decrement character index and test until the word boundary is determined. At which point, I return to the prior index and test for the # character, in the case it is a #, I would test the next character and in the case it is not a #, I would add the # character to the start of the word.
Is there a function within UIKit, TextKit, or a method of UITextView or NSTextStorage, that will return the word of the character tapped and NSRange of that word?
Also what would be the correct method for adding the # to the TextView's text?
[textView:shouldChangeTextInRange:replacementText or textView.textStorage:replaceCharactersInRange:withString:]
I have worked commercially on PC, PlayStation and GameBoy, but this is the first time developing an app and using the iPhone/Mac platform, so I could really use advice.
for detecting the # you need to call the code inside the delegate shouldChangeCharactersInRange
let stringprocess = stringfordetecting.text
let tok = stringprocess!.componentsSeparatedByString(" ")
for item in tok
{
let demo = String(item)
if demo.hasPrefix("#")
{
let range = (stringfordetecting.text! as NSString).rangeOfString(item)
//add code
}
else
{
//add code
}
for detecting the tapped character index add a guesture to the textview
let tapGesture = UITapGestureRecognizer(target: self, action: "textTapped:")
tapGesture.headline = indexPath
tapGesture.numberOfTapsRequired = 1
textview2.addGestureRecognizer(tapGesture)
func textTapped(recognizer: MyTapGestureRecognizer){
let textView: UITextView = recognizer.view as! UITextView
var layoutManager: NSLayoutManager = textView.layoutManager
var location: CGPoint = recognizer.locationInView(textView)
let position: CGPoint = CGPointMake(location.x, location.y)
location.x -= textview2.textContainerInset.left
location.y -= textview2.textContainerInset.top
var charIndex: Int
charIndex = layoutManager.characterIndexForPoint(location, inTextContainer: textview2.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
if charIndex < textview2.textStorage.length
{
print(charIndex)
}
}
for detecting the tapped character in a textview inside the tapguesture recogniser function
func textTapped(recognizer: MyTapGestureRecognizer){
let textView: UITextView = recognizer.view as! UITextView
var layoutManager: NSLayoutManager = textView.layoutManager
var location: CGPoint = recognizer.locationInView(textView)
let position: CGPoint = CGPointMake(location.x, location.y)
location.x -= cell.messageLabel.textContainerInset.left
location.y -= cell.messageLabel.textContainerInset.top
var charIndex: Int
charIndex = layoutManager.characterIndexForPoint(location, inTextContainer: cell.messageLabel.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
if charIndex < cell.messageLabel.textStorage.length {
let stringprocess = textview.text
let tok = stringprocess.componentsSeparatedByString(" ")
// let attributedString1 = NSMutableAttributedString(string:stringcheck as String)
for item in tok
{
let demo = String(item)
if demo.hasPrefix("#") {
let range = (stringcheck as NSString).rangeOfString(item)
var i = range.location
while i <= range.location+range.length
{
if i == charIndex
{
print(demo)
}
i++
}
}
}
}

How to implement UITableView`s swipe to delete for UICollectionView

I just like to ask how can I implement the same behavior of UITableView`s swipe to delete in UICollectionView. I am trying to find a tutorial but I cannot find any.
Also, I am using PSTCollectionView wrapper to support iOS 5.
Thank you!
Edit:
The swipe recognizer is already good.
What I need now is the same functionality as UITableView's when cancelling the Delete mode, e.g. when user taps on a cell or on a blank space in the table view (that is, when user taps outside of the Delete button).
UITapGestureRecognizer won't work, since it only detects taps on release of a touch.
UITableView detects a touch on begin of the gesture (and not on release), and immediately cancels the Delete mode.
There is a simpler solution to your problem that avoids using gesture recognizers. The solution is based on UIScrollView in combination with UIStackView.
First, you need to create 2 container views - one for the visible part of the cell and one for the hidden part. You’ll add these views to a UIStackView. The stackView will act as a content view. Make sure that the views have equal widths with stackView.distribution = .fillEqually.
You’ll embed the stackView inside a UIScrollView that has paging enabled. The scrollView should be constrained to the edges of the cell. Then you’ll set the stackView’s width to be 2 times the scrollView’s width so each of the container views will have the width of the cell.
With this simple implementation, you have created the base cell with a visible and hidden view. Use the visible view to add content to the cell and in the hidden view you can add a delete button. This way you can achieve this:
I've set up an example project on GitHub. You can also read more about this solution here.
The biggest advantage of this solution is the simplicity and that you don't have to deal with constraints and gesture recognizers.
Its very simple..You need to add a customContentView and customBackgroundView behind the customContentView.
After that and you need to shift the customContentViewto the left as user swipes from right to left. Shifting the view makes visible to the customBackgroundView.
Lets Code:
First of all you need to add panGesture to your UICollectionView as
override func viewDidLoad() {
super.viewDidLoad()
self.panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.panThisCell))
panGesture.delegate = self
self.collectionView.addGestureRecognizer(panGesture)
}
Now implement the selector as
func panThisCell(_ recognizer:UIPanGestureRecognizer){
if recognizer != panGesture{ return }
let point = recognizer.location(in: self.collectionView)
let indexpath = self.collectionView.indexPathForItem(at: point)
if indexpath == nil{ return }
guard let cell = self.collectionView.cellForItem(at: indexpath!) as? CustomCollectionViewCell else{
return
}
switch recognizer.state {
case .began:
cell.startPoint = self.collectionView.convert(point, to: cell)
cell.startingRightLayoutConstraintConstant = cell.contentViewRightConstraint.constant
if swipeActiveCell != cell && swipeActiveCell != nil{
self.resetConstraintToZero(swipeActiveCell!,animate: true, notifyDelegateDidClose: false)
}
swipeActiveCell = cell
case .changed:
let currentPoint = self.collectionView.convert(point, to: cell)
let deltaX = currentPoint.x - cell.startPoint.x
var panningleft = false
if currentPoint.x < cell.startPoint.x{
panningleft = true
}
if cell.startingRightLayoutConstraintConstant == 0{
if !panningleft{
let constant = max(-deltaX,0)
if constant == 0{
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: false)
}else{
cell.contentViewRightConstraint.constant = constant
}
}else{
let constant = min(-deltaX,self.getButtonTotalWidth(cell))
if constant == self.getButtonTotalWidth(cell){
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: false)
}else{
cell.contentViewRightConstraint.constant = constant
cell.contentViewLeftConstraint.constant = -constant
}
}
}else{
let adjustment = cell.startingRightLayoutConstraintConstant - deltaX;
if (!panningleft) {
let constant = max(adjustment, 0);
if (constant == 0) {
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: false)
} else {
cell.contentViewRightConstraint.constant = constant;
}
} else {
let constant = min(adjustment, self.getButtonTotalWidth(cell));
if (constant == self.getButtonTotalWidth(cell)) {
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: false)
} else {
cell.contentViewRightConstraint.constant = constant;
}
}
cell.contentViewLeftConstraint.constant = -cell.contentViewRightConstraint.constant;
}
cell.layoutIfNeeded()
case .cancelled:
if (cell.startingRightLayoutConstraintConstant == 0) {
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: true)
} else {
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: true)
}
case .ended:
if (cell.startingRightLayoutConstraintConstant == 0) {
//Cell was opening
let halfOfButtonOne = (cell.swipeView.frame).width / 2;
if (cell.contentViewRightConstraint.constant >= halfOfButtonOne) {
//Open all the way
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: true)
} else {
//Re-close
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: true)
}
} else {
//Cell was closing
let buttonOnePlusHalfOfButton2 = (cell.swipeView.frame).width
if (cell.contentViewRightConstraint.constant >= buttonOnePlusHalfOfButton2) {
//Re-open all the way
self.setConstraintsToShowAllButtons(cell,animate: true, notifyDelegateDidOpen: true)
} else {
//Close
self.resetConstraintToZero(cell,animate: true, notifyDelegateDidClose: true)
}
}
default:
print("default")
}
}
Helper methods to update constraints
func getButtonTotalWidth(_ cell:CustomCollectionViewCell)->CGFloat{
let width = cell.frame.width - cell.swipeView.frame.minX
return width
}
func resetConstraintToZero(_ cell:CustomCollectionViewCell, animate:Bool,notifyDelegateDidClose:Bool){
if (cell.startingRightLayoutConstraintConstant == 0 &&
cell.contentViewRightConstraint.constant == 0) {
//Already all the way closed, no bounce necessary
return;
}
cell.contentViewRightConstraint.constant = -kBounceValue;
cell.contentViewLeftConstraint.constant = kBounceValue;
self.updateConstraintsIfNeeded(cell,animated: animate) {
cell.contentViewRightConstraint.constant = 0;
cell.contentViewLeftConstraint.constant = 0;
self.updateConstraintsIfNeeded(cell,animated: animate, completionHandler: {
cell.startingRightLayoutConstraintConstant = cell.contentViewRightConstraint.constant;
})
}
cell.startPoint = CGPoint()
swipeActiveCell = nil
}
func setConstraintsToShowAllButtons(_ cell:CustomCollectionViewCell, animate:Bool,notifyDelegateDidOpen:Bool){
if (cell.startingRightLayoutConstraintConstant == self.getButtonTotalWidth(cell) &&
cell.contentViewRightConstraint.constant == self.getButtonTotalWidth(cell)) {
return;
}
cell.contentViewLeftConstraint.constant = -self.getButtonTotalWidth(cell) - kBounceValue;
cell.contentViewRightConstraint.constant = self.getButtonTotalWidth(cell) + kBounceValue;
self.updateConstraintsIfNeeded(cell,animated: animate) {
cell.contentViewLeftConstraint.constant = -(self.getButtonTotalWidth(cell))
cell.contentViewRightConstraint.constant = self.getButtonTotalWidth(cell)
self.updateConstraintsIfNeeded(cell,animated: animate, completionHandler: {(check) in
cell.startingRightLayoutConstraintConstant = cell.contentViewRightConstraint.constant;
})
}
}
func setConstraintsAsSwipe(_ cell:CustomCollectionViewCell, animate:Bool,notifyDelegateDidOpen:Bool){
if (cell.startingRightLayoutConstraintConstant == self.getButtonTotalWidth(cell) &&
cell.contentViewRightConstraint.constant == self.getButtonTotalWidth(cell)) {
return;
}
cell.contentViewLeftConstraint.constant = -self.getButtonTotalWidth(cell) - kBounceValue;
cell.contentViewRightConstraint.constant = self.getButtonTotalWidth(cell) + kBounceValue;
self.updateConstraintsIfNeeded(cell,animated: animate) {
cell.contentViewLeftConstraint.constant = -(self.getButtonTotalWidth(cell))
cell.contentViewRightConstraint.constant = self.getButtonTotalWidth(cell)
self.updateConstraintsIfNeeded(cell,animated: animate, completionHandler: {(check) in
cell.startingRightLayoutConstraintConstant = cell.contentViewRightConstraint.constant;
})
}
}
func updateConstraintsIfNeeded(_ cell:CustomCollectionViewCell, animated:Bool,completionHandler:#escaping ()->()) {
var duration:Double = 0
if animated{
duration = 0.1
}
UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseOut], animations: {
cell.layoutIfNeeded()
}, completion:{ value in
if value{ completionHandler() }
})
}
I have created a sample project here in Swift 3.
It is a modified version of this tutorial.
In the Collection View Programming Guide for iOS, in the section Incorporating Gesture Support, the docs read:
You should always attach your gesture recognizers to the collection view itself and not to a specific cell or view.
So, I think it's not a good practice to add recognizers to UICollectionViewCell.
I followed a similar approach to #JacekLampart, but decided to add the UISwipeGestureRecognizer in the UICollectionViewCell's awakeFromNib function so it is only added once.
UICollectionViewCell.m
- (void)awakeFromNib {
UISwipeGestureRecognizer* swipeGestureRecognizer = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:#selector(swipeToDeleteGesture:)];
swipeGestureRecognizer.direction = UISwipeGestureRecognizerDirectionLeft;
[self addGestureRecognizer:swipeGestureRecognizer];
}
- (void)swipeToDeleteGesture:(UISwipeGestureRecognizer *)swipeGestureRecognizer {
if (swipeGestureRecognizer.state == UIGestureRecognizerStateEnded) {
// update cell to display delete functionality
}
}
As for exiting delete mode, I created a custom UIGestureRecognizer with an NSArray of UIViews. I borrowed the idea from #iMS from this question: UITapGestureRecognizer - make it work on touch down, not touch up?
On touchesBegan, if the touch point isn't within any of the UIViews, the gesture succeeds and delete mode is exited.
In this way, I am able to pass the delete button within the cell (and any other views) to the UIGestureRecognizer and, if the touch point is within the button's frame, delete mode will not exit.
TouchDownExcludingViewsGestureRecognizer.h
#import <UIKit/UIKit.h>
#interface TouchDownExcludingViewsGestureRecognizer : UIGestureRecognizer
#property (nonatomic) NSArray *excludeViews;
#end
TouchDownExcludingViewsGestureRecognizer.m
#import "TouchDownExcludingViewsGestureRecognizer.h"
#import <UIKit/UIGestureRecognizerSubclass.h>
#implementation TouchDownExcludingViewsGestureRecognizer
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
if (self.state == UIGestureRecognizerStatePossible) {
BOOL touchHandled = NO;
for (UIView *view in self.excludeViews) {
CGPoint touchLocation = [[touches anyObject] locationInView:view];
if (CGRectContainsPoint(view.bounds, touchLocation)) {
touchHandled = YES;
break;
}
}
self.state = (touchHandled ? UIGestureRecognizerStateFailed : UIGestureRecognizerStateRecognized);
}
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
self.state = UIGestureRecognizerStateFailed;
}
-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
self.state = UIGestureRecognizerStateFailed;
}
#end
Implementation (in the UIViewController containing UICollectionView):
#import "TouchDownExcludingViewsGestureRecognizer.h"
TouchDownExcludingViewsGestureRecognizer *touchDownGestureRecognizer = [[TouchDownExcludingViewsGestureRecognizer alloc] initWithTarget:self action:#selector(exitDeleteMode:)];
touchDownGestureRecognizer.excludeViews = #[self.cellInDeleteMode.deleteButton];
[self.view addGestureRecognizer:touchDownGestureRecognizer];
- (void)exitDeleteMode:(TouchDownExcludingViewsGestureRecognizer *)touchDownGestureRecognizer {
// exit delete mode and disable or remove TouchDownExcludingViewsGestureRecognizer
}
You can try adding a UISwipeGestureRecognizer to each collection cell, like this:
-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
CollectionViewCell *cell = ...
UISwipeGestureRecognizer* gestureRecognizer = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:#selector(userDidSwipe:)];
[gestureRecognizer setDirection:UISwipeGestureRecognizerDirectionRight];
[cell addGestureRecognizer:gestureRecognizer];
}
followed by:
- (void)userDidSwipe:(UIGestureRecognizer *)gestureRecognizer {
if (gestureRecognizer.state == UIGestureRecognizerStateEnded) {
//handle the gesture appropriately
}
}
With iOS 14, you can use UICollectionViewLayoutListConfiguration in conjunction with UICollectionViewCompositionalLayout to get this functionality natively for free, no custom cells or gesture recognizes needed.
If your minimum deploy target is >= iOS 14.x, this is probably the preferred method from now on, and it will also let you to adopt modern cell configuration with UIContentView and UIContentConfiguration to boot.
There is a more standard solution to implement this feature, having a behavior very similar to the one provided by UITableView.
For this, you will use a UIScrollView as the root view of the cell, and then position the cell content and the delete button inside the scroll view. The code in your cell class should be something like this:
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(scrollView)
scrollView.addSubview(viewWithCellContent)
scrollView.addSubview(deleteButton)
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
}
In this code we set the property isPagingEnabled to true to make the scroll view to stop scrolling only at the boundaries of its content. The layout subviews for this cell should be something like:
override func layoutSubviews() {
super.layoutSubviews()
scrollView.frame = bounds
// make the view with the content to fill the scroll view
viewWithCellContent.frame = scrollView.bounds
// position the delete button just at the right of the view with the content.
deleteButton.frame = CGRect(
x: label.frame.maxX,
y: 0,
width: 100,
height: scrollView.bounds.height
)
// update the size of the scrolleable content of the scroll view
scrollView.contentSize = CGSize(width: button.frame.maxX, height: scrollView.bounds.height)
}
With this code in place, if you run the app you will see that the swipe to delete is working as expected, however, we lost the ability to select the cell. The problem is that since the scroll view is filling the whole cell, all the touch events are processed by it, so the collection view will never have the opportunity to select the cell (this is similar to when we have a button inside a cell, since touches on that button don't trigger the selection process but are handled directly by the button.)
To fix this problem we just have to indicate the scroll view to ignore the touch events that are processed by it and not by one of its subviews. To achieve this just create a subclass of UIScrollView and override the following function:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
return result != self ? result : nil
}
Now in your cell you should use an instance of this new subclass instead of the standard UIScrollView.
If you run the app now you will see that we have the cell selection back, but this time the swipe isn't working 😳. Since we are ignoring touches that are handled directly by the scroll view, then its pan gesture recognizer won't be able to start recognizing touch events. However, this can be easily fixed by indicating to the scroll view that its pan gesture recognizer will be handled by the cell and not by the scroll. You do this adding the following line at the bottom of your cell's init(frame: CGRect):
addGestureRecognizer(scrollView.panGestureRecognizer)
This may look like a bit hacky, but it isn't. By design, the view that contains a gesture recognizer and the target of that recognizer don't have to be the same object.
After this change all should be working as expected. You can see a full implementation of this idea in this repo

Resources