TextKit drawing excess at top of graphics context - ios

I'm drawing directly into a graphics context by constructing the iOS 7 Text Kit "stack" of classes myself and asking for the glyphs to be drawn. This is my custom UIView subclass's drawRect: implementation:
NSLayoutManager* lm = [NSLayoutManager new];
NSTextStorage* ts =
[[NSTextStorage alloc] initWithAttributedString:self.attributedText];
[ts addLayoutManager:lm];
NSTextContainer* tc =
[[NSTextContainer alloc]
initWithSize:rect.size];
[lm addTextContainer:tc];
tc.lineFragmentPadding = 0;
NSRange r = NSMakeRange(0,lm.numberOfGlyphs);
[lm drawBackgroundForGlyphRange:r atPoint:CGPointMake(0,10)];
[lm drawGlyphsForGlyphRange:r atPoint:CGPointMake(0,10)];
It seems innocent enough, but an odd thing is happening: the excess text (i.e. too much text for the size of the text container) is being drawn as an extra line across the top of the graphics context:
I can skirt the issue by limiting the range of drawn glyphs to those that fit into the text container:
NSRange r = [lm glyphRangeForTextContainer:tc];
But I feel I shouldn't have to do that. And I've also seen this problem in a UITextView (where I'm not the one issuing the drawing calls), so I'm a little worried that this is just a bug in iOS 7's TextKit.

Related

Use something else than ellipsis (...) with NSStringDrawingTruncatesLastVisibleLine

I am trying to render some text in background using [NSAttributedString drawWithRect:options:context:] method and I'm passing (NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading| NSStringDrawingTruncatesLastVisibleLine | NSLineBreakByWordWrapping) for the options. If my string is longer than two lines (I've calculated the max height of the rectangle for that) my text is truncated with ....
It works great, however, instead of ..., I need to truncate with ...more (with "more" at the end).
All the rendering must be done on background thread so any UI component is not possible. And please don't recommend TTTAttributedLabel because I'm trying to get away from it in the first place as it resulted in terrible performance in scrolling in my app (already tried that).
How can I use a custom truncation token when drawing a string in background thread?
May not be the most efficient thing, but I've ended up like this:
Check the size of the string with desired width and no height limit (using MAXFLOAT for the bounding rect's height in draw method):
NSStringDrawingOptions drawingOptions = (NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading);
[stringToDraw boundingRectWithSize:maximumSize options:drawingOptions context:nil].size;
As I know the font size, check the height of the resulting size and check if it's taller than a predetermined height which would indicate if it's more than two lines.
If it's more than two lines, get the index of the character at the point of the rectangle where ...more should roughly start, using a modified version of the answer at https://stackoverflow.com/a/26806991/811405 (the point is somewhere near the bottom right of the original text's rectangle's second line):
//this string spans more than two lines.
NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString];
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
[textStorage addLayoutManager:layoutManager];
// init text container
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:size];
textContainer.lineFragmentPadding = 0;
textContainer.maximumNumberOfLines = 2;
textContainer.lineBreakMode = NSLineBreakByClipping;
[layoutManager addTextContainer:textContainer];
CGPoint moreStartLocation = CGPointMake(size.width - 60, 30); //35 magic number
NSUInteger characterIndex = [layoutManager characterIndexForPoint:moreStartLocation
inTextContainer:textContainer
fractionOfDistanceBetweenInsertionPoints:NULL];
stringToDraw = [attributedString attributedSubstringFromRange:NSMakeRange(0, characterIndex)].mutableCopy;
[stringToDraw appendAttributedString:self.truncationText];
size = CGSizeMake(size.width, 35);
Truncate the original string to character there (optional: one can also find the last whitespace (e.g. space, newline) character from the limit and get the substring from that point to avoid word clipping). Add the "...more" to the original string. (The text can be anything, with any attributes. Just make sure it will fit into the result rectangle in two lines. I've fixed it to 60px, but one can also get the size of their desired truncation string, and use its width to find the last character precisely)
Render the new string (ending with "...more") as usual:
UIGraphicsBeginImageContextWithOptions(contextSize, YES, 0);
[stringToDraw drawWithRect:rectForDrawing options:drawingOptions context:nil];
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
The good thing about this method is that, since we're not touching UIKit (UIGraphics... functions and UIImage are thread-safe) we can execute the whole process in a background thread/queue. I'm using it to prerender some text content with attributes/links etc in background, that otherwise takes a frame or two in UI thread when scrolling, and it works perfectly.

CoreText Attributed String Height Calculation Inaccurate

CoreText isn't giving the correct height of the attributed string (its short by a line or more). I have seen a lot of posts on SO about this but unable to understand or find a solution. Can somebody explain how Core Text height calculation works? Here's an example code I wrote showing inaccurate height calculation.
Context
I have a collection view where the cell's height is determined by the content inside it.
I am displaying paragraphs of text in the cells. I would like to save some performance by doing the height calculation using core text. I have seen that with core text's height calculation I could save ~300ms.
Code
// Height Calculation
+ (CGFloat)getHeight
{
NSString *text = #"The Apple HIG recommends to use a common color for links and buttons and we did just that. By using the same color throughout the app we trained the user to always associate blue to a link.The Apple HIG recommends to use a common color for links and buttons and we did just that.By using the same color throughout the app we trained the user to always associate blue to a link.";
NSAttributedString *attrStr = [self attributedString:text withLinespacing:3 withLineBreakMode:NSLineBreakByWordWrapping];
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)(attrStr));
CGSize suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(frameSetter,
CFRangeMake(0, attrStr.length),
NULL,
CGSizeMake(320, 9999),
NULL);
return suggestedSize.height;
}
// Load the same text when Cell is about to display
- (void)loadData
{
NSString *text = #"The Apple HIG recommends to use a common color for links and buttons and we did just that.By using the same color throughout the app we trained the user to always associate blue to a link.The Apple HIG recommends to use a common color for links and buttons and we did just that.By using the same color throughout the app we trained the user to always associate blue to a link.";
NSAttributedString *attrStr = [[self class] attributedString:text withLinespacing:3 withLineBreakMode:NSLineBreakByWordWrapping];
// UILabel element
self.textLabel.attributedText = attrStr;
self.layer.borderColor = [UIColor blueColor].CGColor;
self.layer.borderWidth = 1.0f;
}
// Generate attributed string with leading, font and linebreak
+ (NSAttributedString *)attributedString:(NSString *)string
withLinespacing:(CGFloat)linespacing
withLineBreakMode:(NSLineBreakMode)lineBreakMode
{
NSMutableAttributedString *attrStr = [[NSMutableAttributedString alloc] initWithString:string];
NSInteger strLength = [string length];
NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
style.lineSpacing = linespacing;
style.lineBreakMode = lineBreakMode;
[attrStr addAttributes:#{NSParagraphStyleAttributeName: style,
NSFontAttributeName: [UIFont fontWithName:#"HelveticaNeue" size:15]} range:NSMakeRange(0, strLength)];
return attrStr;
}
The above code uses core text to calculate the height and UILabel to display the text. The UILabel has 3 constraints to the cell {Top:17, Leading:13px, Trailing:13px}
CTFramesetterSuggestFrameSizeWithConstraints is known to be buggy, returning incorrect height values. The missing line bug you experience is very common, and there are no good solutions that I know of, only ugly workarounds which never give 100% accurate results.
For iOS7 and above, I recommend moving to TextKit. Somehow the calculations performed there internally do work correctly, while being based on Core Text also. Using NSLayoutManager's usedRectForTextContainer: returns a correct result.
You can see a more complete answer here. While not exactly 100% on topic, there is some discussion about the bugginess of Core Text calculations.

NSTextContainer exclusionPaths freezes app and uses 99% CPU on iOS 7.1 - workaround?

I'm trying to exclude a square in a UITextView using NSTextContainer's excludePaths, like so:
NSTextStorage* textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString];
NSLayoutManager *layoutManager = [NSLayoutManager new];
[textStorage addLayoutManager:layoutManager];
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:self.bounds.size];
UIBezierPath *rectanglePath = [UIBezierPath bezierPathWithRect:CGRectMake(0, 0, 250, 250)];
textContainer.exclusionPaths = #[rectanglePath];
[layoutManager addTextContainer:textContainer];
self.textView = [[UITextView alloc] initWithFrame:self.bounds textContainer:textContainer];
self.textView.editable = NO;
self.textView.scrollEnabled = NO;
[self addSubview:self.textView];
This works fine in iOS 7.0:
In iOS 7.1, however, this will result in an infinite loop somewhere in lineFragmentRectForProposedRect:atIndex:writingDirection:remainingRect: of NSTextContainer, using 99% CPU and leaking memory like crazy. The app is completely unresponsive and is eventually terminated due to memory use. Apparently this is a bug in iOS 7.1.
When I change the x-origin of the exclusion rectangle by just one point (origin to {1,0}), it works, but looks terrible:
The bug only seems to happen when the first character of the first line is affected by the exclusion rect. When I change the exclusion rect to {0,30}, it will also work:
But obviously this is not what I want. Does anyone know how I can work around this bug?
I have the same issue, to fix this i placed:
mytextView.exclusionPaths = #[rectanglePath]
into layoutSubview method.
I hope this will help someone
Actually I encountered the same thing with iOS 7 and an attributed Text.
I had to completely remove the attributed text, make the UITextView selectable so I can change text color and font and only then it worked.
Sigh.
Just mentioning this in case anyone stumbles upon this in the future.

Core Plot: Add text label to padded area around plotAreaFrame

I am creating a bar graph and then adding empty space above as follows....
CPTGraph *graph = self.hostView.hostedGraph;
graph.plotAreaFrame.paddingTop = smallPlotTopOffset; //some number like 25.0
I have already added paddingTop to the graph itself, and then used titleDisplacement to move the graph.title into this space.
However, now I would like to add a label between the title and the graph, in the space created by the plotAreaFrame.paddingTop. Is this possible using CPTTextLayers? I've been trying to add a layer, but they seem constrained by the plotAreaFrame (i.e. within the padding). I would like to align my label centred below my graph.title hence trying to use the hostView, rather than just adding a UILabel to the superview.
For anyone else interested I did this as follows....
CPTLayerAnnotation *ann = [[CPTLayerAnnotation alloc] initWithAnchorLayer:self.hostView.hostedGraph.plotAreaFrame];
ann.rectAnchor = CPTRectAnchorTop; //to make it the top centre of the plotFrame
ann.displacement = CGPointMake(0, -20.0); //To move it down, below the title
CPTTextLayer *textLayer = [[CPTTextLayer alloc] initWithText:#"Your Text" style:nil];
ann.contentLayer = textLayer;
[self.hostView.hostedGraph.plotAreaFrame addAnnotation:ann];
I think this is the best way, although it'd be better if I could anchor it to below the main title, rather than add a manual displacement.
Use a layer annotation and add it to the graph.

iOS - Issue adding my CATextLayer frame to a UIScrollView

I am trying to add my CATextLayer frame to a UIScrollView in order to get some scrolling. I have been trying to use the technique mentioned here (How can i make my CATextLayer object Scrollable like UITextView?) with no success.
Actually due to the (suggested) 3 lines I've added to my code my CATextLayer does not display anymore.
I have attached my code below and noted these 3 lines. Perhaps someone can help me troubleshoot this or even propose a better way to approach this :-)
// Create the scrool view (FIRST LINE ADDED)
UIScrollView* scrollLayer = [[UIScrollView alloc] initWithFrame:CGRectMake(0.0, 0.0, 500.0, 500.0)];
// Create the new layer object
boxLayer = [[CATextLayer alloc] init];
// Give it a size
[boxLayer setBounds:CGRectMake(0.0, 0.0, 500.0, 500.0)];
// Give it a location
[boxLayer setPosition:CGPointMake(300.0, 350.0)];
// Make half-transparent red the background color for the layer
UIColor *reddish = [UIColor colorWithRed:0.0 green:0.0 blue:0.0 alpha:0.1];
// Get CGColor object with the same color values
CGColorRef cgReddish = [reddish CGColor];
[boxLayer setBackgroundColor:cgReddish];
// Make it a sublayer on the view's layer
[self.view.layer addSublayer:boxLayer];
// Create string
NSString *text2 = #"The article was about employment.\nHe leafed through it in an instant.\nHis feeling of anxiety resurfaced and he closed the magazine.\n\n-Hm…, he breathed.\n\n-Have you been looking for work long?, asked the stranger at his side.\nThe article was about employment.\nHe leafed through it in an instant.\nHis feeling of anxiety resurfaced and he closed the magazine.\n\n-Hm…, he breathed.\n\n-Have you been looking for work long?, asked the stranger at his side.\nThe article was about employment.\nHe leafed through it in an instant.\nHis feeling of anxiety resurfaced and he closed the magazine.\n\n-Hm…, he breathed.\n\n-Have you been looking for work long?, asked the stranger at his side.";
// Set font type
[boxLayer setFont:#"MarkerFelt-Thin"];
// Set font size
[boxLayer setFontSize:20.0];
// Text is left justified
[boxLayer setAlignmentMode:kCAAlignmentLeft];
// Text is wrapped
boxLayer.wrapped = YES;
// Assign string to layer
[boxLayer setString:text2];
// Define the text size (SECOND LINE ADDED)
scrollLayer.contentSize = CGSizeMake(500, 1000);
// Set the text layer as a sub layer of the scroll view (THIRD LINE ADDED)
[scrollLayer.layer addSublayer:boxLayer];
Pursuant to our discussion on another discussion, I wonder if you should even be adding CATextLayer objects at all. You can add UILabel objects, for example. For example, let's add a series of UILabel objects to our scroll view:
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if (!self.addedLabels)
{
[self addLabelsToScrollView:self.scrollView];
self.addedLabels = YES;
}
}
- (void)addLabelsToScrollView:(UIScrollView *)scrollView
{
NSArray *gettysburgAddress = #[
#"Four score and seven years ago our fathers brought forth on this continent a new nation, conceived in liberty, and dedicated to the proposition that all men are created equal.",
#"Now we are engaged in a great civil war, testing whether that nation, or any nation, so conceived and so dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this.",
#"But, in a larger sense, we can not dedicate, we can not consecrate, we can not hallow this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before us—that from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotion—that we here highly resolve that these dead shall not have died in vain—that this nation, under God, shall have a new birth of freedom—and that government of the people, by the people, for the people, shall not perish from the earth."
];
CGFloat y = 0.0;
UIFont *font = [UIFont fontWithName:#"MarkerFelt-Thin" size:20.0];
CGSize maxSize = CGSizeMake(scrollView.frame.size.width, 10000.0);
for (NSString *line in gettysburgAddress)
{
CGSize labelSize = [line sizeWithFont:font constrainedToSize:maxSize lineBreakMode:NSLineBreakByWordWrapping];
CGRect labelFrame = CGRectMake(0.0, y, labelSize.width, labelSize.height);
UILabel *label = [[UILabel alloc] initWithFrame:labelFrame];
label.font = font;
label.text = line;
label.numberOfLines = 0;
[scrollView addSubview:label];
y += labelSize.height + 16.0;
}
scrollView.contentSize = CGSizeMake(scrollView.contentSize.width, y);
}
Or even easier, rather than adding UILabel objects to a UIScrollView, you could just use a UITextView:
- (void)updateTextView
{
NSArray *gettysburgAddress = #[
#"Four score and seven years ago our fathers brought forth on this continent a new nation, conceived in liberty, and dedicated to the proposition that all men are created equal.",
#"Now we are engaged in a great civil war, testing whether that nation, or any nation, so conceived and so dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this.",
#"But, in a larger sense, we can not dedicate, we can not consecrate, we can not hallow this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before us—that from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotion—that we here highly resolve that these dead shall not have died in vain—that this nation, under God, shall have a new birth of freedom—and that government of the people, by the people, for the people, shall not perish from the earth."
];
NSString *text = [gettysburgAddress componentsJoinedByString:#"\n\n"];
self.textView.text = text;
self.textView.font = [UIFont fontWithName:#"MarkerFelt-Thin" size:20.0];
self.textView.editable = NO;
}
It all comes down to whether you really need to use a CATextLayer.

Resources