Say I have a Mac app with a window containing an NSScrollView. When added to a storyboard or nib file, the scroll view contains a clip view (NSClipView) and an NSView that is the scroll view's document view.
I want to add some content to this document view (say, two labels arranged vertically) and set up constraints such that the scroll view will scroll as it needs to in order to reveal all the content. So:
Pin the top, leading, and trailing of the upper label to those if its superview (the document view)
Pin the top of the lower label to the bottom of the upper label
Pin the leading, trailing, and bottom of the lower label to those of its superview (the document view)
Add a custom view to the document view, pin its bottom, left, and right edges to superview, and constrain its width to a constant (the width of the window) and its height to 0 to define the scroll view's scrollable content width
At this point, if I set the text of the labels to something long enough to overrun the height of the window, I'd expect the scroll view to allow the user to scroll to reveal the rest of the content. But it doesn't - it just elastic-scrolls vertically, and the text is cut off. Even if I set the content compression resistance to 1000 on both of the labels.
What am I missing here?
Sample app here: https://github.com/tomhamming/MacScrollTester
I want to use an NSStackView as "floating" container for buttons. i.e. a horizontal NSStackView that acts as toolbar of sorts and contains a row of square buttons (with fixed size). In runtime, some buttons will be dynamically shown/hidden and I want this NSStackView to dynamically adjust its width according to the visible buttons (plus spacing, edges, etc).
So, this NSStackView would have:
Fixed height
Dynamic width based on its content
Its exact position in parent view is not known in design time
A row of NSButtons with fix sizes
I have a nib file that contains the NSStackView and its buttons, which I load in runtime:
Bundle.main.loadNibNamed("ToolbarView", owner: self, topLevelObjects: nil)
toolbarView.detachesHiddenViews = true
toolbarView.setFrameOrigin(NSPoint(x: 100, y: 100))
I am struggling to find the exact combination of properties and constraints to make this behavior work. When I hide some buttons, the NSStackView detaches them but doesn't shrink to fit the shown buttons total width. I tried all NSStackView NSStackViewDistribution options.
I suspect I'm missing some constrains to make this work but all the NSStackView examples I find anchor the NSStackView to it's superview and I can't do that because its position in parent view is set in runtime.
Is there any way to make this work or do I need to fall back to manually calculating width from the visible buttons (which kind of makes using NSStackView pointless)?
What is the value of translatesAutoresizingMaskIntoConstraints on the stack view after loading it from the nib? If true, what is the autoresizingMask? (the default it should be .none).
If that is the case, then there are autoresizing mask constraints that hold the stack view's frame origin and size in place (i.e whatever gets loaded from the nib).
The best way to deal with this is to set translatesAutoresizingMaskIntoConstraints to false and then use constraints to position it in its parent view at runtime.
In addition to the already-mentioned translatesAutoresizingMaskIntoConstraints and leading and top constraints, have you tried modifying the stack view's hugging priority? Use a value of 1000 (required) to absolutely prevent the stack view from extending beyond its contents:
stackView.setHuggingPriority(1000, for: .horizontal)
I'm having problem getting vertical window resizing working with an auto layout scroll view.
What I Want
I would like to replicate, as closely as possible, my app's current window resizing behaviour. The width of the window is flexible, but the height of the window should generally track the height of the contents. Specifically:
Normally, the window automatically resizes its height to exactly
match its contents (with the exception of #2).
The user can choose to manually resize the window so it's smaller than its
contents, in which case the imbedded scroll view will scroll the
contents. Once made shorter, the window remains that height until manually
resized or the contents becomes equal or smaller than the window again.
If the contents grows to the point that the bottom of the window
bumps into the dock or screen bounds, the window's height is pinned
to that height, just as if the user has resized it.
What I've Got
I created a window with a set of representative subviews that mimic the basic needs of my app. The window's hierarchy is simple:
Window
NSView (contentView)
NSScrollView
NSClipView (NSScrollView.contentView)
NSView (NSScrollView.documentView)
A bunch of standard and custom subviews with constraints
You can download the test project here (macOS 10.12/Xcode 8): http://mbx.cm/t/4FUGY
I've organized the various subviews so they have a flexible width, but the constraints define exactly one possible height. The parent scroll view fills the content view of the window.
The auto layout stuff works great. The window automatically resizes to match the size of the contents. If the height of the contents changes, the height of the window changes to match. Awesome.
What I Can't Get to Work
I have had no luck getting NSWindow to let me manually resize its height. The resize indicator (when hovering over the edge) shows that I can alter the window's width, but not its height.
I initially thought "Oh, that's got to be a compression resistance priority in one of the scroll views." But I can find no combination of compression resistance or hugging priorities that will alter this behavior. I've tried setting the priorities of the scroll view itself, and on the documentView (which makes no sense to me, but I tried anyway). I tried the values 749, 499, 49, and 1 in every combination I could think of.
I've searched for every problem that seemed released to this, but most of the posted questions seemed to be addressing different problems.
I added a "Dump" button to log the vertical constraints. Everything listed appears to be as expected, except for a handful of NSAutoresizingMaskLayoutConstraint objects that I don't understand. However, these appear to be constraints between the document view and the clip view, so I assume those are automatically created and aren't part of the problem.
<NSLayoutConstraint:0x608000082580 PartBoxView:0x608000140840'Part B'.height == 96 (active)>
<NSLayoutConstraint:0x608000083de0 V:[PartBoxView:0x608000140840'Part B']-(0)-| (active, names: '|':NSView:0x6080001212c0 )>
<NSLayoutConstraint:0x608000083e80 V:[PartBoxView:0x608000140370'Part A']-(NSSpace(8))-[PartBoxView:0x608000140840'Part B'] (active)>
<NSLayoutConstraint:0x608000082da0 V:|-(0)-[NSScrollView:0x6080001c10e0] (active, names: '|':NSView:0x608000121400 )>
<NSLayoutConstraint:0x608000083430 V:|-(0)-[NSView:0x6080001212c0] (active, names: '|':NSClipView:0x10040e2a0 )>
<NSLayoutConstraint:0x608000081e00 V:[NSScrollView:0x6080001c10e0]-(0)-| (active, names: '|':NSView:0x608000121400 )>
<NSAutoresizingMaskLayoutConstraint:0x650000082b20 h=-&- v=-&- NSView:0x608000121400.minY == 0 (active, names: '|':NSThemeFrame:0x102504ea0'Window' )>
<NSAutoresizingMaskLayoutConstraint:0x6500000823f0 h=-&- v=-&- NSClipView:0x10040e2a0.minY == 1 (active, names: '|':NSScrollView:0x6080001c10e0 )>
<NSLayoutConstraint:0x608000083480 V:[NSView:0x6080001212c0]-(0)-| (active, names: '|':NSClipView:0x10040e2a0 )>
<NSAutoresizingMaskLayoutConstraint:0x650000082440 h=-&- v=-&- V:[NSClipView:0x10040e2a0]-(1)-| (active, names: '|':NSScrollView:0x6080001c10e0 )>
<NSLayoutConstraint:0x608000083d40 V:|-(0)-[PartBoxView:0x608000140370'Part A'] (active, names: '|':NSView:0x6080001212c0 )>
<NSLayoutConstraint:0x608000083c50 V:[NSImageView:0x608000161bc0]-(20)-| (active, names: '|':PartBoxView:0x608000140370'Part A' )>
<NSLayoutConstraint:0x608000083bb0 V:|-(20)-[NSImageView:0x608000161bc0] (active, names: '|':PartBoxView:0x608000140370'Part A' )>
<NSLayoutConstraint:0x608000083660 NSImageView:0x608000161bc0.height == 64 (active)>
I'm hoping someone with auto layout experience can tell me how to get #2 working. I'm pretty sure #3 will require some custom code, but #2 is the big hurdle.
The Background
I'm in the process of giving my app a major facelift. It's a pretty big app, so I started by creating a set of test projects to test out some of the new UI and techniques.
The first big undertaking is converting everything to auto layout. Most of it looks like it will be fairly smooth, and I'm looking forward to some of the many benefits of auto layout.
There's no completely automated way to achieve your goal #2. You want two different modes. In one mode, the window is always made large enough to accommodate the content. If it grows, the window grows; if it shrinks the window shrinks. In the other mode, the window is allowed to be smaller than required to fit the content because the user resized it that way. In that case, if the content grows, the window doesn't grow; if it shrinks, the window doesn't shrink unless the content gets small enough that it all fits and then it switches back to the first mode.
Auto layout doesn't really do modes like this, at least not automatically. You will have to detect the mode changes and programmatically modify the constraints to implement the two behavior modes.
You have apparently created constraints between the document view and the clip view to keep their tops and bottoms coincident. That essentially forces the clip view and then the scroll view to be as large as the document view. The scroll view will never scroll because it will never be too small to show all of the content.
I think you may want two constraints for the bottom spacing. One constraint will be an inequality at required (1000) priority. You want to express that the clip view should never be bigger than the document view. The document view's bottom can be greater than or equal to the clip view's bottom, but never less than.
The second bottom spacing constraint will be an equality (with 0 constant, as now) but with priority slightly less than NSLayoutPriorityWindowSizeStayPut (500). This expresses that you want the clip view and scroll view to be large enough to fit the content, unless that would force the window to grow or prevent the user from shrinking it.
The problem is that if the window is large enough to fit the content and then the content grows, that won't force the window to grow. What I've described implements the second mode.
You could try to implement the first mode by setting the second constraint's priority higher. The problem then is that the user won't be allowed to resize the window. You're back to your current situation.
What I think you'll need to do is notice when the content resizes, by observing the document view's NSViewFrameDidChangeNotification. Be sure to tell the view to post that by setting its postsFrameChangedNotifications property to true. When the frame changes, if you think you should be in the first mode, set the second constraint's priority higher, call -layoutIfNeeded on the window, and then set the priority back down. I think you may need to defer setting the priority down to the next turn of the event loop because it's not clear that you'll get the notification after the clip view has, so maybe use GCD to schedule that.
So, how do you know which mode you should be in? I'm not entirely sure. I think it will work for the window delegate (often its controller) to implement -windowDidEndLiveResize: to know when the user has finished resizing the window. I think the user resizing it will be a live resize while programmatically resizing it or auto layout resizing it won't be.
If it was the user who resized the window, you then need to know if the user grew the window so all of the content fits or if the user sized it smaller than that. For that, you could compare the height of the document view's bounds against the clip view's documentVisibleRect.
Ken,
Thanks for the thorough and thoughtful response.
You have apparently created constraints between the document view and the clip view to keep their tops and bottoms coincident.
Well, I didn't, but IB certainly did. ;)
So the first step was to edit the clip view constraints, changing the clipView.bottom-0-documentView.bottom constraint from "equals 0" to "less than or equal to 0". That permits the clip view to be (vertically) smaller than the document view, ultimately allowing the user to resize the window vertically.
I then started with your other suggestions, adding some additional constraints to pin the height to the document and either modifying its active property or changing its priority.
Ultimately, however, I went a slightly different route. The problem is that when you ask the window's contents to grow a lot, or when it's close to the bottom of the screen, its behavior is ... well, weird.
Instead, I created a "sticky" mode for the window. When set, and the document view grows, I manually calculate a new frame for the window. I do this because I can control how the window resizes when it is near the bottom, and/or top, of the screen.
Warning
I discovered the hard way that there's a hidden danger to all of these techniques. The NSViewFrameDidChangeNotification is sent whenever the frame is resized. This can happen during auto layout. If you observe this notification and immediately adjust the window size, content size, or constraints, auto layout gets very upset and throws nasty "circular" and "recursive" layout warnings (it also sometimes fails to resize properly). The solution was to simply wrap up the window size fix in a block and queue it to execute on the main thread, after all of the auto layout logic has finished.
Here's the finished, working, test project (with comments and notes): http://mbx.cm/t/Zjdml
Here's the relevant code:
#interface ViewController ()
{
BOOL windowSizeSticky; // a change in the content size should resize the window to match
}
- (void)documentSizeChangedNotification:(NSNotification*)notification;
#end
#implementation ViewController
- (void)dealloc
{
self.view.window.delegate = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)viewDidLoad
{
[super viewDidLoad];
// Enable the document view to post size change notifications
NSView* docView = self.scrollView.documentView;
docView.postsFrameChangedNotifications = YES;
// Subscribe to those changes
[[NSNotificationCenter defaultCenter] addObserver:self
selector:#selector(documentSizeChangedNotification:)
name:NSViewFrameDidChangeNotification
object:docView];
// Queue up an initial evaluation so windowSizeSticky is set correctly
dispatch_async(dispatch_get_main_queue(), ^{
[self documentSizeChangedNotification:nil];
});
}
- (void)viewWillAppear
{
// Make this controller the window's delegate
self.view.window.delegate = self;
[super viewWillAppear];
}
- (void)windowDidEndLiveResize:(NSNotification *)notification
{
// Whenever the user resizes the window, reevaluate the windowSizeSticky mode
NSView* documentView = self.scrollView.documentView;
NSClipView* clipView = (NSClipView*)(documentView.superview);
NSRect docVisible = clipView.documentVisibleRect;
NSRect docFrame = documentView.frame;
// Update the "sticky" mode depending on whether the window now displays all, or only a portion, of the contents
windowSizeSticky = (docVisible.size.height==docFrame.size.height);
}
- (void)documentSizeChangedNotification:(__unused NSNotification *)notification
{
NSView* documentView = self.scrollView.documentView;
NSWindow* window = documentView.window;
if (!window.inLiveResize) // Suppress this logic while the user is manually resizing the window
{
dispatch_async(dispatch_get_main_queue(), ^{
// Do this the next time the main loop is idle
// This notification can be sent during auto layout, and we don't want to attempt to resize
// the window in the middle of an auto layout calculation.
// The geometry of the document view has changed; check to see if the window needs resizing
NSClipView* clipView = (NSClipView*)(documentView.superview);
NSRect docVisible = clipView.documentVisibleRect;
NSRect docFrame = documentView.frame; // The doc's frame is in the clip view's coordinate system
if (docVisible.size.height==docFrame.size.height)
{
// All of the document is (vertically) visible in the clip view
// That means the window is displaying all of its contents
// Whenever this happens, switch to "sticky" mode so future changes in content will make the window grow
windowSizeSticky = YES;
}
else if (windowSizeSticky && docVisible.size.height < docFrame.size.height)
{
// The content is now taller than the view port of the scroll view & the window is "sticky"
// Try to make the window taller so all of the content is exposed
NSRect windowFrame = window.frame;
CGFloat addHeight = docFrame.size.height-docVisible.size.height;
NSRect contentRect = [window contentRectForFrameRect:windowFrame];
contentRect.size.height += addHeight;
// Calculate an ideal window frame, then adjust the existing frame so it's as close as we can get
NSRect targetFrame = [window frameRectForContentRect:contentRect];
CGFloat deltaY = targetFrame.size.height-windowFrame.size.height;
if (deltaY >= 1.0)
{
// The window needs to be taller
// Make it tall enough to display all of the content, keeping its title bar where it is
windowFrame.origin.y -= deltaY;
windowFrame.size.height += deltaY;
// Screen bounds check...
NSRect visibleFrame = window.screen.visibleFrame;
if (visibleFrame.origin.y>windowFrame.origin.y)
{
// The bottom of the window is now below the visible area of the screen
// Move the whole window up so it's back on the screen
windowFrame.origin.y = visibleFrame.origin.y;
if (visibleFrame.origin.y+visibleFrame.size.height < windowFrame.origin.y+windowFrame.size.height)
{
// The top of the window is now off the top of the screen
// Shorten the window so it's entirely within the screen
windowFrame.size.height = visibleFrame.size.height;
// This also means "sticky" mode is off, since we had to size the window to something smaller
// than its contents.
windowSizeSticky = NO;
}
}
[window setFrame:windowFrame
display:NO
animate:NO/* be consistent; constraints doesn't animate when getting shorter */];
}
}
// else { window is not sticky OR its contents doesn't exceed the height of the window: do nothing }
});
}
}
#end
I'm using Autolayout & NSSplitViewController to develop a OS X app with two split views. But every time I run the app, the split views have different size. Sometimes it shows:
But sometimes:
What I want is the left panel have a min width of 150 and an initial width of 200. The right panel have a min width of 200 and take the rest space at the initial time.
What I do are followings:
Add a NSSplitViewController and set it as the window content of the NSWindowController.
Resize the left panel to the width of 200(which I want to be the initial width, but it seems not working), and add a Table View to it. Then give the table view a min width constrain of 150.
Add a Custom View to the right panel and give it a min width constrain of 200.
Set the holding priority of the right splitViewItem to 249.
Set the view outlet of the SplitViewController to the split view.
Here's my Xcode screenshot:
The code are here: https://github.com/luin/sample-NSSplitViewControlller
I have a superview with three subviews. Two of those subviews are fixed in size and position within the superview, but the third can vary in size depending on its contents.
I have set up auto layout constraints to set the size and position of each of the sub views, but what I would like to happen is that when the contents of the third view change, the superview should resize to be able to display it fully.
I have implemented - (NSSize) intrinsicContentSize on that subview so that it returns the size it would like to be, but I can't work out how to add a constraint which will trigger the superview to resize.
Any ideas?
UPDATE:
I've got this working by adding constraints to the view like this:
sizeWidthConstraint = [[NSLayoutConstraint constraintsWithVisualFormat:#"H:[self(==viewWidth)]" options:0 metrics:metrics views:views] lastObject];
sizeHeightConstraint = [[NSLayoutConstraint constraintsWithVisualFormat:#"V:[self(==viewHeight)]" options:0 metrics:metrics views:views] lastObject];
where "viewWidth" and "viewHeight" are keys in the metrics dictionary with the desired values. In udpateConstraints I can then analyse the contents of my view and do this:
[sizeHeightConstraint setConstant: size.height];
[sizeWidthConstraint setConstant: size.width];
to set new values and the next layout will cause the view to be resized.
UPDATE in answer to panupan's question:
When you create a constraint in this way, you can put in what is essentially a variable name. In this case, those names are viewWidth and viewHeight. They're known as the constants of the constraint. You also pass in a dictionary, called the metrics, to the [NSLayoutConstrating constraintsWithVisualFormat:...] call that give values for those constants.
So, in this case, I start out with the default height and width for my view as the values for the metrics dictionary keys viewWidth and viewHeight. Later on, I use the setConstant: method to change those values. In the example above, size is a CGSize structure that I have calculated from the contents of the view (i.e. based on the sizes of its subviews) and I am using the width and height values to adjust the constants.
This doesn't achieve quite what I was after, in that it doesn't cause the size of my view to re-size automatically based on the intrinsic sizes of the subviews, but it does give me a way to re-size it manually.