Selecting a Word in a Uitextview

Selecting a word in a UITextView

Update

This got a lot easier for iOS 7, thanks to the addition of NSLayoutManager in CoreText. If you're dealing with a UITextView you can access the layout manager as a property of the view. In my case I wanted to stick with a UILabel, so you have to create a layout manager with the same size, i.e:

NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:labelText];
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
[textStorage addLayoutManager:layoutManager];
CGRect bounds = label.bounds;
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:bounds.size];
[layoutManager addTextContainer:textContainer];

Now you just need to find the index of the character that was clicked, which is simple!

NSUInteger characterIndex = [layoutManager characterIndexForPoint:location
inTextContainer:textContainer
fractionOfDistanceBetweenInsertionPoints:NULL];

Which makes it trivial to find the word itself:

if (characterIndex < textStorage.length) {
[labelText.string enumerateSubstringsInRange:NSMakeRange(0, textStorage.length)
options:NSStringEnumerationByWords
usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
if (NSLocationInRange(characterIndex, enclosingRange)) {
// Do your thing with the word, at range 'enclosingRange'
*stop = YES;
}
}];
}

Original Answer, which works for iOS < 7

Thanks to @JP Hribovsek for some tips getting this working, I managed to solve this well enough for my purposes. It feels a little hacky, and likely wouldn't work too well for large bodies of text, but for paragraphs at a time (which is what I need) it's fine.

I created a simple UILabel subclass that allows me to set the inset value:

#import "WWLabel.h"

#define WWLabelDefaultInset 5

@implementation WWLabel

@synthesize topInset, leftInset, bottomInset, rightInset;

- (id)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
self.topInset = WWLabelDefaultInset;
self.bottomInset = WWLabelDefaultInset;
self.rightInset = WWLabelDefaultInset;
self.leftInset = WWLabelDefaultInset;
}
return self;
}

- (void)drawTextInRect:(CGRect)rect
{
UIEdgeInsets insets = {self.topInset, self.leftInset,
self.bottomInset, self.rightInset};

return [super drawTextInRect:UIEdgeInsetsInsetRect(rect, insets)];
}

Then I created a UIView subclass that contained my custom label, and on tap constructed the size of the text for each word in the label, until the size exceeded that of the tap location - this is the word that was tapped. It's not prefect, but works well enough for now.

I then used a simple NSAttributedString to highlight the text:

#import "WWPhoneticTextView.h"
#import "WWLabel.h"

#define WWPhoneticTextViewInset 5
#define WWPhoneticTextViewDefaultColor [UIColor blackColor]
#define WWPhoneticTextViewHighlightColor [UIColor yellowColor]

#define UILabelMagicTopMargin 5
#define UILabelMagicLeftMargin -5

@implementation WWPhoneticTextView {
WWLabel *label;
NSMutableAttributedString *labelText;
NSRange tappedRange;
}

// ... skipped init methods, very simple, just call through to configureView

- (void)configureView
{
if(!label) {
tappedRange.location = NSNotFound;
tappedRange.length = 0;

label = [[WWLabel alloc] initWithFrame:[self bounds]];
[label setLineBreakMode:NSLineBreakByWordWrapping];
[label setNumberOfLines:0];
[label setBackgroundColor:[UIColor clearColor]];
[label setTopInset:WWPhoneticTextViewInset];
[label setLeftInset:WWPhoneticTextViewInset];
[label setBottomInset:WWPhoneticTextViewInset];
[label setRightInset:WWPhoneticTextViewInset];

[self addSubview:label];
}

// Setup tap handling
UITapGestureRecognizer *singleFingerTap = [[UITapGestureRecognizer alloc]
initWithTarget:self action:@selector(handleSingleTap:)];
singleFingerTap.numberOfTapsRequired = 1;
[self addGestureRecognizer:singleFingerTap];
}

- (void)setText:(NSString *)text
{
labelText = [[NSMutableAttributedString alloc] initWithString:text];
[label setAttributedText:labelText];
}

- (void)handleSingleTap:(UITapGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateEnded)
{
// Get the location of the tap, and normalise for the text view (no margins)
CGPoint tapPoint = [sender locationInView:sender.view];
tapPoint.x = tapPoint.x - WWPhoneticTextViewInset - UILabelMagicLeftMargin;
tapPoint.y = tapPoint.y - WWPhoneticTextViewInset - UILabelMagicTopMargin;

// Iterate over each word, and check if the word contains the tap point in the correct line
__block NSString *partialString = @"";
__block NSString *lineString = @"";
__block int currentLineHeight = label.font.pointSize;
[label.text enumerateSubstringsInRange:NSMakeRange(0, [label.text length]) options:NSStringEnumerationByWords usingBlock:^(NSString* word, NSRange wordRange, NSRange enclosingRange, BOOL* stop){

CGSize sizeForText = CGSizeMake(label.frame.size.width-2*WWPhoneticTextViewInset, label.frame.size.height-2*WWPhoneticTextViewInset);
partialString = [NSString stringWithFormat:@"%@ %@", partialString, word];

// Find the size of the partial string, and stop if we've hit the word
CGSize partialStringSize = [partialString sizeWithFont:label.font constrainedToSize:sizeForText lineBreakMode:label.lineBreakMode];

if (partialStringSize.height > currentLineHeight) {
// Text wrapped to new line
currentLineHeight = partialStringSize.height;
lineString = @"";
}
lineString = [NSString stringWithFormat:@"%@ %@", lineString, word];

CGSize lineStringSize = [lineString sizeWithFont:label.font constrainedToSize:label.frame.size lineBreakMode:label.lineBreakMode];
lineStringSize.width = lineStringSize.width + WWPhoneticTextViewInset;

if (tapPoint.x < lineStringSize.width && tapPoint.y > (partialStringSize.height-label.font.pointSize) && tapPoint.y < partialStringSize.height) {
NSLog(@"Tapped word %@", word);
if (tappedRange.location != NSNotFound) {
[labelText addAttribute:NSForegroundColorAttributeName value:[UIColor blackColor] range:tappedRange];
}

tappedRange = wordRange;
[labelText addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:tappedRange];
[label setAttributedText:labelText];
*stop = YES;
}
}];
}
}

Get currently typed word in a UITextView

The UITextView delegate method : -textView:textView shouldChangeTextInRange:range replacementText:text what actaully does is that it asks whether the specified text should be replaced in the text view in the specified range of textView.text .

This method will be invoked each time when we type a charactor before updating that to the text view. That is why you are getting the range.location as 0, when you type the very first character in the textView.

Only if the return of this method is true, the textView is getting updated with what we have typed in the textView.

This is the definition of the parameters of the -textView:textView shouldChangeTextInRange:range replacementText:text method as provided by apple:

range :- The current selection range. If the length of the range is 0, range reflects the current insertion point. If the user presses the Delete key, the length of the range is 1 and an empty string object replaces that single character.

text :- The text to insert.

So this is what the explanation for the method and your requirement can meet like as follows:

- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
{
//Un-commend this check below :If you want to detect the word only while new line or white space charactor input
//if ([text isEqualToString:@" "] || [text isEqualToString:@"\n"])
//{
// Getting the textView text upto the current editing location
NSString * stringToRange = [textView.text substringWithRange:NSMakeRange(0,range.location)];

// Appending the currently typed charactor
stringToRange = [stringToRange stringByAppendingString:text];

// Processing the last typed word
NSArray *wordArray = [stringToRange componentsSeparatedByString:@" "];
NSString * wordTyped = [wordArray lastObject];

// wordTyped will give you the last typed object
NSLog(@"\nWordTyped : %@",wordTyped);
//}
return YES;
}

Get word from character index in textView (iOS)

The issue apparently seemed to stem from the .x and .y values I set after getting location. All I had to do was remove these lines:

location.x -= textView.textContainerInset.left
location.y -= textView.textContainerInset.top

Get tapped word from UITextView in Swift

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

changin a word format in UITextView from keyboard input when the word begins with @

This will check the last word after every space for a word that has '@' in it:

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if text == " ",let lastWord = textView.text.components(separatedBy: " ").last, lastWord.contains("@"), let lastWordRange = (textView.text as? NSString)?.range(of: lastWord){
let attributes = [NSForegroundColorAttributeName: UIColor.blue, NSFontAttributeName: self.textView.font!] as [String : Any]
let attributedString = NSMutableAttributedString(string: lastWord, attributes: attributes)
textView.textStorage.replaceCharacters(in:lastWordRange, with:attributedString)
}
return true
}

How can I change style of some words in my UITextView one by one in Swift?

Use a timer. Stash matches in a property. Stash the base unhighlighted attributed string in a property. Now have your timer highlight the first match and call itself again in 1 second, highlighting up to the second match and repeat until there are no matches left.

    func highlight (to index: Int = 0) {
guard index < matches.count else {
return
}
let titleDict: NSDictionary = [NSForegroundColorAttributeName: orangeColor]
let attributedString = NSMutableAttributedString(attributedString: storedAttributedString)
for i in 0..< index {
let matchRange = matches[i].rangeAt(0)
attributedString.addAttributes(titleDict as! [String : AnyObject], range: matchRange)
}
self.attributedText = attributedString
let _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
self.highlight(to: index + 1)
}
}


Related Topics



Leave a reply



Submit