Exclusion rects not shifting text properly with NSTextView/UITextView - cocoa

I have an NSTextView with some exclusion rects so I can shift the text vertically without having to resort to adding newlines to the string. However, I've noticed that the exclusion rect is rather limited or perhaps even buggy because I cannot shift the text vertically more than ~45% of the textview's height. I have found a workaround where I increase the textview's height by 2x but this feels gross and I'd rather do it "properly"
In the image above, I have three text views (from left to right)
Programmatically without encapsulating it inside an NSScrollView, 2x height
Programmatically with NSScrollView encapsulation. 2x height
Using interface builder, regular height.
The exclusion rect is drawn with a CAShapeLayer and you can see that "hello world new world" isn't properly positioned outside of the exclusion rect.
I tried all three examples to make sure I wasn't missing something with regards to IB default settings or the dynamics of the text view when encapsulated in an NSScrollView (Im new to AppKit and TextKit), however all 3 examples exhibit the same bug.
Code to update the text exclusion rect
(Each time the slider moves on the bottom right, it will update their text rect)
label.stringValue = "excl: \(sender.integerValue): height: \(view.frame.height)"
print(sender.integerValue)
let exclHeight: CGFloat = CGFloat(slider.integerValue)
[aTextView, bTextView, cTextView]
.compactMap { $0 }
.forEach {
let rect = CGRect(
x: 5,
y: 5,
width: aTextView.frame.width - 10,
height: exclHeight)
$0.wantsLayer = true
$0.textContainer?.exclusionPaths.removeAll()
$0.textContainer?.exclusionPaths.append(.init(rect: rect))
$0.layer?.sublayers?.forEach {
$0.removeFromSuperlayer()
}
let layer = CAShapeLayer()
layer.frame = rect
layer.backgroundColor = NSColor.blue.withAlphaComponent(0.2).cgColor
layer.borderWidth = 1
layer.borderColor = NSColor.white.cgColor
$0.layer?.addSublayer(layer)
}
}

The problem seems to be that the exclusionPath is before the first line.
Just playing around with the parameters, a two line sample text with the rect y positioned after the first line works without any problems.
So it looks like the issue is calculating the container height when it starts with a exclusionPaths in -[NSLayoutManager usedRectForTextContainer:]
#interface LOLayoutManager : NSLayoutManager
#end
#implementation LOLayoutManager
- (NSRect)usedRectForTextContainer:(NSTextContainer *)container
{
NSRect rect = [super usedRectForTextContainer:container];
NSRect newRect = NSMakeRect(0, 0, NSMaxX(rect), NSMaxY(rect));
return newRect;
}
#end
Instead of returning y position from the exclusionPaths and the line fragments height, this returns a big rect starting at 0, 0. This should work as long as the NSTextView only contains one text container.

Related

macOS, how resize window across screens?

I'm trying to programmatically resize macOS windows. Similar to Rectangle.
I have the basic resizing code working, for example, move the window to the right half, and when there is only one screen it works fine, however when I try to resize with two screens (in a vertical layout) the math does not work:
public func moveRight() {
guard let frontmostWindowElement = AccessibilityElement.frontmostWindow()
else {
NSSound.beep()
return
}
let screens = screenDetector.detectScreens(using: frontmostWindowElement)
guard let usableScreens = screens else {
NSSound.beep()
print("Unable to obtain usable screens")
return
}
let screenFrame = usableScreens.currentScreen.adjustedVisibleFrame
print("Visible frame of current screen \(usableScreens.visibleFrameOfCurrentScreen)")
let halfPosition = CGPoint(x: screenFrame.origin.x + screenFrame.width / 2, y: -screenFrame.origin.y)
let halfSize = CGSize(width: screenFrame.width / 2, height: screenFrame.height)
frontmostWindowElement.set(size: halfSize)
frontmostWindowElement.set(position: halfPosition)
frontmostWindowElement.set(size: halfSize)
print("movedWindowRect \(frontmostWindowElement.rectOfElement())")
}
If my window is on the main screen then the resizing works correctly, however if it is a screen below (#3 in the diagram below) then the Y coordinate ends up in the top monitor (#2 or #1 depending on x coordinate) instead of the original one.
The output of the code:
Visible frame of current screen (679.0, -800.0, 1280.0, 775.0)
Raw Frame (679.0, -800.0, 1280.0, 800.0)
movedWindowRect (1319.0, 25.0, 640.0, 775.0)
As far as I can see the problem lies in how Screens and windows are positioned:
I'm trying to understand how should I position the window so that it remains in the correct screen (#3), but having no luck so far, there doesn't seem to be any method to get the absolute screen dimensions to place the screen in the correct origin.
Any idea how can this be solved?
I figured it out, I completely missed one of the functions used in the AccessibilityElement class:
static func normalizeCoordinatesOf(_ rect: CGRect) -> CGRect {
var normalizedRect = rect
let frameOfScreenWithMenuBar = NSScreen.screens[0].frame as CGRect
normalizedRect.origin.y = frameOfScreenWithMenuBar.height - rect.maxY
return normalizedRect
}
Basically, since everything is calculated based on the main screen then there is no other option than to take the coordinates of that one and then offset to get the real position of the screen element.

NSClipView bounds - Problems to find out the scroll position

I try to find out, when the user scrolls to the end of a NSTableView. So of course, the tableview is embedded as usual in a NSClipView an this is embedded in a NSScrollView. There are no more changes except the fact of adding insets to the ClipView (Top: 20, Left/Right: 20, Bottom: 0).
To get notified about the scrolling, I connected an outlet of the NSClipView.
So I use this code:
self.clipView.postsBoundsChangedNotifications = true
NotificationCenter.default.addObserver(self,
selector: #selector(scrollViewDidScroll(notification:)),
name: NSView.boundsDidChangeNotification,
object: self.clipView)
...
#objc func scrollViewDidScroll(notification: Notification) {
print("\(self.clipView.contentInsets.bottom) - \(self.clipView.contentInsets.top)")
print(self.clipView.bounds)
}
If I'm at the top of the ScrollView, the print-results look like this:
0.0 - 20.0
(-20.0, -20.0, 600.0, 556.0)
// x y width height
This seems ok for me. But if I'm at the bottom, it looks like this:
0.0 - 20.0
(-20.0, 595.0, 600.0, 556.0)
In my understanding, the y-value should be 536, not 595. Where is this difference coming from?
I found the solution by my one by observing the different values especially after resizing the window. So:
clipView.documentVisibleRect is that, what you really can see. Of course I cannot use the height value of this part. The origin value tells me about the position in the ScrollView.
So necessary is the height of the total content. Therefore I have to use clipView.documentRect.
Now it's very easy: If the visibleRects y-position + the height of the visible rect is the same as the height of the total clipview, we are at the end. This code works:
#objc func scrollViewDidScroll(notification: Notification) {
let scrollY = self.clipView.documentVisibleRect.origin.y
let visibleHeight = self.clipView.documentVisibleRect.size.height
let totalHeight = self.clipView.documentRect.size.height
if scrollY + visibleHeight >= totalHeight {
print("end")
}
}

Horizontal scrollView/UIImageView layout issue

The goal: Have a scroll view that displays an array of uiimageviews (photos) that you can horizontally scroll through them
How I understand to do this: Make the frame (CGRect) of each uiimageview the height and width of the scroll view, the y value to 0 on each, and set the first imgViews x value to 0. For every imgView after that, add the width of the scrollview to the x value. In theory, this would line the imgViews (Photos) up next to each other horizontally and not allow for any vertical scrolling or zooming, purely a horizontal photo viewer.
The storyboard setup: I am creating my scrollview in a xib file (It’s a custom uiCollectionViewCell), with these constraints:
— Top space to cell (0)
— Trailing space to cell (0)
— Leading space to cell (0)
— Height of 400
— Bottom space to a view (0)
—— (See Below for img)
Laying out the UIImgViews:
func layoutScrollView() {
for (index, img) in currentImages.enumerate() {
let imgView = UIImageView(frame: CGRect(x: CGFloat(index) * scrollView.bounds.width, y: CGFloat(0), width: scrollView.bounds.width, height: scrollView.bounds.height))
imgView.contentMode = .ScaleAspectFill
imgView.image = img
scrollView.addSubview(imgView)
scrollView.contentSize = CGSize(width: imgView.frame.width * CGFloat(index), height: scrollView.bounds.height)
scrollView.setNeedsLayout()
}
}
My suspicion: I suspect the issue is stemming from the auto layout constraints i’ve specified, but (considering Im asking a SO question) not sure
If there is a better way to do this (really the correct way) please let me know! I have been trying to wrap my head around this for a few days now.
I appreciate all responses! Thanks for reading
EDIT #1
I tried paulvs approach of setting setNeedsLayout & layoutIfNeeded before the "for" loop, and still no luck. Here is (out of three images selected) the second photo displaying. It seems that both the first and second photos are way longer than the content view and that would move the middle view over (Squished).
Your code looks fine except for a few details (that may be causing the problem):
Add:
view.setNeedsLayout()
view.layoutIfNeeded()
before accessing the scrollView's frame (a good place would be before the for-loop).
This is because when using Autolayout, if you access a view's frame before the layout engine has performed a pass, you will get incorrect frames sizes/positions.
Remove these lines from inside the for-loop:
scrollView.contentSize = CGSize(width: imgView.frame.width * CGFloat(index), height: scrollView.bounds.height)
scrollView.setNeedsLayout()
and place this line after (outside) the for loop:
scrollView.contentSize = CGSize(width: imgView.frame.width * CGFloat(currentImages.count), height: scrollView.bounds.height)

Layout subviews in TableViewCell without adding to NSTableView

I have a subclass of a NSTableViewCell that has several controls in it, and I need the width of one of them to calculate the height for the row.
Since its not allowed to get the cell from tableView:heightOfRow, I need to calculate the height taking into account the width of a textField thats inside the cell and the string that goes in that row.
I figured I'd create a single static cell, give it a frame with the current width and make it calculate and perform its layout and get the resulting width for a textField width.
Here is the code thats supposed to get me the width of the "nameTextField"
+(float)nameTextFieldWidthForTaskTableType:(TaskTableType )type compactSize:(BOOL)compactSize totalCellWidth:(float)totalWidth{
static TaskCellView *taskCell = nil;
if (taskCell == nil) {
taskCell = [[TaskCellView alloc]initWithCompactSize:compactSize taskTableType:type identifier:#"notUsed"];
}
if (taskCell.frame.size.width != totalWidth) {
taskCell.frame = NSMakeRect(0, 0, totalWidth, 25);
[taskCell updateConstraints];
}
NSTextField *nameTextField = [taskCell viewForComponent:kTaskCellComponentName];
return nameTextField.frame.size.width;
}
The cell subviews are constraint based. And they work ok. But here, the nameTextField frame always is zero. Does the cell need a superview to do its layout ? Or what else could be missing ?
This did the trick:
[taskCell layoutSubtreeIfNeeded];

How to get height for NSAttributedString at a fixed width

I want to do some drawing of NSAttributedStrings in fixed-width boxes, but am having trouble calculating the right height they'll take up when drawn. So far, I've tried:
Calling - (NSSize) size, but the results are useless (for this purpose), as they'll give whatever width the string desires.
Calling - (void)drawWithRect:(NSRect)rect options:(NSStringDrawingOptions)options with a rect shaped to the width I want and NSStringDrawingUsesLineFragmentOrigin in the options, exactly as I'm using in my drawing. The results are ... difficult to understand; certainly not what I'm looking for. (As is pointed out in a number of places, including this Cocoa-Dev thread).
Creating a temporary NSTextView and doing:
[[tmpView textStorage] setAttributedString:aString];
[tmpView setHorizontallyResizable:NO];
[tmpView sizeToFit];
When I query the frame of tmpView, the width is still as desired, and the height is often correct ... until I get to longer strings, when it's often half the size that's required. (There doesn't seem to be a max size being hit: one frame will be 273.0 high (about 300 too short), the other will be 478.0 (only 60-ish too short)).
I'd appreciate any pointers, if anyone else has managed this.
-[NSAttributedString boundingRectWithSize:options:]
You can specify NSStringDrawingUsesDeviceMetrics to get union of all glyph bounds.
Unlike -[NSAttributedString size], the returned NSRect represents the dimensions of the area that would change if the string is drawn.
As #Bryan comments, boundingRectWithSize:options: is deprecated (not recommended) in OS X 10.11 and later. This is because string styling is now dynamic depending on the context.
For OS X 10.11 and later, see Apple's Calculating Text Height developer documentation.
The answer is to use
- (void)drawWithRect:(NSRect)rect options:(NSStringDrawingOptions)options
but the rect you pass in should have 0.0 in the dimension you want to be unlimited (which, er, makes perfect sense). Example here.
I have a complex attributed string with multiple fonts and got incorrect results with a few of the above answers that I tried first. Using a UITextView gave me the correct height, but was too slow for my use case (sizing collection cells). I wrote swift code using the same general approach described in the Apple doc referenced previously and described by Erik. This gave me correct results with must faster execution than having a UITextView do the calculation.
private func heightForString(_ str : NSAttributedString, width : CGFloat) -> CGFloat {
let ts = NSTextStorage(attributedString: str)
let size = CGSize(width:width, height:CGFloat.greatestFiniteMagnitude)
let tc = NSTextContainer(size: size)
tc.lineFragmentPadding = 0.0
let lm = NSLayoutManager()
lm.addTextContainer(tc)
ts.addLayoutManager(lm)
lm.glyphRange(forBoundingRect: CGRect(origin: .zero, size: size), in: tc)
let rect = lm.usedRect(for: tc)
return rect.integral.size.height
}
You might be interested in Jerry Krinock's great (OS X only) NS(Attributed)String+Geometrics category, which is designed to do all sorts of string measurement, including what you're looking for.
On OS X 10.11+, the following method works for me (from Apple's Calculating Text Height document)
- (CGFloat)heightForString:(NSAttributedString *)myString atWidth:(float)myWidth
{
NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:myString];
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithContainerSize:
NSMakeSize(myWidth, FLT_MAX)];
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
[layoutManager addTextContainer:textContainer];
[textStorage addLayoutManager:layoutManager];
[layoutManager glyphRangeForTextContainer:textContainer];
return [layoutManager
usedRectForTextContainer:textContainer].size.height;
}
Swift 4.2
let attributedString = self.textView.attributedText
let rect = attributedString?.boundingRect(with: CGSize(width: self.textView.frame.width, height: CGFloat.greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
print("attributedString Height = ",rect?.height)
Swift 3:
let attributedStringToMeasure = NSAttributedString(string: textView.text, attributes: [
NSFontAttributeName: UIFont(name: "GothamPro-Light", size: 15)!,
NSForegroundColorAttributeName: ClickUpConstants.defaultBlackColor
])
let placeholderTextView = UITextView(frame: CGRect(x: 0, y: 0, width: widthOfActualTextView, height: 10))
placeholderTextView.attributedText = attributedStringToMeasure
let size: CGSize = placeholderTextView.sizeThatFits(CGSize(width: widthOfActualTextView, height: CGFloat.greatestFiniteMagnitude))
height = size.height
This answer works great for me, unlike the other ones which were giving me incorrect heights for larger strings.
If you want to do this with regular text instead of attributed text, do the following:
let placeholderTextView = UITextView(frame: CGRect(x: 0, y: 0, width: ClickUpConstants.screenWidth - 30.0, height: 10))
placeholderTextView.text = "Some text"
let size: CGSize = placeholderTextView.sizeThatFits(CGSize(width: widthOfActualTextView, height: CGFloat.greatestFiniteMagnitude))
height = size.height
I just wasted a bunch of time on this, so I'm providing an additional answer to save others in the future. Graham's answer is 90% correct, but it's missing one key piece:
To obtain accurate results with -boundingRectWithSize:options: you MUST pass the following options:
NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesDeviceMetrics|NSStringDrawingUsesFontLeading
If you omit the lineFragmentOrigin one, you'll get nonsense back; the returned rect will be a single line high and won't at all respect the size you pass into the method.
Why this is so complicated and so poorly documented is beyond me. But there you have it. Pass those options and it'll work perfectly (on OS X at least).
Use NSAttributedString method
- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options context:(NSStringDrawingContext *)context
The size is the constraint on the area, the calculated area width is restricted to the specified width whereas the height is flexible based on this width. One can specify nil for context if that's not available. To get multi-line text size, use NSStringDrawingUsesLineFragmentOrigin for options.
As lots of guys mentioned above, and base on my test.
I use open func boundingRect(with size: CGSize, options: NSStringDrawingOptions = [], context: NSStringDrawingContext?) -> CGRect on iOS like this bellow:
let rect = attributedTitle.boundingRect(with: CGSize(width:200, height:0), options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil)
Here the 200 is the fixed width as your expected, height I give it 0 since I think it's better to kind tell API height is unlimited.
Option is not so important here,I have try .usesLineFragmentOrigin or .usesLineFragmentOrigin.union(.usesFontLeading) or .usesLineFragmentOrigin.union(.usesFontLeading).union(.usesDeviceMetrics), it give same result.
And the result is expected as my though.
Thanks.
Not a single answer on this page worked for me, nor did that ancient old Objective-C code from Apple's documentation. What I finally did get to work for a UITextView is first setting its text or attributedText property on it and then calculating the size needed like this:
let size = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.max))
Works perfectly. Booyah!
I found helper class to find height and width of attributedText (Tested code)
https://gist.github.com/azimin/aa1a79aefa1cec031152fa63401d2292
Add above file in your project
How to use
let attribString = AZTextFrameAttributes(attributedString: lbl.attributedText!)
let width : CGFloat = attribString.calculatedTextWidth()
print("width is :: >> \(width)")

Resources