19

I know that this problem has been solved in Objective-C, but I haven't seen any solution to it in Swift. I have tried to convert the solution code from this post, but I'm getting errors:

func textTapped(recognizer: UITapGestureRecognizer){

    var textView: UITextView = recognizer.view as UITextView
    var layoutManager: NSLayoutManager = textView.layoutManager
    var location: CGPoint = recognizer.locationInView(textView)
    location.x -= textView.textContainerInset.left
    location.y -= textView.textContainerInset.top

    var charIndex: Int
    charIndex = layoutManager.characterIndexForPoint(location, inTextContainer: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

    if charIndex < textView.textStorage.length {
        // do the stuff
        println(charIndex)
    }
}

I think the problem is in this line (see error here):

 var textView: UITextView = recognizer.view as UITextView

... which I have converted from Objective-C based on this line:

 UITextView *textView = (UITextView *)recognizer.view;

Finally, I am also in doubt about how this function should be called. As I understand it, the function should be passed to a Selector in viewDidLoad(), like this:

 let aSelector: Selector = "textTapped:"   

 let tapGesture = UITapGestureRecognizer(target: self, action: aSelector)
 tapGesture.numberOfTapsRequired = 1
 view.addGestureRecognizer(tapGesture)

Because I'm getting the before mentioned error, I'm not sure if it would work. But I'm thinking that I would need to pass the parameter in the textTapped function (recognizer) into the Selector as well. However, I've read that you can only pass the function and not any parameters.

Shruti Thombre
  • 989
  • 4
  • 11
  • 27
Benjamin Hviid
  • 463
  • 2
  • 5
  • 13

3 Answers3

23

For Swift 3.0 OR Above

add Tap gesture to UITextView

let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapOnTextView(_:)))
textView.addGestureRecognizer(tapGesture)

add tap handler method

@objc private final func tapOnTextView(_ tapGesture: UITapGestureRecognizer){

  let point = tapGesture.location(in: textView)
  if let detectedWord = getWordAtPosition(point)
  {

  }
}

get word from point

private final func getWordAtPosition(_ point: CGPoint) -> String?{
if let textPosition = textView.closestPosition(to: point)
{
  if let range = textView.tokenizer.rangeEnclosingPosition(textPosition, with: .word, inDirection: 1)
  {
    return textView.text(in: range)
  }
}
return nil}
Hiren Panchal
  • 2,963
  • 1
  • 25
  • 21
10

You need to add the UITapGestureRecognizer to the UITextView that you want to be able to tap. You are presently adding the UITapGestureRecognizer to your ViewController's view. That is why the cast is getting you into trouble. You are trying to cast a UIView to a UITextView.

let tapGesture = UITapGestureRecognizer(target: self, action: #selector(textTapped))
tapGesture.numberOfTapsRequired = 1
myTextView.addGestureRecognizer(tapGesture)

Technically recognizer.view is an optional type (UIView!) and could be nil, but it seems unlikely that your textTapped() would be called it that wasn't set. Likewise, the layoutManager is of type NSLayoutManager!. To be on the safe side though, the Swift way to do this is:

guard let textView = recognizer.view as? UITextView, let layoutManager = textView.layoutManager else {
    return
}
// code using textView and layoutManager goes here

In fact, if you had written it this way, you wouldn't have crashed because the conditional cast of the UIView to UITextView would not have succeeded.

To make this all work then, add attributes to your attributed string that you will extract in your textTapped routine:

var beginning = NSMutableAttributedString(string: "To the north you see a ")
var attrs = [NSFontAttributeName: UIFont.systemFontOfSize(19.0), "idnum": "1", "desc": "old building"]
var condemned = NSMutableAttributedString(string: "condemned building", attributes: attrs)
beginning.appendAttributedString(condemned)
attrs = [NSFontAttributeName: UIFont.systemFontOfSize(19.0), "idnum": "2", "desc": "lake"]
var lake = NSMutableAttributedString(string: " on a small lake", attributes: attrs)
beginning.appendAttributedString(lake)
myTextView.attributedText = beginning

Here's the full textTapped:

@objc func textTapped(recognizer: UITapGestureRecognizer) {
    guard let textView = recognizer.view as? UITextView, let layoutManager = textView.layoutManager else {
        return
    }
    var location: CGPoint = recognizer.locationInView(textView)
    location.x -= textView.textContainerInset.left
    location.y -= textView.textContainerInset.top

    /* 
    Here is what the Documentation looks like :

    Returns the index of the character falling under the given point,    
    expressed in the given container's coordinate system.  
    If no character is under the point, the nearest character is returned, 
    where nearest is defined according to the requirements of selection by touch or mouse.  
    This is not simply equivalent to taking the result of the corresponding 
    glyph index method and converting it to a character index, because in some 
    cases a single glyph represents more than one selectable character, for example an fi ligature glyph.
    In that case, there will be an insertion point within the glyph, 
    and this method will return one character or the other, depending on whether the specified 
    point lies to the left or the right of that insertion point.  
    In general, this method will return only character indexes for which there 
    is an insertion point (see next method).  The partial fraction is a fraction of the distance 
    from the insertion point logically before the given character to the next one, 
    which may be either to the right or to the left depending on directionality.
    */
    var charIndex = layoutManager.characterIndexForPoint(location, inTextContainer: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

    guard charIndex < textView.textStorage.length else {
        return
    }

    var range = NSRange(location: 0, length: 0)
    if let idval = textView.attributedText?.attribute("idnum", atIndex: charIndex, effectiveRange: &range) as? NSString {
        print("id value: \(idval)")
        print("charIndex: \(charIndex)")
        print("range.location = \(range.location)")
        print("range.length = \(range.length)")
        let tappedPhrase = (textView.attributedText.string as NSString).substringWithRange(range)
        print("tapped phrase: \(tappedPhrase)")
        var mutableText = textView.attributedText.mutableCopy() as NSMutableAttributedString
        mutableText.addAttributes([NSForegroundColorAttributeName: UIColor.redColor()], range: range)
        textView.attributedText = mutableText
    }
    if let desc = textView.attributedText?.attribute("desc", atIndex: charIndex, effectiveRange: &range) as? NSString {
        print("desc: \(desc)")
    }
}
vacawama
  • 150,663
  • 30
  • 266
  • 294
  • If you look at the code in Objective-C (link in my initial post) he's managing NS Range and id value, apparently in order to collect the string, which was tapped. Would you be able to explain how to approach this in Swift as I'm not familiar with the id value notion. My purpose is to get the string, highlight it when tapped (using NSAttributedString) and load another ViewController based upon the value of the string. – Benjamin Hviid Aug 23 '14 at 22:01
  • I have tried to fiddle around with it for a little time now, but I still can't get the grasp of it (formatting code in comments is cumbersome, to link to code is [here](http://pbrd.co/1t30rKc)). It gives "fatal error: unexpectedly found nil while unwrapping an Optional value", though I think I've written the optional correct. – Benjamin Hviid Aug 23 '14 at 23:29
  • How are you creating the attributed string that is in the textView? – vacawama Aug 24 '14 at 01:09
  • The attributed string is a variable in my Location class ([click here to see it](http://pbrd.co/XIW7D2)). I then make an instance of Location in the ViewController like this: `var someLocation = Location(locationName:"Some place", id: 1)` and add the attributed string to the UITextView in viewDidLoad() like this: `someTextView.attributedText = someLocation.description` – Benjamin Hviid Aug 24 '14 at 07:47
  • @wacawama, it works! However, if my TextView is larger than the inputted text and I tap beneath the text (on the empty "whitespace" in the TextView), it will print out the last string. It appears that it is fetching the last charIndex. Is there a way to disregard the tap if it is not directly on top of the text? I have tried setting `textView.textContainer.size = textView.contentSize`, which does not work. The best option seems to be to set the if statement to be `if charIndex < textView.textStorage.length - 1`, but then I can't tap the last character either. – Benjamin Hviid Aug 24 '14 at 17:07
  • Weird. OK, here's the workaround. Do the `-1` and make your last character a space. If don't want the space as part of your last string, add an additional string that is just the space. – vacawama Aug 24 '14 at 17:30
  • Yes it works fine by putting an extra space at the end. Lastly, how would you go about referencing the variable of the tapped string? I would like to be able to edit the attributes of the strings whenever I tap them, so the user gets a bit of feedback. Can I in some way make a similar variable in ViewController.swift and pass the memory address of textView.attributedText to get hold of the original variable from Location (i.e. after tappedPhrase is declared)? I can't figure out, if possible, to work with pointers in Swift. – Benjamin Hviid Aug 24 '14 at 18:03
  • See edit above. I added code to turn the text red when it is selected. You can either use `setAttributes` to replace all attributes in the range, or `addAttributes` to add additional ones to the range. – vacawama Aug 24 '14 at 18:58
  • I've been trying to make it so that only one string (the most recent tap) will be highlighted at the same time, but I can't make it happen. [You can see my code here](http://pastebin.com/rPrJHCpH). I've checked that the hashValue of the tapped string matches item.hashValue, so i'm referencing the original variable (most likely!). – Benjamin Hviid Aug 24 '14 at 22:54
  • How about just updating the whole string to black before updating the subrange to red, or whatever color you are using. – vacawama Aug 24 '14 at 22:56
  • It could be the way to go, but, if possible, I would like to keep each individual string attribute/font color, instead of making all strings (apart from the tapped) the same, – Benjamin Hviid Aug 24 '14 at 23:00
  • The other thing you could do is store the `range` in a `previousRange` variable in your ViewController and then reset that range back to a non-bold font before updating the new range to bold. – vacawama Aug 24 '14 at 23:25
  • For some reason it starts giving me an "unexpectedly found nil" error in the `if let idval = textView.attributedText.attribute(...)` part that you provided me with. The error is triggered whenever I tap the text. I have tried restarting, deleting and making a new ViewController (helped a similar issue one time), repasting your code snippets etc. I though that the way the if statement was set up as an optional should prevent this error? Do you have any idea what might cause this behavior? [full source code here](http://pastebin.com/4nkktshw) and [screenshot of error here](http://pbrd.co/YTQi60) – Benjamin Hviid Aug 25 '14 at 10:04
  • What happens if you add a `?` like so: `if let idval = textView.attributedText?.attribute(...)` – vacawama Aug 25 '14 at 11:06
  • I get a "Type Int does not conform to AnyObject Protocol" error – Benjamin Hviid Aug 25 '14 at 11:14
  • I changed `idnum` to be an `NSString`. See the edits above. Integers are not objects so they don't belong in a Dictionary of attribute values. My mistake. – vacawama Aug 25 '14 at 11:31
  • I have tried your edited code, but now it never gets inside the if statement. – Benjamin Hviid Aug 25 '14 at 12:32
  • OK. That tells me your `attributedText` is `nil`. You need to debug that and find out why. – vacawama Aug 25 '14 at 13:35
  • If copied everything into a fresh project, and it will now print the `desc` part, but it does not reach or print the stuff inside the `idnum` part, even though both let if statements are the same apart from the first parameter in attribute(...) being desc and idnum, of course. The code is exactly as the one that you've updated. The var that contain the idnum and desc look like this `var attrs = [NSFontAttributeName: UIFont(name: "HelveticaNeue-Light", size: 19), "idnum": 1, "desc": "keep going"]`. It seems weird that it can detect desc but not idnum? Changing the name of idnum does not work. – Benjamin Hviid Aug 25 '14 at 14:14
  • Ah, `idnum` is an Int, while the let if statement is looking for an NSString. Placing the `idnum` values in " " did the trick. – Benjamin Hviid Aug 25 '14 at 14:23
  • I concidered starting a new question about this, but thought to ask here first. When some words/sentences are tapped, the user should get noticed by a fade in/out of the text's alpha value. As I understand it, you can only change the alpha value of the entire TextView. Is it possible to change the text's alpha value over time within a certain range? – Benjamin Hviid Aug 25 '14 at 20:53
  • I'd recommend you ask a new question. I'd personally have to research that answer so it would take a while before you got an answer from me. – vacawama Aug 25 '14 at 21:15
  • If i tap on white space beyond UITextView then it is still showing the character index of last word in UITextView. How to fix it, any help is highly appreciated thanks. – Salman Khakwani Nov 03 '16 at 05:06
  • characterIndexForPoint returns nearest character index incase no character index is found. I have edited the answer and added it's Documentation. – Salman Khakwani Nov 03 '16 at 05:13
1

Swift 5> solution:

// Add this somewhere in the init of the UITextView subclass
let tapGesture = UITapGestureRecognizer(
    target: self,
    action: #selector(textTapped)
)
tapGesture.numberOfTapsRequired = 1
addGestureRecognizer(tapGesture)

// A method in your UITextView subclass
func textTapped(recognizer: UITapGestureRecognizer) {
    let point = recognizer.location(in: self)

    guard
        let textPosition = closestPosition(to: point),
        let range = tokenizer.rangeEnclosingPosition(
            textPosition,
            with: .word,
            inDirection: .init(rawValue: 1)
        ) else {
        return
    }

    print(text(in: range) ?? "Not found, this should not happen")
}
J. Doe
  • 12,159
  • 9
  • 60
  • 114