MacOS: Cell-based NSTableView custom row height breaks rendering - macos

I am attempting to update some very old, formerly functional MacOS code (Objective C, pre-10.7) to modern MacOS and XCode 13. I have an NSCell-based NSTableView with custom row heights which when compiled pre-10.7 works fine and which more recently has stopped working. Recall 10.7 was the era in which auto-layout was introduced and NSTableView revised to accomodate NSView-based cells, so it makes sense that something problematic in this formerly functional code was flushed at that point.
The symptom of "stopped working" is that my custom NSCell's drawing method (drawWithFrame:inView) is never called, and the NSTableView renders each row as an opaque rectangle two pixels high. 8 years ago someone reported a similar symptom in similar global circumstances, but this issue remained unresolved, and they are no longer active.
In Interface Builder, I have "Row Size Style: Custom" and "Content Mode: Cell Based" set. I believe auto-layout is not relevant, since NSCells do not have constraints.
Now for the bizarre part. If I remove my NSTableViewDelegate's heightOfRow method, the table render works perfectly, and custom rows render though all at a constant height.
However if I re-add an implementation as straightforward as:
- (float)tableView:(NSTableView *)tableView heightOfRow:(int)row
{
return 30.0;
}
then I can see this method being called, once per row, to accumulate their sizes but after that NSCell's draw method is never called.
On the off chance I am completely misunderstand the documentation for heightOfRow, I have tried returning numbers much larger and much smaller than 30.0 as well. No luck. The fact that the table renders perfectly with heightOfRow unimplemented makes me believe my dataSource architecture and even my custom NSCell architecture is all functional, and that the issue somehow relates specifically to how NSTableView is interpreting my table's rows.
Here's the table's config in Interface Builder:
and here's source of a (toyed-down) ViewController for the panel (a modal dialog box) that both contains the tableView and acts as its delegate:
/* A customCell, installed in our nib, allows us to do our custom rendering */
#interface CustomCell : NSCell
{
}
#end
#implementation CustomCell
- (void)drawWithFrame:(NSRect)cellFrame inView:(NSView *)controlView
{
CGContextRef quartz = [NSGraphicsContext currentContext].CGContext;
CGContextSaveGState(quartz);
CGContextSetRGBFillColor(quartz, 1.0, 0.0, 0.0, 1.0);
CGContextFillEllipseInRect(quartz, cellFrame);
CGContextRestoreGState(quartz);
}
#end
#implementation GSPRDBBrowseDialogController
#undef SHOW_THE_BUG
#ifdef SHOW_THE_BUG
/*
SHOW THE BUG: Explicitly including any definition of the
following delegate method, even one that returns a constant row
height of 30, causes our [cell drawWithFrame] method to never be called,
and all table rows appear as black rectangles 2 pixels high.
*/
- (float)tableView:(NSTableView *)tableView heightOfRow:(int)row
{
return 30;
}
#else
/* with no implementation of heightOfRow, Cocoa calls our renderer using
some the constant row height value (24 pixels) specified in the nib.
*/
#endif
- (IBAction) openButtonPressed: (id) sender
{
[NSApp stopModal];
}
- (id) initWithContext: (const RDB_Context*) iContext
helpContext: (int) iHelpContext
{
rdbContext = *iContext;
return( self = [super initWithNibNamed: #"rdbbrowse_dlg"
dialogData: NULL
changeProc: NULL
backTrack: false
helpContext: iHelpContext]);
}
- (int) numberOfRowsInTableView: (NSTableView*) iTableView
{
return 20;
}
- (id) tableView: (NSTableView*) iTableView
objectValueForTableColumn: (NSTableColumn*) iColumn
row: (int) iRow
{
return nil; // doesn't matter. real code returns more here.
}
#end
Running this code with SHOW_THE_BUG #UNDEFined produces the following correct (but undesired) result: 20 rows of red circles, each 24 pixels high:
But changing to #DEFINE SHOW_THE_BUG, thereby including a toy-implementation of heightOfRow that should set all rows to 30 pixels high, and instead we get this...thin rectangles (look closely at the top of the table), and the actual cell renderer (drawWithFrame) is never called:
Any thoughts about what crazy contortion my code may be in, where defining a custom row height that is effectively constant breaks rendering completely but leaving row height undefined produces almost-perfect results?
Thanks,
Nick

This wound up being my own fault. The object I was instantiating as NSTableViewDelegate, my dialog box NSWindowController, turned out to inherit from a long-forgotten base class that provides some general utilities for other dialog boxes in this app. That base class, in turn, implemented some of the NSTableViewDelegate contract, and those parts of one delegate instantiation were thus by inheritance intermingled with the various parts of the delegate contract I'm implementing here, leading to a broken contract from NSTableView's perspective. Since the base class is out of my control solution here was to firewall this table's delegate implementation into its own object, owned by but not descending from this NSWindowController, so it completely controls how much of the (optional) NSTableViewDelegate instantiation gets presented to the table.
I'm suitably sheepish and grateful to #Willeke for insisting the local implementation was itself coherent, which is what led me to wonder if the effective delegate implementation was somehow "more than" the local one...

Related

Resizing MTKView scales old content before redraw

I'm using a MTKView to draw Metal content. It's configured as follows:
mtkView = MTKView(frame: self.view.frame, device: device)
mtkView.colorPixelFormat = .bgra8Unorm
mtkView.delegate=self
mtkView.sampleCount=4
mtkView.isPaused=true
mtkView.enableSetNeedsDisplay=true
setFrameSize is overriden to trigger a redisplay.
Whenever the view resizes it scales its old content before it redraws everything. This gives a jittering feeling.
I tried setting the contentGravity property of the MTKView's layer to a non-resizing value, but that totally messes up the scale and position of the content. It seems MTKView doesn't want me to fiddle with that parameter.
How can I make sure that during a resize the content is always properly redrawn?
In my usage of Metal and MTKView, I tried various combinations of presentsWithTransaction and waitUntilScheduled without success. I still experienced occasional frames of stretched content in between frames of properly rendered content during live resize.
Finally, I dropped MTKView altogether and made my own NSView subclass that uses CAMetalLayer and resize looks good now (without any use of presentsWithTransaction or waitUntilScheduled). One key bit is that I needed to set the layer's autoresizingMask to get the displayLayer method to be called every frame during window resize.
Here's the header file:
#import <Cocoa/Cocoa.h>
#interface MyMTLView : NSView<CALayerDelegate>
#end
Here's the implementation:
#import <QuartzCore/CAMetalLayer.h>
#import <Metal/Metal.h>
#implementation MyMTLView
- (id)initWithFrame:(NSRect)frame
{
if (!(self = [super initWithFrame:frame])) {
return self;
}
// We want to be backed by a CAMetalLayer.
self.wantsLayer = YES;
// We want to redraw the layer during live window resize.
self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawDuringViewResize;
// Not strictly necessary, but in case something goes wrong with live window
// resize, this layer placement makes it more obvious what's going wrong.
self.layerContentsPlacement = NSViewLayerContentsPlacementTopLeft;
return self;
}
- (CALayer*)makeBackingLayer
{
CAMetalLayer* metalLayer = [CAMetalLayer layer];
metalLayer.device = MTLCreateSystemDefaultDevice();
metalLayer.delegate = self;
// *Both* of these properties are crucial to getting displayLayer to be
// called during live window resize.
metalLayer.autoresizingMask = kCALayerHeightSizable | kCALayerWidthSizable;
metalLayer.needsDisplayOnBoundsChange = YES;
return metalLayer;
}
- (CAMetalLayer*)metalLayer
{
return (CAMetalLayer*)self.layer;
}
- (void)setFrameSize:(NSSize)newSize
{
[super setFrameSize:newSize];
self.metalLayer.drawableSize = newSize;
}
- (void)displayLayer:(CALayer*)layer
{
// Do drawing with Metal.
}
#end
For reference, I do all my Metal drawing in MTKView's drawRect method.
I have the same problem with glitches on view resizing. You can even reproduce it in the HelloTriangle example from the Apple's developer site. However the effect is minimized because the triangle is drawn near the middle of the screen, and it's the content closest to the edge of the window, opposite the corner that drags, that is effected worst. The developer notes regarding use of presentsWithTransaction and waitUntilScheduled do not work for me either.
My solution was to add a Metal layer beneath the window.contentView.layer, and to make that layer large enough that it rarely needs to be resized. The reason this works is that, unlike the window.contentView.layer, which sizes itself automatically to the view (in turn maintaining the window size), you have explicit control of the sublayer size. This eliminates the flickering.
This helped me - https://github.com/trishume/MetalTest
He uses MetalLayer and careful setting of various properties. Everything is pretty smooth even with two side by side in synchronised scroll views with 45megapixel images.
A link to my original problem How do I position an image correctly in MTKView?

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()
}
}
}

How do I prevent duplicate code inside 2 drawRect: methods?

I have a few lines of drawing code that are duplicated in two different subclasses. When I move this drawing code to its own class and then call it from within drawRect: it is called but it is never drawn to the screen. What is the right way prevent duplicating code in two different drawRect: methods?
Details: I'm making a custom control by subclassing NSTableView and NSTableCellView. My drawing code needs to be in drawRect: in both of these subclasses.
I created a subclass of NSObject that declares one method. Here is the implementation:
#implementation TNLChartDrawingExtras
- (void)drawDividersInRect:(NSRect)rect startingAtDate:(NSDate *)startDate withZoomFactor:(NSNumber *)zoomFactor {
float pos = 0;
NSDate *currentDate = [startDate copy];
while (pos < rect.size.width) {
//draw the vertical divider
NSBezierPath *linePath = [NSBezierPath bezierPathWithRect:NSMakeRect(pos, 0.0, 1.0, rect.size.height)];
[[NSColor colorWithCalibratedWhite:0.85 alpha:0.5] set];
[linePath fill];
//increment the values for the next day
currentDate = [NSDate dateWithTimeInterval:86400 sinceDate:currentDate]; // add one day to the current date
pos = pos + (86400.0/ [zoomFactor floatValue]);
}
}
In my NSTableView subclass I define a property for this object. Then in awakeFromNib I create an instance of this class:
- (void)awakeFromNib {
self.extras = [[TNLChartDrawingExtras alloc] init];
}
In drawRect: I send this message:
- (void)drawRect:(NSRect)dirtyRect {
// more code here...
[self.extras drawDividersInRect:viewBounds startingAtDate:chart.startDate withZoomFactor:self.zoomFactor];
}
The code is executed but the lines it is supposed to draw don't appear. If I put the code from drawDividersInRect:... in the drawRect: method, it works fine.
My original solution (described in the question) may have worked if I had continued to debug it. However, I think the more important question is what is the right way to approach this problem. Here I solve it by adding category on NSView to the project:
I'm trying to add custom drawing code to both NSTableView and NSTableCellView. Both are subclasses of NSView so I created a category of NSView and added my custom drawing method there. Now I can call my drawing method from both subclasses.
Without see any of your code, it sounds like you are in need of a protocol, which is that same thing as an interface in the java language. Protocols are a series of methods that a group of a few unrelated classes may need to used. For example, in a drawing program like PhotoShop, Rects, Ovals, and Images are all valid objects that can be stored as layers in a .psd document, however, they all share traits like the ability to change object properties in a particular way. An example would be adjusting an objects' opacity or rescale an objects size, etc. Methods that access the objects properties for scaling or functions that can be shared between unrelated objects types call for protocols.
They are essentially .h files that list out the methods. The .m file that defines implementation of the code can store a tag in it's .h file.
// example of a class that acts as a protocol implementor
#interface LayerObject: NSObject <Resizable>
The tag says, "I am a member of the protocol named X, you can find one/some of the methods of protocol X in my .m file." All you would have to do is import the protocol to the desired classes using the following syntax:
// Declare protocol
#protocol Resizable;
// List methods wanted from protocol
- id resizeRect: id layerObject;
to gain the methods defined in the protocol.
Here is a website that describes protocols through an example:
http://agilewarrior.wordpress.com/2012/03/19/simple-objective-c-protocol-example/
Another solution would be to create a class hierarchy that uses an abstract class to put the given drawRect method you are working in. From here you could define the two subclass you are working on as a subclass of the abstract class in which they would inherit the drawRect method code, keeping you from repeating the code in two separate classes.

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;
}
}
}

NSTableView content view inset

I am looking to inset the contents of an NSTableView so that there is a gap between the top of the table view and the first cell.
On iOS this is easy with UITableView - achieved by using setContentInset:.
Turn headers back on and substitute the header view with your own subclass. Override its -drawRect: to draw only your background color. Also override -headerRectOfColumn: to prevent any of the column headers from being drawn. I'm not sure if this prevents column dragging or sorting but I'll bet it does.
The question asked how to adjust content insets similar to iOS. The currently selected answer shows how to move the first row down, but that's not quite the same thing. Adjusting the content insets will also move the start of the scrollbar to the inset position, just like iOS. This is useful when placing content underneath a "vibrant" or transparent toolbar.
An NSTableView itself does not have content insets. On macOS content insets are usually part of NSScrollView. To get access to the scroll view of NSTableView's view controller you can use the enclosingScrollview method of NSView, disable automatic adjustment and set the insets like this:
(warning old school Obj-C here)
self.enclosingScrollView.automaticallyAdjustsContentInsets = NO;
self.enclosingScrollView.contentInsets = NSEdgeInsetsMake(50.f,0.f,0.f,0.f);
Calling these from viewDidLoad is usually fine, however some types of table views will override your values with their own.
NSOutlineView set to source-list mode comes with lots of default values overridden to make the view look like the Finder sidebar.
There is no "clean" way to set the content-insets of these views. They stubbornly override your values, I've found that if you subclass NSOutlineView and overload setFrameSize: it will do the trick. So like this (inside the NSOutlineView subclass):
- (void)setFrameSize:(NSSize)newSize {
[super setFrameSize:newSize];
self.enclosingScrollView.automaticallyAdjustsContentInsets = NO;
self.enclosingScrollView.contentInsets = NSEdgeInsetsMake(100.f,0.f,0.f,0.f);
}
This will do the trick, but the initial scroll position will be strange. Calling scrollToBeginningOfDocument: from the initWithCoder: method of the subclass will scroll it to the correct initial position.
It's not very clean but you can achieve that by having the first row higher than the rest. Implement heightOfRow table delegate method:
- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row
{
if (row == 0) {
return normalRowHeight + topPadding;
} else {
return normalRowHeight;
}
}
The drawback is that you would also need to implement custom highlighting and custom cell drawing to take into account the extra space for the first row.
scrollView.automaticallyAdjustsContentInsets = false
scrollView.contentInsets = NSEdgeInsets(top: 40, left: 0, bottom: 0, right: 0)

Resources