enumerateLineFragmentsForGlyphRange:withBlock: returns word fragments - ios

I'm using a UITextView to display some text. In laying out the text, I enumerate the lines of text using the enumerateLineFragmentsForGlyphRange:withBlock: method.
NSInteger shrunkNumberOfLines = 3;
__block NSMutableString *shortenedText = [NSMutableString new];
__block NSInteger currentLine = 0;
__block BOOL needsTruncation = NO;
[detailsTableViewCell.descriptionTextView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, text.length) usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *textContainer, NSRange glyphRange, BOOL *stop) {
if (currentLine < shrunkNumberOfLines) {
NSRange stringRange = ((glyphRange.length + glyphRange.location) <= text.length) ? glyphRange : NSMakeRange(glyphRange.location, (text.length - glyphRange.location));
NSString *appendString = [text substringWithRange:stringRange];
NSLog(#"%#", appendString);
[shortenedText appendString:appendString];
currentLine += 1;
} else {
needsTruncation = YES;
*stop = YES;
}
}];
However, I'm running into a weird bug: oftentimes, the text that gets displayed in the textview doesn't line up with the text that I see in that appendString.
For example, the text in the textfield might say something like:
President Obama offered a
blueprint for deeper American
engagement in the Middle East.
...but looking at my NSLog statements, those appendStrings are something like:
President Obama offered a blu
eprint for deeper American en
gagement in the Middle East.
I've tried a bunch of things - playing with hyphenationFactor, making sure that the textContainerInsets are correct, etc - but I can't figure this out. What's causing invalid line breaks in the enumerateLineFragmentsForGlyphRange:withBlock: method?

While I'm still not sure what caused the underlying issue above, I've at least found something that solves the symptom: https://stackoverflow.com/a/19603172/686902

Related

Multiline UILabel without word wrap?

Is it possible to have a UILabel that consists of multiple \n-delimited lines to have lines with their width > the label width to be truncated and not wrapped?
Suppose I have some text like the following:
This is a really long first line of text that is too long to fit horizontally
Short line
Another short line
I want this to appear in my UILabel like so:
1. This is a really long first line of text...
2. Short line
3. Another short line
However what is happening is I'm getting this:
1. This is a really long first line of text
that is too long to fit horizontally
2. Short line...
The third line is getting cut off. I've set the number of lines to 3, but it is still wrapping the first long line. It doesn't seem to matter what I set the line breaks property to on the label--it always wraps that first line. Is there any way to prevent wrapping completely on the label?
I don't think this is possible with any setting you can apply to your label. One way to do it, is to break the string into its individual lines, truncate any line that needs it so that it (with an added ellipsis) fits on one line, then put the string back together with line breaks. Something like this should work for any number of lines,
#interface ViewController ()
#property (weak, nonatomic) IBOutlet UILabel *label;
#property (nonatomic) CGFloat ellipsisWidth;
#end
#implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSString *text = #"This is a really long first line of text that is too long to fit horizontally\nShort line\nAnother short line";
NSString *ellipsis = #"...";
self.ellipsisWidth = [ellipsis sizeWithAttributes:#{NSFontAttributeName:self.label.font}].width;
__block NSMutableString *truncatedString = [#"" mutableCopy];
[text enumerateLinesUsingBlock:^(NSString *line, BOOL *stop) {
[truncatedString appendFormat:#"%#\n", [self oneLineOfString:line withFont:self.label.font]];
}];
NSString *finalString = [truncatedString stringByTrimmingCharactersInSet:[NSCharacterSet newlineCharacterSet]];
self.label.numberOfLines = 0;
self.label.text = finalString;
}
-(NSString *)oneLineOfString:(NSString *) aLine withFont:(UIFont *) font {
__block NSString *singleLine = nil;
__block NSString *lastFragment;
[aLine enumerateSubstringsInRange:NSMakeRange(0, aLine.length) options:NSStringEnumerationByWords usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
NSString *textFragment = [aLine substringToIndex:(substringRange.location + substringRange.length)];
CGRect textRect = [textFragment boundingRectWithSize:CGSizeMake(CGFLOAT_MAX ,CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:#{NSFontAttributeName:font} context:nil];
if (textRect.size.width >= self.label.bounds.size.width - self.ellipsisWidth) {
singleLine = [lastFragment stringByAppendingString:#"..."];
*stop = YES;
}
lastFragment = textFragment;
}];
if (!singleLine) singleLine = aLine; // it doesn't need to be truncated, so return the passed in line
return singleLine;
}
If you want to truncate by character instead of by word, you can pass NSStringEnumerationByComposedCharacterSequences instead of NSStringEnumerationByWords to the options parameter of
enumerateSubstringsInRange:options:usingBlock:.
Of course, you could do it the easy way; stack 3 labels on top of each other, and give each one a line of your text :)

Old RPG scrolling text, but break up text blocks with (dot dot dot)

In old RPGs, the dialogue text would type on screen, and if there was more than could fit on the page there'd be a ... and you'd press to NEXT to continue reading.
I've got a lot of this already working. What I'm stuck on is I need a block of text to programmatically know how to break itself up from page to page using the ...
Normally, the easy route would be to just specify in the dialogue where the breaks should be, but for this specific project I have to allow it to take in a large block of text, then break it into the correct sizes for each page.
Anyone have any thoughts on how to do this? Just counting characters won't work because the font won't be monospaced.
I was having a hell of a time trying to search for this. Found this, but it didn't answer my question.
Much appreciated!
One solution could be:
separate all the words from the big text into an array
go through of it and search for the boundaries based on textbox height
You can implement it eg.:
CGFloat w = 200.0; // RPG textbox width. Get it from actual UI object.
CGFloat h = 150.0; // RPG textbox height. Get it from actual UI object.
NSArray *words = [yourRPGtext componentsSeparatedByString:#" "];
NSString *cur_txt = [words objectAtIndex:0];
int i = 1;
RPG_txt_pages = [[NSMutableArray alloc] init];
while (i < [words count]) {
NSString *next_txt = [cur_txt stringByAppendingFormat:#" %#",[words objectAtIndex:i]];
CGSize size = [next_txt sizeWithFont:yourRPGtextlable.font
constrainedToSize:CGSizeMake(w, 99999)
lineBreakMode:UILineBreakModeWordWrap];
if (size.height > h) {
cur_txt = [cur_txt stringByAppendingString:#"..."];
[RPG_txt_pages addObject:cur_txt];
cur_txt = [words objectAtIndex:i];
} else {
cur_txt = next_txt;
}
i++;
}
[RPG_txt_pages addObject:curText];
The key here is NSString's sizeWithFont method: here is the link to the docs.
IOS7 comment: sizeWithFont is deprecated you can use sizeWithAttributes. Here is an SO answer on this.
If you tell what IOS version are you using I'll modify this answer. Hope it helped!

UITextView get the current line

Is there a way (crazy hacks welcome) to get the current line as a string of a UITextView? This would include word wrapping, etc. For example, in this case:
The method would return "stack overflow. Isn't it great? I" because that is the current line based on the cursor.
It could also return "This is a test I made for" or "think so", based on the position of the cursor. I have tried working with both the UITextView methods and those of UITextInput protocol.
EDIT:
Here is the code I have attempted to use. The reason I need to find the string is to get it's length, so this is why you'll see UI based code.
NSRange location = self.textView.selectedRange;
NSString *searchString = [self.textView.text substringWithRange:NSMakeRange(0, location)];
CGSize currentStringDimensions = [searchString sizeWithFont:self.textView.font constrainedToSize:CGSizeMake(self.textView.frame.size.width, self.textView.frame.size.height) lineBreakMode:NSLineBreakByWordWrapping];
float numberOfRows = (currentStringDimensions.width/(self.textView.frame.size.width));
float left = (float)(numberOfRows - (int)numberOfRows) * (self.textView.frame.size.width);
This doesn't work, however. I think it might have something to with words being wrapped or the differently sized characters, but the left value is inconsistent or off after the first line.
The following code solution seem to be working. The "self" in this code refers to an instance of UITextView.
- (NSString *) getLineString:(NSRange)range
{
NSLayoutManager *manager = self.layoutManager;
// Convert it to a glyph range
NSRange matchingGlyphRange = [manager glyphRangeForCharacterRange:range actualCharacterRange:NULL];
// line fragment rect at location range
CGRect rect = [manager lineFragmentRectForGlyphAtIndex:matchingGlyphRange.location effectiveRange:nil];
// obtain the line range for the line fragment rect
NSRange lineRange = [manager glyphRangeForBoundingRect:rect inTextContainer:self.textContainer];
// extract the string out from lineRange
return [self.text substringWithRange:lineRange];
}
// ... later
NSLog(#"line %#", [self getLineString:self.selectedRange]);
This worked for me (self = the UITextView)
func getLineString() -> String {
return (self.text! as NSString).substringWithRange((self.text! as NSString).lineRangeForRange(self.selectedRange))
}
Swift 5 extension version of Gil's answer:
extension UITextView {
func getLineString() -> String {
guard let text = text else { return "" }
return (text as NSString).substring(with: (text as NSString).lineRange(for: self.selectedRange))
}
}
I ended up using the caretRect method of UITextInput to get the offset from the left. Worked flawlessly.

UITextView word wrapping with maximum line-length and lines

For an iOS application I am writing I am using an UITextView, where the user can insert a limited text.
To the textview there are 2 restrictions:
Lines can be no longer than 30 characters
There can be only 20 lines of text in the UITextView.
So in short, the maximum is 20 lines of 30 characters.
When a user is typing some text in the UITextView and the current sentence is 30 characters, I want it to automatically insert a new line \n (before the last word on that line) and force the last word and cursor to the line below.
When a user has 20 lines with 30 characters (or even simpler said: 20 lines, with 30 characters on the last line) I want the input to be blocked.
Now, most of this is fairly 'simple' but the code I have does not account for border cases, like inserting text in earlier lines.
I looked around the documentation from Apple, but I can not find a way to actually force this kind of Word-wrapping on a UITextView.
My try is to handle all this in the shouldChangeTextInRange delegate method (made the code a little more verbose, so it's a bit easier to read).
#define MAX_LENGTH_LINE 30
#define MAX_LENGTH_ROWS 20
- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
{
// Check for backspaces, they should always be allowed?
if ([text length] == 0 && ![text isEqualToString:#"\n"])
return YES;
NSArray* lines = [textView.text componentsSeparatedByString:#"\n"];
// Check if there are a maximum of lines and the last line is already maxed out
NSString* lastLine = [lines objectAtIndex:[lines count] - 1];
if (([lines count] == MAX_LENGTH_ROWS) &&
(lastLine != nil) &&
([lastLine length] > MAX_LENGTH_LINE) &&
([text length] > 0))
return NO;
if ((lastLine != nil) &&
([lastLine length] > MAX_LENGTH_LINE))
{
NSRange range = [textView.text rangeOfString:#" " options:NSBackwardsSearch];
NSRange breakRange = [textView.text rangeOfString:#"\n" options:NSBackwardsSearch];
if (breakRange.location == NSNotFound)
breakRange = NSMakeRange(0, 1);
if (range.location == NSNotFound) {
range = NSMakeRange(0, 1);
}
if (range.location > breakRange.location)
{
textView.text = [textView.text stringByReplacingCharactersInRange:NSMakeRange(range.location, 1) withString:#"\n"];
}
else
{
textView.text = [textView.text stringByAppendingString:#"\n"];
}
}
if ([text isEqualToString:#"\n"])
{
if ([lines count] == MAX_LENGTH_ROWS)
return NO;
else {
return YES;
}
NSRange range = NSMakeRange(textView.text.length - 1, 1);
[textView scrollRangeToVisible:range];
}
return YES;
}
In the meanwhile I have been at this for a while and I lost it at the moment. Anyone who can give some pointers to just limit the UITextView to the 20 lines / 30 characters limitation I want?
This may be counter to your overall goals, but the simplest answer in my head is to reparse the string every time the user adds a character. Then it wouldn't matter where the character was added. Instead of doing all this in shouldChangeTextInRange: do it in textViewDidChange:. You will need to be a UITextViewDelegate and you will need a single class NSString to hold the last successful user text update in case your user is trying to add a character beyond the allowed limit.
Something like this:
-(void)textViewDidChange:(UITextView *)textView
{
//Get the textview without any new line characters
NSMutableString *temporaryString = [NSMutableString stringWithString:[textView.text stringByReplacingOccurrencesOfString:#"\n" withString:#" "]];
bool updatePossible = true;
int i = 0, numberOfLinesSoFar = 0;
while(i + 30 < [temporaryString length])
{
//Go 30 characters in and start the reverse search for a word separation
i += 30;
int j = i;
//Get the location of the final word separation in the current line
while(j >= i && [temporaryString characterAtIndex:j] != ' ')
{
j--;
}
//This means we found a word separation
if(j > i)
{
i = j;
[temporaryString replaceCharactersInRange:NSMakeRange(i,1) withString:#"\n"];
}
//We didn't find a word separation
else
{
//Here we will just have to break the line at 30.
[temporaryString insertString:#"\n" atIndex:i];
}
numberOfLinesSoFar++;
//Check if we just wrote line 20 and still have characters to go.
if(numberOfLinesSoFar > 19 && i < [temporaryString length])
{
//Revert user change to the last successful character addition
textView.text = lastSuccessfulViewString;
updatePossible = false;
break;
}
}
if(updatePossible)
{
//If we are within the limits then update the global string (for undoing character additions) and the textview
textView.text = temporaryString;
lastSuccessfulViewString = temporaryString;
}
}
Now this won't allow the user to put in their own new line characters, but that could be handled with a couple if then statements.
After a bit of fiddling around, created a control which contains a UITextView as a subview.
I let this control handle the text wrapping and forward the delegate methods to the view registered as a delegate.
It might help others, so I am posting a link to BitBucket here.
PS. It's still very verbose, but that is to show how I solved this for my case.
https://bitbucket.org/depl0y/sbframework/src/master/SBFramework/Views/SBTextView?at=master

Title of Segmented Control does not fit, overlaps

In my segmented control, sometimes the title is wider than fits its segment. How can I make it truncate?
Let's say the title of segment 1 is Text overlaps and segment 2 is named ok.
How I want it to look:
[Text ov...| ok ]
What it looks like:
T[ext overla|ps ok ]
What I tried:
Setting the width of the segment programmatically setWidth:forSegment. Still overlapping, not truncating.
Studying the properties of UISegmentedControl
Do I have to truncate it myself, before setting the title of the segment?
You have to truncate it yourself.
There is no public API for setting the truncation. Even if you dig through the UISegmentedControl's private view hierarchy, find the labels, and set lineBreakMode to UILineBreakModeTailTruncation, it won't truncate the labels for you. (I tried.)
EDIT: I got this to work. It's not pretty, it might stop working in a future iOS release, and it might get you rejected from the App Store.
static void fixLineBreakMode(UIView *view)
{
if ([view respondsToSelector:#selector(setLineBreakMode:)]) {
[(id)view setLineBreakMode:UILineBreakModeTailTruncation];
[view setFrame:CGRectInset([view.superview bounds], 6, 0)];
} else {
for (UIView *subview in view.subviews)
fixLineBreakMode(subview);
}
}
- (void)viewDidLoad
{
[super viewDidLoad];
fixLineBreakMode(self.segmentedControl);
}
I had the same challenge when I needed to populate a segmented control with x number of segments. Some titles were overhanging. I have tried to truncate the titles in middle if they were too long. I did something like:
NSString *s = #"This title is too long to fit";
NSMutableString *mS = [[NSMutableString alloc] init];
int len = [s length];
if (len > 10) {
for (int i = 0; i < len; i++) {
unichar ch = [s characterAtIndex:i];
if(i > 3 && i < 6){
[mS appendString:#"."];
} else {
[mS appendString:[NSString stringWithFormat: #"%C", ch]];
}
}
}
This is just to give you some idea. You can limit the number of dots in the middle, just count the number of dots appended and stop adding when e.g. it reaches 3. When you have the truncated string you can use that to set a segment title. The same logic for head, tail truncating.

Resources