NSTextField is blurry in NSTableView when image is inserted in a row - macos

In my view-based NSTableView, each view has (among other stuff) an NSTextField, and an NSImageView right below the text field.
Sometimes, when I insert a new row at the top of the table, and this row has an image in its NSImageView, the text in the other rows becomes blurry/degraded.
The text becomes normal again after scrolling several times.
Not ok:
Ok:
An example where the text is only blurred in the rows after the one with the image:
This really makes me think it's a problem coming from .insertRows or noteHeightOfRows.
All elements have autolayout constraints set in IB.
The scroll view has a CoreAnimation layer set in IB. I also use the layer when preparing the cell:
cell.layer?.isOpaque = true
cell.textLabel.layer?.isOpaque = true
and sometimes
cell.layer?.borderColor = someColor
cell.layer?.borderWidth = someWidth
Rows are inserted with:
// update the model, then:
NSAnimationContext.runAnimationGroup({ (context) in
context.allowsImplicitAnimation = true
self.table.insertRows(at: index, withAnimation: [.effectGap])
})
Updating the row with the image:
// in tableViewCell, while populating the table
cell.postImage.image = img
cell.postImage.needsDisplay = true
// once the image is downloaded
table.reloadData(forRowIndexes: ..., columnIndexes: ...)
table.noteHeightOfRows(withIndexesChanged: ...)
How to avoid this issue? It's hard to debug because it doesn't always happen and I can't see what are the reasons for it to happen when it does.

Due to the fact that the text got blurry only when an image was inserted, my first thought is that the NSTextField's frame is not "integral" (its pixel values are not integers). Which is a well known reason for getting blurry text.
You are using an Auto Layout feature of Cocoa so my suggestion is to override the void layout() method inside your view-based table view cell.
To call it's super method (to let Cocoa to do all the needed layout calculations) and just to set the frame of the textFiled to be the same frame, but integral.
Addendum: while the textField itself may have an integral frame - it still can be layed out on a "half pixel" (in real screen coordinates) which will still lead to the blurriness.
TextField is a subView of the cell so itss frame is not in the screen coordinates but in the cell coordinates.
Example: let's imagine that the cell has a frame of (0.5, 0.5 , 100, 100) which is not integral, and the textField has an integral frame of (10,10, 50, 50) which is integral by all means!
BUT when the textField will be layed out on the screen it will have the following frame: (10.5, 10.5, 50, 50) which is not integral - and will lead to drawing the textField on a "half pixel" (not integral) which leads to blurry text.
So in your case the cell itself must be layed out on an integral frame as well as the textField to ensure that the textField is on integral frame in screen coordinates.
In your tableViewCell subclass:
void layout()
{
[super layout];
//**** We have to ensure that the cell is also layed on an integral frame ****//
[self setFrame:NSIntegralRect(self.frame)];
//At this point after calling the [super layout]; myTextField will have a valid (autolayoutwise) frame so all you have to do is to ensure that it is indeed an integral frame doing so by calling NSIntegralRect(NSRect rect); method of Cocoa framework.
[myTetxField setFrame:NSIntegralRect(myTextField.frame)];
//Maybe optionally you will want to set everything to the integral rect :-)
/*
for(NSView * view in self.subViews)
{
[view setFrame:NSIntegralRect(view.frame)];
}
*/
}

Related

In a view-based NSTableView, the NSTextField is clipped by an NSImageView

TL;DR:
In macOS 10.11, a view based NSTableView containing an NSTextField and an NSImageView right under the textfield, with some rows having an image and others not, some texts are clipped after having scrolled the table down then up.
Before scrolling:
After scrolling:
When I say "clipped" it means that the text view is at its minimum height as defined with autolayout, when it should be expanded instead.
Note that this faulty behavior only happens in macOS 10.11 Mavericks. There's no such issues with macOS 10.12 Sierra.
Context
macOS 10.11+, view based NSTableView.
Each row has a textfield, and an image view just below the textfield.
All elements have autolayout constraints set in IB.
Goal
The text view has to adapt its height vertically - the image view should move accordingly, staying glued to the bottom of the textfield.
Of course the row itself also has to adapt its height.
Sometimes there's no image to display, in this case the image view should not be visible.
This is how it's supposed to render when it works properly:
Current implementation
The row is a subclass of NSTableCellView.
In the cell the textfield is set with an attributed string.
In tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat, I make a dummy textView that I use to find the actual textfield's height, and return an amended row height accordingly. I also check if there's an image to display and if it's the case I add the image height to the row height.
let ns = NSTextField(frame: NSRect(x: 0, y: 0, width: defaultWidth, height: 0))
ns.attributedStringValue = // the attributed string
var h = ns.attributedStringValue.boundingRect(with: ns.bounds.size, options: [.usesLineFragmentOrigin, .usesFontLeading]).height
if post.hasImage {
h += image.height
}
return h + margins
In tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? I prepare the actual cell contents:
getUserAvatar { img in
DispatchQueue.main.async {
cell.iconBackground.layer?.backgroundColor = self.prefs.colors.iconBackground.cgColor
cell.iconBackground.rounded(amount: 6)
cell.iconView.rounded(amount: 6)
cell.iconView.image = img
}
}
cell.usernameLabel.attributedStringValue = xxx
cell.dateLabel.attributedStringValue = xxx
// the textView at the top of the cell
cell.textLabel.attributedStringValue = xxx
// the imageView right under the textView
if post.hasImage {
getImage { img in
DispatchQueue.main.async {
cell.postImage.image = img
}
}
}
Issues
When scrolling the tableView, there's display issues as soon as one or several rows have an image in the image view.
Clipped text
Sometimes the text is clipped, probably because the empty image view is masking the bottom part of the text:
Normal
Clipped
IMPORTANT: resizing the table triggers a redraw and fixes the display issue...
What I've tried
Cell reuse
I thought the main issue was because of cell reuse by the tableView.
So I'm hiding the image field by default in tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView?, only unhiding if there's an image to set, and I do it on the main thread:
guard let cell = tableView.make(withIdentifier: "xxx", owner: self) as? PostTableCellView else {
return nil
}
DispatchQueue.main.async {
cell.postImage.isHidden = true
cell.postImage.image = nil
}
// set the text view here, the buttons, labels, etc, then this async part runs:
downloadImage { img in
DispatchQueue.main.async {
cell.postImage.isHidden = false
cell.postImage.image = img
}
}
// more setup
return cell
But the imageView is still blocking the text view in some cases.
And anyway, sometimes the text is clipped at the first display, before any scrolling is done...
CoreAnimation Layer
I thought maybe the cells need to be layer backed so that they're correctly redisplayed, so I've enabled CoreAnimation layer on the scrollView, the tableView, the tableCell, etc. But I hardly see any difference.
A/B test
If I remove the imageView completely and only deal with the textfield everything works ok. Having the imageView is definitely what is causing issues here.
Autolayout
I've tried to handle the display of the row contents without using autolayout but to no avail. I've also tried to set different constraints but clearly I suck at autolayout and didn't manage to find a good alternative to my current constraints.
Alternative: NSAttributedString with image
I've tried to remove the image view and have the image added at the end of the attributed string in the text view instead. But the result is often ugly when it works - and for most times it just doesn't work (for example when the image can't be downloaded in time to be added to the attributed string, leaving the cell without text or image at all and at a wrong height).
Question
What am I doing wrong? How could I fix these issues? My guess is that I should change the autolayout constraints but I don't see a working solution.
Or maybe would you do this entirely differently?
Text field has "Preferred Width" field in IB which allows tune up correlation of intrinsic size with auto-layout calculated size.
Changing this IB property value to "First Runtime Layout Width" or "Explicit" often helps resolving similar issues.
This resolved a lot of my issues in past.

Automatically wrap NSTextField using Auto Layout

How does one go about having auto-layout automatically wrap an NSTextField to multiple lines as the width of the NSTextField changes?
I have numerous NSTextFields displaying static text (i.e.: labels) in an inspector pane. As the inspector pane is resized by the user, I would like the right hand side labels to reflow to multiple lines if need be.
(The Finder's Get Info panel does this.)
But I haven't been able to figure out the proper combination of auto layout constraints to allow this behavior. In all case, the NSTextFields on the right refuse to wrap. (Unless I explicitly add a height constraint that would allow it to.)
The view hierarchy is such that each gray band is a view containing two NSTextFields, the property name on the left and the property value on the right. As the user resizes the inspector pane, I would like the property value label to auto-resize it's height as need-be.
Current situation:
What I would like to have happen:
(Note that this behavior is different than most Stack Overflow questions I came across regarding NSTextFields and auto layout. Those questions wanted the text field to grow while the user is typing. In this situation, the text is static and the NSTextField is configured to look like a label.)
Update 1.0
Taking #hamstergene's suggestion, I subclassed NSTextField and made a little sample application. For the most part, it now works, but there's now a small layout issue that I suspect is a result of the NSTextField's frame not being entirely in sync with what auto-layout expects it to be. In the screenshot below, the right-hand side labels are all vertically spaced with a top constraint. As the window is resized, the Where field is getting properly resized and wrapped. However, the Kind text field does not get pushed down until I resize the window "one more pixel".
Example: If I resize the window to just the right width that the Where textfield does it's first wrap, then I get the results in the middle image. If I resize the window one more pixel, then the Kind field's vertical location is properly set.
I suspect that's because auto-layout is doing it's pass and then the frames are getting explicitly set. I imagine auto-layout doesn't see that on that pass but does it it on the next pass, and updates the positions accordingly.
Assuming that's the issue, how do I inform auto-layout of these changes I'm doing in setFrameSize so that it can run the layout again. (And, most importantly, not get caught in recursive state of layout-setFrameSize-layout-etc...)
Solution
I've come up with a solution that appears to work exactly how I was hoping. Instead of subclassing NSTextField, I just override layout in the superview of the NSTextField in question. Within layout, I set the preferredMaxLayoutWidth on the text field and then trigger a layout pass. That appears to be enough to get it mostly working, but it leaves the annoying issue of the layout being briefly "wrong". (See note above).
The solution to that appears to be to call setNeedsDisplay and then everything Just Works.
- (void)layout {
NSTextField *textField = ...;
NSRect oldTextFieldFrame = textField.frame;
[textField setPreferredMaxLayoutWidth:NSWidth(self.bounds) - NSMinX(textField.frame) - 12.0];
[super layout];
NSRect newTextFieldFrame = textField.frame;
if (oldTextFieldFrame.size.height != newTextFieldFrame.size.height) {
[self setNeedsDisplay:YES];
}
}
The simplest way to get this working, assuming you're using an NSViewController-based solution is this:
- (void)viewDidLayout {
[super viewDidLayout];
self.aTextField.preferredMaxLayoutWidth = self.aTextField.frame.size.width;
[self.view layoutSubtreeIfNeeded];
}
This simply lets the constraint system solve for the width (height will be unsolvable on this run so will be what ever you initially set it to), then you apply that width as the max layout width and do another constraint based layout pass.
No subclassing, no mucking with a view's layout methods, no notifications. If you aren't using NSViewController you can tweak this solution so that it works in most cases (subclassing textfield, in a custom view, etc.).
Most of this came from the swell http://www.objc.io/issue-3/advanced-auto-layout-toolbox.html (look at the Intrinsic Content Size of Multi-Line Text section).
If inspector pane width will never change, just check "First Runtime Layout Width" in IB (note it's 10.8+ feature).
But allowing inspector to have variable width at the same time is not possible to achieve with constraints alone. There is a weak point somewhere in AutoLayout regarding this.
I was able to achieve reliable behaviour by subclassing the text field like this:
- (NSSize) intrinsicContentSize;
{
const CGFloat magic = -4;
NSSize rv;
if ([[self cell] wraps] && self.frame.size.height > 1)
rv = [[self cell] cellSizeForBounds:NSMakeRect(0, 0, self.bounds.size.width + magic, 20000)];
else
rv = [super intrinsicContentSize];
return rv;
}
- (void) layout;
{
[super layout];
[self invalidateWordWrappedContentSizeIfNeeded];
}
- (void) setFrameSize:(NSSize)newSize;
{
[super setFrameSize:newSize];
[self invalidateWordWrappedContentSizeIfNeeded];
}
- (void) invalidateWordWrappedContentSizeIfNeeded;
{
NSSize a = m_previousIntrinsicContentSize;
NSSize b = self.intrinsicContentSize;
if (!NSEqualSizes(a, b))
{
[self invalidateIntrinsicContentSize];
}
m_previousIntrinsicContentSize = b;
}
In either case, the constraints must be set the obvious way (you have probably already tried it): high vertical hugging priority, low horizontal, pin all four edges to superview and/or sibling views.
Set in the size inspector tab in section Text Field Preferred Width to "First Runtime layout Width"
This works for me and is a bit more elegant. Additionally i've made a little sample project on Github
public class DynamicTextField: NSTextField {
public override var intrinsicContentSize: NSSize {
if cell!.wraps {
let fictionalBounds = NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: CGFloat.greatestFiniteMagnitude)
return cell!.cellSize(forBounds: fictionalBounds)
} else {
return super.intrinsicContentSize
}
}
public override func textDidChange(_ notification: Notification) {
super.textDidChange(notification)
if cell!.wraps {
validatingEditing()
invalidateIntrinsicContentSize()
}
}
}

UICollectionView fix paging position after rotation

I have a UICollectionView with the frame of
[UIScreen mainScreen].bounds
and these attributes:
_collectionView.pagingEnabled = YES;
_collectionView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
A cell has also the size of the collection view. When I rotate, the contentOffset of the collection view does not fit to the new orientation. It has still the same offset as before the rotation.
To fix this, I changed the contentOffset manually in the didRotate method.
- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation {
_collectionView.contentOffset = CGPointMake(_newContentOffsetX, _collectionView.contentOffset.y);
}
This works, but it looks terrible. I also tried to scroll to the current IndexPath, but it hast the same ugly behaviour:
- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation {
[_collectionView scrollToItemAtIndexPath:_currentIndexPath atScrollPosition:UICollectionViewScrollPositionLeft animated:NO];
}
I need a clean transition and behaviour of updating the collection view's content offset when rotating the device.
Its better To use the
willAnimateRotationToInterfaceOrientation:duration:
When this method is called the bound of your view controllers view are already updated to the current device orientation. It seems that this method gets called inside of the rotation animation block. This means all the positions you will set inside of willAnimateRotationToInterfaceOrientation:duration: are animated.
This helps me a lot and I use it to update my view Controllers view content insets on rotation. Works like a charm! :-)
Instead of using didRotateFromInterfaceOrientation, try updating contentOffset in willRotateToInterfaceOrientation.
You'll need to make sure to compensate for using the other width/height dimension that you'll be rotating to since it won't have done the rotation yet.
- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
_collectionView.contentOffset = CGPointMake(_newContentOffsetX, _collectionView.contentOffset.y);
}

NSSplitView resizes the custom NSView contained

I've a vertical NSSplitView, the bottom subview contains a custom view (eg NSView) and a NSTextView.
The NSView contains inside it two NSButtons.
When I resize the splitView, making it smaller, the NSView containing the buttons is resized, too.
I don't want this behavior.
To better explain my problem please view the attached image.
Image 1: the window at application startup, everything is ok
Image 2: I've resized making smaller the split view, only a little part of buttons is visible
Image 3: I've enlarged again the split view but as you can see the NSView remains smaller and buttons are no longer visible (if I resize the splitView to bottom the NSView 'disappears')
This is a vicious problem that's based on the legacy workings of Cocoa views. The best solution I've seen is to constrain the minimum dimension of any portion of the split view. If the subviews never collapse, their metrics don't cross into another dimension and they should re-enlarge just fine.
To do this, set up a delegate for your split view, which will implement - splitView:constrainMaxCoordinate:ofSubviewAt:. The split view will call your delegate method hoping it can leave the max divider position at the height of the split view (passing this in as the second argument), but you can simply subtract some quantity from that value (say, 60) to return it as the minimum height for the bottom view.
- (CGFloat)splitView:(NSSplitView *)aSplitView
constrainMaxCoordinate:(CGFloat)proposedMin
ofSubviewAt:(NSInteger)dividerIndex {
return proposedMin - 60;
}
Of course, you'll probably want to do more checking in this method to make sure you're talking about the right split view, and the right subview, to avoid overreaching effects, but this is the basic idea.
(See also this fabulicious article on the subject.)
Constraining the divider position did not help in my case, as I'm animating the subviews and subviews can be collapsed.
I managed to achieve an acceptable solution by implementing the splitView delegate method -splitviewWillResizeSubviews: (means, you have to connect the delegate property from the split view to your controller in IB or in code) to maintain a minimum width by setting the subview to hidden instead of shrinking it to zero:
- (void)splitViewWillResizeSubviews:(NSNotification *)notification {
NSUInteger divider = [[[notification userInfo] valueForKey:#"NSSplitViewDividerIndex"] intValue];
NSView *subview = nil;
if(divider == SPLITVIEW_DIVIDER_SIDEBAR) {
subview = (NSView*)[self.splitView.subviews objectAtIndex:SPLITVIEW_SIDEBAR_INDEX];
}
if(subview) {
if(subview.frame.size.width < SPLITVIEW_MINIMUM_SIDEBAR_WIDTH) {
CGRect correctedFrame = subview.frame;
correctedFrame.size.width = SPLITVIEW_MINIMUM_SIDEBAR_WIDTH;
subview.frame = correctedFrame;
subview.hidden = YES;
} else {
subview.hidden = NO;
}
}
}

How to let NSTextField grow with the text in auto layout?

Auto layout in Lion should make it fairly simple to let a text field (and hence a label) grow with text it holds.
The text field is set to wrap in Interface Builder.
What is a simple and reliable way to do this?
The method intrinsicContentSize in NSView returns what the view itself thinks of as its intrinsic content size.
NSTextField calculates this without considering the wraps property of its cell, so it will report the dimensions of the text if laid out in on a single line.
Hence, a custom subclass of NSTextField can override this method to return a better value, such as the one provided by the cell's cellSizeForBounds: method:
-(NSSize)intrinsicContentSize
{
if ( ![self.cell wraps] ) {
return [super intrinsicContentSize];
}
NSRect frame = [self frame];
CGFloat width = frame.size.width;
// Make the frame very high, while keeping the width
frame.size.height = CGFLOAT_MAX;
// Calculate new height within the frame
// with practically infinite height.
CGFloat height = [self.cell cellSizeForBounds: frame].height;
return NSMakeSize(width, height);
}
// you need to invalidate the layout on text change, else it wouldn't grow by changing the text
- (void)textDidChange:(NSNotification *)notification
{
[super textDidChange:notification];
[self invalidateIntrinsicContentSize];
}
Swift 4
Editable Autosizing NSTextField
Based on Peter Lapisu's Objective-C post
Subclass NSTextField, add the code below.
override var intrinsicContentSize: NSSize {
// Guard the cell exists and wraps
guard let cell = self.cell, cell.wraps else {return super.intrinsicContentSize}
// Use intrinsic width to jive with autolayout
let width = super.intrinsicContentSize.width
// Set the frame height to a reasonable number
self.frame.size.height = 750.0
// Calcuate height
let height = cell.cellSize(forBounds: self.frame).height
return NSMakeSize(width, height);
}
override func textDidChange(_ notification: Notification) {
super.textDidChange(notification)
super.invalidateIntrinsicContentSize()
}
Setting self.frame.size.height to 'a reasonable number' avoids some bugs when using FLT_MAX, CGFloat.greatestFiniteMagnitude or large numbers. The bugs occur during operation when the user select highlights the text in the field, they can drag scroll up and down off into infinity. Additionally when the user enters text the NSTextField is blanked out until the user ends editing. Finally if the user has selected the NSTextField and then attempts to resize the window, if the value of self.frame.size.height is too large the window will hang.
The accepted answer is based on manipulating intrinsicContentSize but that may not be necessary in all cases. Autolayout will grow and shrink the height of the text field if (a) you give the text field a preferredMaxLayoutWidth and (b) make the field not editable. These steps enable the text field to determine its intrinsic width and calculate the height needed for autolayout. See this answer and this answer for more details.
Even more obscurely, it follows from the dependency on the text field's editable attribute that autolayout will break if you are using bindings on the field and fail to clear the Conditionally Sets Editable option.

Resources