How to uniformly scale rich text in an NSTextView? - objective-c

Context:
I have a normal Document-based Cocoa Mac OS X application which uses an NSTextView for rich text input. The user may edit the font family, point size and colors of the text in the NSTextView.
Base SDK: 10.7
Deployment Target: 10.6
Question:
I would like to implement zooming of the entire UI programmatically (including the NSTextView) while the user is editing text. Scaling the frame of the NSTextView is no problem. But I don't know how to scale the editable text inside the view which may contain multiple different point sizes in different sub-sections of the entire run of text.
How can I apply a uniform scale factor to the rich text displayed in an NSTextView?
This should play nicely with "rich text", such that the user's font family, color and especially point size (which may be different at different points of the run of text) are preserved, but scaled uniformly/relatively.
Is this possible given my Base SDK and Deployment targets? Is it possible with a newer Base SDK or Deployment target?

If the intent is to scale the view (and not actually change the attributes in the string), I would suggest using scaleUnitSquareToSize: method: along with the ScalingScrollView (available with the TextEdit sample code) for the proper scroll bar behavior.
The core piece from the ScalingScrollView is:
- (void)setScaleFactor:(CGFloat)newScaleFactor adjustPopup:(BOOL)flag
{
CGFloat oldScaleFactor = scaleFactor;
if (scaleFactor != newScaleFactor)
{
NSSize curDocFrameSize, newDocBoundsSize;
NSView *clipView = [[self documentView] superview];
scaleFactor = newScaleFactor;
// Get the frame. The frame must stay the same.
curDocFrameSize = [clipView frame].size;
// The new bounds will be frame divided by scale factor
newDocBoundsSize.width = curDocFrameSize.width / scaleFactor;
newDocBoundsSize.height = curDocFrameSize.height / scaleFactor;
}
scaleFactor = newScaleFactor;
[scale_delegate scaleChanged:oldScaleFactor newScale:newScaleFactor];
}
The scale_delegate is your delegate that can adjust your NSTextView object:
- (void) scaleChanged:(CGFloat)oldScale newScale:(CGFloat)newScale
{
NSInteger percent = lroundf(newScale * 100);
CGFloat scaler = newScale / oldScale;
[textView scaleUnitSquareToSize:NSMakeSize(scaler, scaler)];
NSLayoutManager* lm = [textView layoutManager];
NSTextContainer* tc = [textView textContainer];
[lm ensureLayoutForTextContainer:tc];
}
The scaleUnitSquareToSize: method scales relative to its current state, so you keep track of your scale factor and then convert your absolute scale request (200%) into a relative scale request.

Works for both iOS and Mac OS
#implementation NSAttributedString (Scale)
- (NSAttributedString *)attributedStringWithScale:(double)scale
{
if(scale == 1.0)
{
return self;
}
NSMutableAttributedString *copy = [self mutableCopy];
[copy beginEditing];
NSRange fullRange = NSMakeRange(0, copy.length);
[self enumerateAttribute:NSFontAttributeName inRange:fullRange options:0 usingBlock:^(UIFont *oldFont, NSRange range, BOOL *stop) {
double currentFontSize = oldFont.pointSize;
double newFontSize = currentFontSize * scale;
// don't trust -[UIFont fontWithSize:]
UIFont *scaledFont = [UIFont fontWithName:oldFont.fontName size:newFontSize];
[copy removeAttribute:NSFontAttributeName range:range];
[copy addAttribute:NSFontAttributeName value:scaledFont range:range];
}];
[self enumerateAttribute:NSParagraphStyleAttributeName inRange:fullRange options:0 usingBlock:^(NSParagraphStyle *oldParagraphStyle, NSRange range, BOOL *stop) {
NSMutableParagraphStyle *newParagraphStyle = [oldParagraphStyle mutableCopy];
newParagraphStyle.lineSpacing *= scale;
newParagraphStyle.paragraphSpacing *= scale;
newParagraphStyle.firstLineHeadIndent *= scale;
newParagraphStyle.headIndent *= scale;
newParagraphStyle.tailIndent *= scale;
newParagraphStyle.minimumLineHeight *= scale;
newParagraphStyle.maximumLineHeight *= scale;
newParagraphStyle.paragraphSpacing *= scale;
newParagraphStyle.paragraphSpacingBefore *= scale;
[copy removeAttribute:NSParagraphStyleAttributeName range:range];
[copy addAttribute:NSParagraphStyleAttributeName value:newParagraphStyle range:range];
}];
[copy endEditing];
return copy;
}
#end

OP here.
I found one solution that kinda works and is not terribly difficult to implement. I'm not sure this is the best/ideal solution however. I'm still interested in finding other solutions. But here's one way:
Manually scale the font point size and line height multiple properties of the NSAttributedString source text before display, and then un-scale the displayed text before storing as source.
The problem with this solution is that while scaled, the system Font Panel will show the actual scaled display point size of selected text (rather than the "real" source point size) while editing. That's not desirable.
Here's my implementation of that:
- (void)scaleAttributedString:(NSMutableAttributedString *)str by:(CGFloat)scale {
if (1.0 == scale) return;
NSRange r = NSMakeRange(0, [str length]);
[str enumerateAttribute:NSFontAttributeName inRange:r options:0 usingBlock:^(NSFont *oldFont, NSRange range, BOOL *stop) {
NSFont *newFont = [NSFont fontWithName:[oldFont familyName] size:[oldFont pointSize] * scale];
NSParagraphStyle *oldParaStyle = [str attribute:NSParagraphStyleAttributeName atIndex:range.location effectiveRange:NULL];
NSMutableParagraphStyle *newParaStyle = [[oldParaStyle mutableCopy] autorelease];
CGFloat oldLineHeight = [oldParaStyle lineHeightMultiple];
CGFloat newLineHeight = scale * oldLineHeight;
[newParaStyle setLineHeightMultiple:newLineHeight];
id newAttrs = #{
NSParagraphStyleAttributeName: newParaStyle,
NSFontAttributeName: newFont,
};
[str addAttributes:newAttrs range:range];
}];
}
This requires scaling the source text before display:
// scale text
CGFloat scale = getCurrentScaleFactor();
[self scaleAttributedString:str by:scale];
And then reverse-scaling the displayed text before storing as source:
// un-scale text
CGFloat scale = 1.0 / getCurrentScaleFactor();
[self scaleAttributedString:str by:scale];

I want to thank Mark Munz for his answer, as it saved me from wandering in a dark forest, full of of NSScrollView magnification madness and NSLayoutManagers.
For anyone still looking, this is my approach. This code is inside a NSDocument. All text is being inset into a fixed-width and centered container, and the zooming here keeps word wrapping etc. intact. It creates a nice "page view" sort of appearance without resorting to complicated layout management.
You need to have CGFloat _documentSize and NSTextView textView constants set in you class for this example to work.
- (void) initZoom {
// Call this when the view has loaded and is ready
// I am storing a separate _scaleFactor and _magnification for my own purposes, mainly to have the initial scale to be higher than 1.0
_scaleFactor = 1.0;
_magnification = 1.1;
[self setScaleFactor:_magnification adjustPopup:false];
[self updateLayout];
// NOTE: You might need to call updateLayout after the content is set and we know the window size etc.
}
- (void) zoom: (bool) zoomIn {
if (!_scaleFactor) _scaleFactor = _magnification;
// Arbitrary maximum levels of zoom
if (zoomIn) {
if (_magnification < 1.6) _magnification += 0.1;
} else {
if (_magnification > 0.8) _magnification -= 0.1;
}
[self setScaleFactor:_magnification adjustPopup:false];
[self updateLayout];
}
- (void)setScaleFactor:(CGFloat)newScaleFactor adjustPopup:(BOOL)flag
{
CGFloat oldScaleFactor = _scaleFactor;
if (_scaleFactor != newScaleFactor)
{
NSSize curDocFrameSize, newDocBoundsSize;
NSView *clipView = [[self textView] superview];
_scaleFactor = newScaleFactor;
// Get the frame. The frame must stay the same.
curDocFrameSize = [clipView frame].size;
// The new bounds will be frame divided by scale factor
//newDocBoundsSize.width = curDocFrameSize.width / _scaleFactor;
newDocBoundsSize.width = curDocFrameSize.width;
newDocBoundsSize.height = curDocFrameSize.height / _scaleFactor;
NSRect newFrame = NSMakeRect(0, 0, newDocBoundsSize.width, newDocBoundsSize.height);
clipView.frame = newFrame;
}
_scaleFactor = newScaleFactor;
[self scaleChanged:oldScaleFactor newScale:newScaleFactor];
}
- (void) scaleChanged:(CGFloat)oldScale newScale:(CGFloat)newScale
{
CGFloat scaler = newScale / oldScale;
[self.textView scaleUnitSquareToSize:NSMakeSize(scaler, scaler)];
NSLayoutManager* lm = [self.textView layoutManager];
NSTextContainer* tc = [self.textView textContainer];
[lm ensureLayoutForTextContainer:tc];
}
- (void) updateLayout {
CGFloat width = (self.textView.frame.size.width / 2 - _documentWidth * _magnification / 2) / _magnification; self.textView.textContainerInset = NSMakeSize(width, TEXT_INSET_TOP);
self.textView.textContainer.size = NSMakeSize(_documentWidth, self.textView.textContainer.size.height);
}

Related

iOS7 glyphRangeForTextContainer - select all glyphs / all text range in a UITextView

I am trying to achieve linespacing when typing into a UITextView. I have this function which seems to render the linespacing only for all the characters upto the cursor position when you click inside the UITextView.
How do I write the function to ensure that the formatting is applied to all of the text range when you click inside the UITextView please.
is there an equivalent method perhaps for glyphRangeForTextContainer to help me with this?
- (void) formatText
{
__block CGFloat topOffset = 0;
NSRange lineGlyphRange = [self.layoutManager glyphRangeForTextContainer:self.textContainer];
[self.layoutManager
enumerateLineFragmentsForGlyphRange:lineGlyphRange usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *textContainer, NSRange glyphRange, BOOL *stop)
{
CGRect adjustedRect = rect;
CGRect adjustedUsedRect = usedRect;
adjustedRect.origin.y = topOffset;
adjustedUsedRect.origin.y = topOffset;
[self.layoutManager setLineFragmentRect:adjustedRect forGlyphRange:glyphRange usedRect:adjustedUsedRect];
topOffset += 30; // 30 is the space between the lines you can adjust this as you like
}];
CGRect adjustedExtraLineFragmentRect = self.layoutManager.extraLineFragmentRect;
CGRect adjustedExtraLineFragmentUsedRect = self.layoutManager.extraLineFragmentUsedRect;
adjustedExtraLineFragmentRect.origin.y = topOffset;
adjustedExtraLineFragmentUsedRect.origin.y = topOffset;
[self.layoutManager setExtraLineFragmentRect:adjustedExtraLineFragmentRect usedRect:adjustedExtraLineFragmentUsedRect textContainer:self.textContainer];
}
The problem is related to this thread
iOS7 Type into UITextView with Line Spacing and keep formatting using TextKit
1- This is a video of the problem:- http://1drv.ms/1o8Rpd2
2- This is the project I am testing with: http://1drv.ms/1o8RtK0
Thank you.

CATiledLayers on OS X

This has been driving me crazy.. I have a large image, and need to have a view that is both zoomable, and scrollable (ideally it should also be able to rotate, but I've given up on that part). Since the image is very large, I plan on using CATiledLayer, but I simply can't get it to work.
My requirements are:
I need to be able to zoom (on mouse center) and pan
The image should not change its width:height ratio (shouldn't resize, only zoom).
This should run on Mac OS 10.9 (NOT iOS!)
Memory use shouldn't be huge (although up to like 100 MB should be ok).
I have the necessary image both complete in one file, and also tiled into many (even have it for different zoom levels). I prefer using the tiles, as that should be easier on memory, but both options are available.
Most of the examples online refer to iOS, and thus use UIScrollView for the zoom/pan, but I can't get to copy that behaviour for NSScrollView. The only example for Mac OS X I found is this, but his zoom always goes to the lower left corner, not the middle, and when I adapt the code to use png files instead of pdf, the memory use gets around 400 MB...
This is my best try so far:
#implementation MyView{
CATiledLayer *tiledLayer;
}
-(void)awakeFromNib{
NSLog(#"Es geht los");
tiledLayer = [CATiledLayer layer];
// set up this view & its layer
self.wantsLayer = YES;
self.layer = [CALayer layer];
self.layer.masksToBounds = YES;
self.layer.backgroundColor = CGColorGetConstantColor(kCGColorWhite);
// set up the tiled layer
tiledLayer.delegate = self;
tiledLayer.levelsOfDetail = 4;
tiledLayer.levelsOfDetailBias = 5;
tiledLayer.anchorPoint = CGPointZero;
tiledLayer.bounds = CGRectMake(0.0f, 0.0f, 41*256, 22*256);
tiledLayer.autoresizingMask = kCALayerNotSizable;
tiledLayer.tileSize = CGSizeMake(256, 256);
self.frame = CGRectMake(0.0f, 0.0f, 41*256, 22*256);
self.layer = tiledLayer;
//[self.layer addSublayer:tiledLayer];
[tiledLayer setNeedsDisplay];
}
-(void)drawRect:(NSRect)dirtyRect{
CGContextRef context = [[NSGraphicsContext currentContext] graphicsPort];
CGFloat scale = CGContextGetCTM(context).a;
CGSize tileSize = tiledLayer.tileSize;
tileSize.width /= scale;
tileSize.height /= scale;
// calculate the rows and columns of tiles that intersect the rect we have been asked to draw
int firstCol = floorf(CGRectGetMinX(dirtyRect) / tileSize.width);
int lastCol = floorf((CGRectGetMaxX(dirtyRect)-1) / tileSize.width);
int firstRow = floorf(CGRectGetMinY(dirtyRect) / tileSize.height);
int lastRow = floorf((CGRectGetMaxY(dirtyRect)-1) / tileSize.height);
for (int row = firstRow; row <= lastRow; row++) {
for (int col = firstCol; col <= lastCol; col++) {
NSImage *tile = [self tileForScale:scale row:row col:col];
CGRect tileRect = CGRectMake(tileSize.width * col, tileSize.height * row,
tileSize.width, tileSize.height);
// if the tile would stick outside of our bounds, we need to truncate it so as
// to avoid stretching out the partial tiles at the right and bottom edges
tileRect = CGRectIntersection(self.bounds, tileRect);
[tile drawInRect:tileRect];
}
}
}
-(BOOL)isFlipped{
return YES;
}
But this deforms the image, and doesn't zoom or pan correctly (but at least the tile selection works)...
I can't believe this is so hard, any help would be greatly appreciated. Thanks :)
After a lot of research and tries, I finally managed to get this to work using this example. Decided to post it for future reference. Open the ZIP > CoreAnimationLayers> TiledLayers, there's a good example there. That's how CATiledLayer works with OS X, and since the example there doesn't handle zoom very well, I leave here my zoom code
-(void)magnifyWithEvent:(NSEvent *)event{
[super magnifyWithEvent:event];
if (!isZooming) {
isZooming = YES;
BOOL zoomOut = (event.magnification > 0) ? NO : YES;
if (zoomOut) {
[self zoomOutFromPoint:event.locationInWindow];
} else {
[self zoomInFromPoint:event.locationInWindow];;
}
}
}
-(void)zoomInFromPoint:(CGPoint)mouseLocationInWindow{
if(zoomLevel < pow(2, tiledLayer.levelsOfDetailBias)) {
zoomLevel *= 2.0f;
tiledLayer.transform = CATransform3DMakeScale(zoomLevel, zoomLevel, 1.0f);
tiledLayer.position = CGPointMake((tiledLayer.position.x*2) - mouseLocationInWindow.x, (tiledLayer.position.y*2) - mouseLocationInWindow.y);
}
}
-(void)zoomOutFromPoint:(CGPoint)mouseLocationInWindow{
NSInteger power = tiledLayer.levelsOfDetail - tiledLayer.levelsOfDetailBias;
if(zoomLevel > pow(2, -power)) {
zoomLevel *= 0.5f;
tiledLayer.transform = CATransform3DMakeScale(zoomLevel, zoomLevel, 1.0f);
tiledLayer.position = CGPointMake((tiledLayer.position.x + mouseLocationInWindow.x)/2, (tiledLayer.position.y + mouseLocationInWindow.y)/2);
}
}

boundingRectWithSize for NSAttributedString returning wrong size

I am trying to get the rect for an attributed string, but the boundingRectWithSize call is not respecting the size I pass in and is returning a rect with a single line height as opposed to a large height (it is a long string). I have experimented by passing in a very large value for the height and also 0 as in the code below, but the rect returned is always the same.
CGRect paragraphRect = [attributedText boundingRectWithSize:CGSizeMake(300,0.0)
options:NSStringDrawingUsesDeviceMetrics
context:nil];
Is this broken, or do I need to do something else to have it returned a rect for wrapped text?
Looks like you weren't providing the correct options. For wrapping labels, provide at least:
CGRect paragraphRect =
[attributedText boundingRectWithSize:CGSizeMake(300.f, CGFLOAT_MAX)
options:(NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading)
context:nil];
Note: if the original text width is under 300.f there won't be line wrapping, so make sure the bound size is correct, otherwise you will still get wrong results.
For some reason, boundingRectWithSize always returns wrong size.
I figured out a solution.
There is a method for UItextView -sizeThatFits which returns the proper size for the text set.
So instead of using boundingRectWithSize, create an UITextView, with a random frame, and call its sizeThatFits with the respective width and CGFLOAT_MAX height.
It returns the size that will have the proper height.
UITextView *view=[[UITextView alloc] initWithFrame:CGRectMake(0, 0, width, 10)];
view.text=text;
CGSize size=[view sizeThatFits:CGSizeMake(width, CGFLOAT_MAX)];
height=size.height;
If you are calculating the size in a while loop, do no forget to add that in an autorelease pool, as there will be n number of UITextView created, the run time memory of the app will increase if we do not use autoreleasepool.
Ed McManus has certainly provided a key to getting this to work. I found a case that does not work
UIFont *font = ...
UIColor *color = ...
NSDictionary *attributesDictionary = [NSDictionary dictionaryWithObjectsAndKeys:
font, NSFontAttributeName,
color, NSForegroundColorAttributeName,
nil];
NSMutableAttributedString *string = [[NSMutableAttributedString alloc] initWithString: someString attributes:attributesDictionary];
[string appendAttributedString: [[NSAttributedString alloc] initWithString: anotherString];
CGRect rect = [string boundingRectWithSize:constraint options:(NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading) context:nil];
rect will not have the correct height. Notice that anotherString (which is appended to string) was initialized without an attribute dictionary. This is a legitimate initializer for anotherString but boundingRectWithSize: does not give an accurate size in this case.
My final decision after long investigation:
- boundingRectWithSize function returns correct size for uninterrupted sequence of characters only!
In case string contains spaces or something else (called by Apple "Some of the glyphs" ) - it is impossible to get actual size of rect needed to display text!
I have replaced spaces in my strings by letters and immediately got correct result.
Apple says here:
https://developer.apple.com/documentation/foundation/nsstring/1524729-boundingrectwithsize
"This method returns the actual bounds of the glyphs in the string. Some of the glyphs (spaces, for example) are allowed to overlap the layout constraints specified by the size passed in, so in some cases the width value of the size component of the returned CGRect can exceed the width value of the size parameter."
So it is necessary to find some another way to calculate actual rect...
After long investigation process solution finally found!!!
I am not sure it will work good for all cases related to UITextView, but main and important thing was detected!
boundingRectWithSize function as well as CTFramesetterSuggestFrameSizeWithConstraints (and many other methods) will calculate size and text portion correct when correct rectangle used.
For example - UITextView has textView.bounds.size.width - and this value not actual rectangle used by system when text drawing on UITextView.
I found very interesting parameter and performed simple calculation in code:
CGFloat padding = textView.textContainer.lineFragmentPadding;
CGFloat actualPageWidth = textView.bounds.size.width - padding * 2;
And magic works - all my texts calculated correct now!
Enjoy!
Swift four version
let string = "A great test string."
let font = UIFont.systemFont(ofSize: 14)
let attributes: [NSAttributedStringKey: Any] = [.font: font]
let attributedString = NSAttributedString(string: string, attributes: attributes)
let largestSize = CGSize(width: bounds.width, height: .greatestFiniteMagnitude)
//Option one (best option)
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
let textSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, largestSize, nil)
//Option two
let textSize = (string as NSString).boundingRect(with: largestSize, options: [.usesLineFragmentOrigin , .usesFontLeading], attributes: attributes, context: nil).size
//Option three
let textSize = attributedString.boundingRect(with: largestSize, options: [.usesLineFragmentOrigin , .usesFontLeading], context: nil).size
Measuring the text with the CTFramesetter works best as it provides integer sizes and handles emoji's and other unicode characters well.
I didn't have luck with any of these suggestions. My string contained unicode bullet points and I suspect they were causing grief in the calculation. I noticed UITextView was handling the drawing fine, so I looked to that to leverage its calculation. I did the following, which is probably not as optimal as the NSString drawing methods, but at least it's accurate. It's also slightly more optimal than initialising a UITextView just to call -sizeThatFits:.
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(width, CGFLOAT_MAX)];
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
[layoutManager addTextContainer:textContainer];
NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:formattedString];
[textStorage addLayoutManager:layoutManager];
const CGFloat formattedStringHeight = ceilf([layoutManager usedRectForTextContainer:textContainer].size.height);
Turns out that EVERY part of an NSAttributedString must have a dictionary set with at least NSFontAttributeName and NSForegroundColorAttributeName set, if you wish boundingRectWithSize to actually work!
I don't see that documented anywhere.
In case you'd like to get bounding box by truncating the tail, this question can help you out.
CGFloat maxTitleWidth = 200;
NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init];
paragraph.lineBreakMode = NSLineBreakByTruncatingTail;
NSDictionary *attributes = #{NSFontAttributeName : self.textLabel.font,
NSParagraphStyleAttributeName: paragraph};
CGRect box = [self.textLabel.text
boundingRectWithSize:CGSizeMake(maxTitleWidth, CGFLOAT_MAX)
options:(NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading)
attributes:attributes context:nil];
I've found that the preferred solution does not handle line breaks.
I've found this approach works in all cases:
UILabel* dummyLabel = [UILabel new];
[dummyLabel setFrame:CGRectMake(0, 0, desiredWidth, CGFLOAT_MAX)];
dummyLabel.numberOfLines = 0;
[dummyLabel setLineBreakMode:NSLineBreakByWordWrapping];
dummyLabel.attributedText = myString;
[dummyLabel sizeToFit];
CGSize requiredSize = dummyLabel.frame.size;
#warrenm Sorry to say that framesetter method didn't work for me.
I got this.This function can help us to determine the frame size needed for a string range of an NSAttributedString in iphone/Ipad SDK for a given Width :
It can be used for a dynamic height of UITableView Cells
- (CGSize)frameSizeForAttributedString:(NSAttributedString *)attributedString
{
CTTypesetterRef typesetter = CTTypesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
CGFloat width = YOUR_FIXED_WIDTH;
CFIndex offset = 0, length;
CGFloat y = 0;
do {
length = CTTypesetterSuggestLineBreak(typesetter, offset, width);
CTLineRef line = CTTypesetterCreateLine(typesetter, CFRangeMake(offset, length));
CGFloat ascent, descent, leading;
CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
CFRelease(line);
offset += length;
y += ascent + descent + leading;
} while (offset < [attributedString length]);
CFRelease(typesetter);
return CGSizeMake(width, ceil(y));
}
Thanks to HADDAD ISSA >>> http://haddadissa.blogspot.in/2010/09/compute-needed-heigh-for-fixed-width-of.html
I have had the same problem with not getting an accurate size using these techniques and I've changed my approach to make it work.
I have a long attributed string which I've been trying to fit into a scroll view so that it shows properly without being truncated. What I did to make the text work reliably was to not set the height at all as a constraint and instead allowed the intrinsic size to take over. Now the text is displayed correctly without being truncated and I do not have to calculate the height.
I suppose if I did need to get the height reliably I would create a view which is hidden and these constraints and get the height of the frame once the constraints are applied.
Update July 2022
After many more trial and error and getting feedback from other answers, specifically the ones pointing out to use NSString.DrawingOptions.usesDeviceMetrics, I found out that this option is definitely a game changer, though is not enough on itself.
Using .deviceMetrics returns the correct height, but it won't fit properly on a UILabel nor on a NSTextField on some cases.
The only way I was able to make it fit on all cases was using a CATextLayer. Which is available for both iOS and macOS.
Example
let attributedString = NSAttributedString(string: "my string")
let maxWidth = CGFloat(300)
let size = attributedString.boundingRect(
with: .init(width: maxWidth,
height: .greatestFiniteMagnitude),
options: [
.usesFontLeading,
.usesLineFragmentOrigin,
.usesDeviceMetrics])
let textLayer = CATextLayer()
textLayer.frame = .init(origin: .zero, size: size)
textLayer.contentsScale = 2 // for retina
textLayer.isWrapped = true // for multiple lines
textLayer.string = attributedString
Then you can add the CATextLayer to any NSView/UIView.
macOS
let view = NSView()
view.wantsLayer = true
view.layer?.addSublayer(textLayer)
iOS
let view = UIView()
view.layer.addSublayer(textLayer)
Original answer February 2021
Many of the answers here are great, David Rees summarises the options nicely.
But sometimes when there are special characters or multiple white spaces the size seemed to always be wrong.
Example of a not working string (for me):
"hello . . world"
What I found out is that setting the kern of the NSAttributedString to 1 helps returning the right size.
Like this:
NSAttributedString(
string: "some string",
attributes: [
.font: NSFont.preferredFont(forTextStyle: .body),
.kern: 1])
Im a little late to the game - but I have been trying to figure out a way that works to find the bounding box that will fit around an attributed string to make a focus ring like editing a file in Finder does. everything I had tried failed when there are spaces at the end of the string or multiple spaces inside the string. boundingRectWithSize fails miserably for this as well as CTFramesetterCreateWithAttributedString.
Using a NSLayoutManager the following code seems to do the trick in all the cases I have found so far and returns a rect that perfectly bounds the string. Bonus: if you select the text the edges of the selection go right up to the bounds of the rect returned. The code below uses the layoutManager from a NSTextView.
NSLayoutManager* layout = [self layoutManager];
NSTextContainer* container = [self textContainer];
CGRect focusRingFrame = [layout boundingRectForGlyphRange:NSMakeRange(0, [[self textStorage] length]) inTextContainer:container];
textView.textContainerInset = UIEdgeInsetsZero;
NSString *string = #"Some string";
NSDictionary *attributes = #{NSFontAttributeName:[UIFont systemFontOfSize:12.0f], NSForegroundColorAttributeName:[UIColor blackColor]};
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:string attributes:attributes];
[textView setAttributedText:attributedString];
CGRect textViewFrame = [textView.attributedText boundingRectWithSize:CGSizeMake(CGRectGetWidth(self.view.frame)-8.0f, 9999.0f) options:(NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading) context:nil];
NSLog(#"%f", ceilf(textViewFrame.size.height));
Works on all fonts perfectly!
I had the same problem, but I recognised that height constrained has been set correctly. So I did the following:
-(CGSize)MaxHeighForTextInRow:(NSString *)RowText width:(float)UITextviewWidth {
CGSize constrainedSize = CGSizeMake(UITextviewWidth, CGFLOAT_MAX);
NSDictionary *attributesDictionary = [NSDictionary dictionaryWithObjectsAndKeys:
[UIFont fontWithName:#"HelveticaNeue" size:11.0], NSFontAttributeName,
nil];
NSMutableAttributedString *string = [[NSMutableAttributedString alloc] initWithString:RowText attributes:attributesDictionary];
CGRect requiredHeight = [string boundingRectWithSize:constrainedSize options:NSStringDrawingUsesLineFragmentOrigin context:nil];
if (requiredHeight.size.width > UITextviewWidth) {
requiredHeight = CGRectMake(0, 0, UITextviewWidth, requiredHeight.size.height);
}
return requiredHeight.size;
}
NSDictionary *stringAttributes = [NSDictionary dictionaryWithObjectsAndKeys:
[UIFont systemFontOfSize:18], NSFontAttributeName,
[UIColor blackColor], NSForegroundColorAttributeName,
nil];
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:myLabel.text attributes:stringAttributes];
myLabel.attributedText = attributedString; //this is the key!
CGSize maximumLabelSize = CGSizeMake (screenRect.size.width - 40, CGFLOAT_MAX);
CGRect newRect = [myLabel.text boundingRectWithSize:maximumLabelSize
options:(NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading)
attributes:stringAttributes context:nil];
self.myLabelHeightConstraint.constant = ceilf(newRect.size.height);
I tried everything on this page and still had one case for a UILabel that was not formatting correctly. Actually setting the attributedText on the label finally fixed the problem.
Add Following methods in ur code for getting correct size of attribute string
1.
- (CGFloat)findHeightForText:(NSAttributedString *)text havingWidth:(CGFloat)widthValue andFont:(UIFont *)font
{
UITextView *textView = [[UITextView alloc] init];
[textView setAttributedText:text];
[textView setFont:font];
CGSize size = [textView sizeThatFits:CGSizeMake(widthValue, FLT_MAX)];
return size.height;
}
2. Call on heightForRowAtIndexPath method
int h = [self findHeightForText:attrString havingWidth:yourScreenWidth andFont:urFont];
One thing I was noticing is that the rect that would come back from (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options attributes:(NSDictionary *)attributes context:(NSStringDrawingContext *)context would have a larger width than what I passed in. When this happened my string would be truncated. I resolved it like this:
NSString *aLongString = ...
NSInteger width = //some width;
UIFont *font = //your font;
CGRect rect = [aLongString boundingRectWithSize:CGSizeMake(width, CGFLOAT_MAX)
options:(NSStringDrawingUsesFontLeading | NSStringDrawingUsesLineFragmentOrigin)
attributes:#{ NSFontAttributeName : font,
NSForegroundColorAttributeName : [UIColor whiteColor]}
context:nil];
if(rect.size.width > width)
{
return rect.size.height + font.lineHeight;
}
return rect.size.height;
For some more context; I had multi line text and I was trying to find the right height to display it in. boundRectWithSize was sometimes returning a width larger than what I would specify, thus when I used my past in width and the calculated height to display my text, it would truncate. From testing when boundingRectWithSize used the wrong width the amount it would make the height short by was 1 line. So I would check if the width was greater and if so add the font's lineHeight to provide enough space to avoid truncation.
NSAttributedString *attributedText =[[[NSAttributedString alloc]
initWithString:joyMeComment.content
attributes:#{ NSFontAttributeName: [UIFont systemFontOfSize:TextFont]}] autorelease];
CGRect paragraphRect =
[attributedText boundingRectWithSize:CGSizeMake(kWith, CGFLOAT_MAX)
options:(NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading)
context:nil];
contentSize = paragraphRect.size;
contentSize.size.height+=10;
label.frame=contentSize;
if label's frame not add 10 this method will never work! hope this can help you! goog luck.
I'd like to add my thoughts since I had exactly the same problem.
I was using UITextView since it had nicer text alignment (justify, which at the time was not available in UILabel), but in order to "simulate" non-interactive-non-scrollable UILabel, I'd switch off completely scrolling, bouncing, and user interaction.
Of course, problem was that text was dynamic, and while width would be fixed, height should be recalculated every time I'd set new text value.
boundingRectWithSize didn't work well for me at all, from what I could see, UITextView was adding some margin on top which boundingRectWithSize would not get into a count, hence, height retrieved from boundingRectWithSize was smaller than it should be.
Since text was not to be updated rapidly, it's just used for some information that may update every 2-3 seconds the most, I've decided following approach:
/* This f is nested in a custom UIView-inherited class that is built using xib file */
-(void) setTextAndAutoSize:(NSString*)text inTextView:(UITextView*)tv
{
CGFloat msgWidth = tv.frame.size.width; // get target's width
// Make "test" UITextView to calculate correct size
UITextView *temp = [[UITextView alloc] initWithFrame:CGRectMake(0, 0, msgWidth, 300)]; // we set some height, really doesn't matter, just put some value like this one.
// Set all font and text related parameters to be exact as the ones in targeted text view
[temp setFont:tv.font];
[temp setTextAlignment:tv.textAlignment];
[temp setTextColor:tv.textColor];
[temp setText:text];
// Ask for size that fits :P
CGSize tv_size = [temp sizeThatFits:CGSizeMake(msgWidth, 300)];
// kill this "test" UITextView, it's purpose is over
[temp release];
temp = nil;
// apply calculated size. if calcualted width differs, I choose to ignore it anyway and use only height because I want to have width absolutely fixed to designed value
tv.frame = CGRectMake(tv.frame.origin.x, tv.frame.origin.y, msgWidth, tv_size.height );
}
*Above code is not directly copied from my source, I had to adjust it / clear it from bunch of other stuff not needed for this article. Don't take it for copy-paste-and-it-will-work-code.
Obvious disadvantage is that it has alloc and release, for each call.
But, advantage is that you avoid depending on compatibility between how boundingRectWithSize draws text and calculates it's size and implementation of text drawing in UITextView (or UILabel which also you can use just replace UITextView with UILabel). Any "bugs" that Apple may have are this way avoided.
P.S. It would seem that you shouldn't need this "temp" UITextView and can just ask sizeThatFits directly from target, however that didn't work for me. Though logic would say it should work and alloc/release of temporary UITextView are not needed, it did not. But this solution worked flawlessly for any text I would set in.
Ok so I spent lots of time debugging this. I found out that the maximum text height as defined by boundingRectWithSize allowed to display text by my UITextView was lower than the frame size.
In my case the frame is at most 140pt but the UITextView tolerate texts at most 131pt.
I had to figure that out manually and hardcode the "real" maximum height.
Here is my solution:
- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
NSString *proposedText = [textView.text stringByReplacingCharactersInRange:range withString:text];
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:proposedText];
CGRect boundingRect;
CGFloat maxFontSize = 100;
CGFloat minFontSize = 30;
CGFloat fontSize = maxFontSize + 1;
BOOL fit;
NSLog(#"Trying text: \"%#\"", proposedText);
do {
fontSize -= 1;
//XXX Seems like trailing whitespaces count for 0. find a workaround
[attributedText addAttribute:NSFontAttributeName value:[textView.font fontWithSize:fontSize] range:NSMakeRange(0, attributedText.length)];
CGFloat padding = textView.textContainer.lineFragmentPadding;
CGSize boundingSize = CGSizeMake(textView.frame.size.width - padding * 2, CGFLOAT_MAX);
boundingRect = [attributedText boundingRectWithSize:boundingSize options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading context:nil];
NSLog(#"bounding rect for font %f is %#; (max is %f %f). Padding: %f", fontSize, NSStringFromCGRect(boundingRect), textView.frame.size.width, 148.0, padding);
fit = boundingRect.size.height <= 131;
} while (!fit && fontSize > minFontSize);
if (fit) {
self.textView.font = [self.textView.font fontWithSize:fontSize];
NSLog(#"Fit!");
} else {
NSLog(#"No fit");
}
return fit;
}
Encountered exactly same issue.
To me, the issue is solved by TTTAttributedLabel's
+ (CGSize)sizeThatFitsAttributedString:(NSAttributedString *)attributedString
withConstraints:(CGSize)size
limitedToNumberOfLines:(NSUInteger)numberOfLines
method, as it provide accurate result.
I had issues calculating the height of an NSTextField. Any method I tried was always returning values that were too small.
For me the problem turned out to be that, for some reason, NSTextField's attributedStringValue property never contained any of the attributes that I set via Interface Builder. It actually contained no attributes at all if I didn't set an attributed string programmatically. Not even a font. That is why all of the height calculations were botched.
To get it to work, I created a Category for NSTextField which implements a custom function for getting the correct attributed string.
Here's the implementation file for that Category:
//
// --------------------------------------------------------------------------
// NSTextField+Additions.m
// Created for Mac Mouse Fix (https://github.com/noah-nuebling/mac-mouse-fix)
// Created by Noah Nuebling in 2021
// Licensed under MIT
// --------------------------------------------------------------------------
//
#import "NSTextField+Additions.h"
#implementation NSTextField (Additions)
// Copy paste template for adding attributes to an attributed string. Contains all possible attributes
// [str addAttributes:#{
// NSFontAttributeName: NSNull.null,
// NSParagraphStyleAttributeName: NSNull.null,
// NSForegroundColorAttributeName: NSNull.null,
// NSBackgroundColorAttributeName: NSNull.null,
// NSLigatureAttributeName: NSNull.null,
// NSKernAttributeName: NSNull.null,
// NSStrikethroughStyleAttributeName: NSNull.null,
// NSUnderlineStyleAttributeName: NSNull.null,
// NSStrokeColorAttributeName: NSNull.null,
// NSStrokeWidthAttributeName: NSNull.null,
// NSShadowAttributeName: NSNull.null,
// NSTextEffectAttributeName: NSNull.null,
// NSAttachmentAttributeName: NSNull.null,
// NSLinkAttributeName: NSNull.null,
// NSBaselineOffsetAttributeName: NSNull.null,
// NSUnderlineColorAttributeName: NSNull.null,
// NSStrikethroughColorAttributeName: NSNull.null,
// NSObliquenessAttributeName: NSNull.null,
// NSExpansionAttributeName: NSNull.null,
// NSWritingDirectionAttributeName: NSNull.null,
// NSVerticalGlyphFormAttributeName: NSNull.null,
// } range:NSMakeRange(0, str.length)];
/// In my testing NSTextField.attributedStringValue actually returned a string without _any_ attributes. Not even a font or anything.
/// This lead to issues when trying to calculate the fitting height for a certain width of the NSTextField.
/// This function takes some of the properties of the NSTextField and returns an NSAttributed string based on those.
/// I'm not sure this is perfect, but the returned attributed string describes the way that the text of the NSTextField is rendered close enough to be usable for my height calculations
- (NSAttributedString *)effectiveAttributedStringValue {
NSMutableAttributedString *str = self.attributedStringValue.mutableCopy;
// Create paragraph style from NSTextField properties
// Not sure if we're setting these properties correctly, and there could be more properties we should be setting
NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
paragraphStyle.alignment = self.alignment;
paragraphStyle.baseWritingDirection = self.baseWritingDirection;
paragraphStyle.lineBreakMode = self.lineBreakMode;
paragraphStyle.allowsDefaultTighteningForTruncation = self.allowsDefaultTighteningForTruncation;
if (#available(macOS 10.15, *)) paragraphStyle.lineBreakStrategy = self.lineBreakStrategy;
// Add attributes to AttributedString based on NSTextField properties
[str addAttributes:#{
NSFontAttributeName: self.font,
NSParagraphStyleAttributeName: paragraphStyle,
NSForegroundColorAttributeName: self.textColor,
NSBackgroundColorAttributeName: self.backgroundColor,
// NSLigatureAttributeName: NSNull.null,
// NSKernAttributeName: NSNull.null,
// NSStrikethroughStyleAttributeName: NSNull.null,
// NSUnderlineStyleAttributeName: NSNull.null,
// NSStrokeColorAttributeName: NSNull.null,
// NSStrokeWidthAttributeName: NSNull.null,
// NSShadowAttributeName: NSNull.null, //self.shadow,
// NSTextEffectAttributeName: NSNull.null,
// NSAttachmentAttributeName: NSNull.null,
// NSLinkAttributeName: NSNull.null,
// NSBaselineOffsetAttributeName: NSNull.null, //self.baselineOffsetFromBottom,
// NSUnderlineColorAttributeName: NSNull.null,
// NSStrikethroughColorAttributeName: NSNull.null,
// NSObliquenessAttributeName: NSNull.null,
// NSExpansionAttributeName: NSNull.null,
// NSWritingDirectionAttributeName: NSNull.null, //self.baseWritingDirection,
// NSVerticalGlyphFormAttributeName: NSNull.null,
} range:NSMakeRange(0, str.length)];
// return NSAttributedString
return str;
}
#end
Random Sidenotes
Some of the issues I've read about people having with UILabel in this thread sound a lot like they might be related.
I eventually decided to use NSTextView over NSTextField because its methods for obtaining the attributed string work out of the box, and using NSTextField for clickable links was completely botched as well. I'm under the impression that NSTextField is just a buggy mess that you should avoid beyond the most basic of use-cases.
I was having issues sometimes calculating some heights with boundingRect, specially with paragraphs and break lines. Adding .usesDeviceMetrics as a parameter did the trick. Now seems to work fine in all cases.
extension NSAttributedString {
func heightWithWidth(_ width: CGFloat) -> CGFloat {
let constraints = CGSize(width: width, height: .infinity)
let bounding = self.boundingRect(with: constraints, options: [.usesLineFragmentOrigin, .usesFontLeading, .usesDeviceMetrics], context: nil)
return bounding.height
}
}

Change font size in UILabel depending on string length

Let's say I have a UILabel which covers the entire window of the app. In this label is displayed random text with different lengths. Is it possible to change the text font size dependant on the text length?
Yes, UILabel can do that for you, just do:
theLabel.adjustsFontSizeToFitWidth = YES;
theLabel.minimumFontSize = MIN_FONT_SIZE;
Attention to this (from the documentation):
This property is effective only when the numberOfLines property is set
to 1.
I created a category method at one point. You basically feed it a rectangle and it will return a font that fits. Maybe you can glean something from the following crude example:
- (UIFont *)fontSizeForRect:(CGRect)rect withFont:(UIFont *)font lineBreakMode:(UILineBreakMode)lineBreakMode minFontSize:(CGFloat)minFontSize
{
CGFloat fontSize = [font pointSize];
UIFont *tempFont = [UIFont fontWithName:[font fontName] size:[font pointSize]];
CGFloat acceptableFontSize = fontSize;
while (fontSize > minFontSize)
{
UIFont *testFont = [UIFont fontWithName:[tempFont fontName] size:fontSize];
CGSize sizeWithTestFont = [self sizeWithFont:testFont constrainedToSize:CGSizeMake(rect.size.width, 99999.0) lineBreakMode:lineBreakMode];
if (sizeWithTestFont.height > rect.size.height)
fontSize -= 1.0f; //Shrink the font size by a point
else
{
//Fits. Use it.
acceptableFontSize = fontSize;
break;
}
}
return [UIFont fontWithName:[font fontName] size:acceptableFontSize];
}

Erasing Cocoa Drawing done by NSRectFill?

I have an NSBox, inside of which I am drawing small rectangles, with NSRectFill(). My code for this looks like this:
for (int i = 0; i <= 100; i++){
int x = (rand() % 640) + 20;
int y = (rand() % 315) + 196;
array[i] = NSMakeRect(x, y, 4, 4);
NSRectFill(array[i]);
}
This for loop creates 100 randomly placed rectangles within the grid. What I have been trying to do is create a sort of animation, created by this code running over and over, creating an animation of randomly appearing rectangles, with this code:
for (int i = 0; i <= 10; i++) {
[self performSelector:#selector(executeFrame) withObject:nil afterDelay:(.05*i)];
}
The first for loop is the only thing inside the executeFrame function, by the way. So, what I need to do is to erase all the rectangles between frames, so the number of them stays the same and they look like they are moving. I tried doing this by just drawing the background again, by calling [myNsBox display]; before calling executeFrame, but that made it seem as though no rectangles were being drawn. Calling it after did the same thing, so did switching in setNeedsDisplay instead of display. I cannot figure this one out, any help would be appreciated.
By the way, an additional thing is that when I try to run my code for executing the frames, without trying to erase the rectangles in between, all that happens is that 100 more rectangles are drawn. Even if I have requested that 1000 be drawn, or 10,000. Then though, if I leave the window and come back to it (immediately, time is not a factor here), the page updates and the rectangles are there. I attempted to overcome that by with [box setNeedsDisplayInRect:array[i]]; which worked in a strange way, causing it to update every frame, but erasing portions of the rectangles. Any help in this would also be appreciated.
It sounds like you're drawing outside drawRect: . If that's the case, move your drawing code into a view's (the box's or some subview's) drawRect: method. Otherwise your drawing will get stomped on by the Cocoa drawing system like you're seeing. You'll also want to use timers or animations rather than loops to do the repeated drawing.
I recently wrote an example program for someone trying to do something similar with circles. The approach I took was to create an array of circle specifications and to draw them in drawRect. It works pretty well. Maybe it will help. If you want the whole project, you can download it from here
#implementation CircleView
#synthesize maxCircles, circleSize;
- (id)initWithFrame:(NSRect)frame {
self = [super initWithFrame:frame];
if (self) {
maxCircles = 1000;
circles = [[NSMutableArray alloc] initWithCapacity:maxCircles];
}
return self;
}
- (void)dealloc {
[circles release];
[super dealloc];
}
- (void)drawRect:(NSRect)dirtyRect {
NSArray *myCircles;
#synchronized(circles) {
myCircles = [circles copy];
}
NSRect bounds = [self bounds];
NSRect circleBounds;
for (NSDictionary *circleSpecs in myCircles) {
NSColor *color = [circleSpecs objectForKey:colorKey];
float size = [[circleSpecs objectForKey:sizeKey] floatValue];
NSPoint origin = NSPointFromString([circleSpecs objectForKey:originKey]);
circleBounds.size.width = size * bounds.size.width;
circleBounds.size.height = size * bounds.size.height;
circleBounds.origin.x = origin.x * bounds.size.width - (circleBounds.size.width / 2);
circleBounds.origin.y = origin.y * bounds.size.height - (circleBounds.size.height / 2);
NSBezierPath *drawingPath = [NSBezierPath bezierPath];
[color set];
[drawingPath appendBezierPathWithOvalInRect:circleBounds];
[drawingPath fill];
}
[myCircles release];
}
#pragma mark Public Methods
-(void)makeMoreCircles:(BOOL)flag {
if (flag) {
circleTimer = [NSTimer scheduledTimerWithTimeInterval:0.2 target:self selector:#selector(makeACircle:) userInfo:nil repeats:YES];
}
else {
[circleTimer invalidate];
}
}
-(void)makeACircle:(NSTimer*)theTimer {
// Calculate a random color
NSColor *color;
color = [NSColor colorWithCalibratedRed:(arc4random() % 255) / 255.0
green:(arc4random() % 255) / 255.0
blue:(arc4random() % 255) / 255.0
alpha:(arc4random() % 255) / 255.0];
//Calculate a random origin from 0 to 1
NSPoint origin;
origin.x = (double)arc4random() / (double)0xFFFFFFFF;
origin.y = (double)arc4random() / (double)0xFFFFFFFF;
NSDictionary *circleSpecs = [NSDictionary dictionaryWithObjectsAndKeys:color, colorKey,
[NSNumber numberWithFloat:circleSize], sizeKey,
NSStringFromPoint(origin), originKey,
nil];
#synchronized(circles) {
[circles addObject:circleSpecs];
if ([circles count] > maxCircles) {
[circles removeObjectsInRange:NSMakeRange(0, [circles count] - maxCircles)];
}
}
[self setNeedsDisplay:YES];
}
#end