Search code examples
cocoaautolayoutappkit

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:

Text fields not wrapping.

What I would like to have happen:

enter image description here

(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".

enter image description here 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];
      }
    }

Solution

  • 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.