Disclosure Triangle (Autolayout)

(Xcode 10, Swift 4.2, macOS 13.6)

This is a common use case: users who just want an overview get a smaller window with the most important content, users who want more detail can click a triangular button and more details unfold. I seem to have tackled this problem several times without feeling completely happy with the results; I’d like to put this topic to bed once and for all. (Also, I have reached a point where I am trying to fit too much information onto one screen, and disclosure is becoming a necessary element.

In a deviation from how these posts are usually constructed, I will not take you through all of these options. Since there isn’t a straightforward out-of-the-box ‘disclosureView’ provided by Apple, there are a number of ways in which the disclosure effect can be achieved. All of them are used successfully by someone, somewhere. My goal was to find one that I found relatively straightforward and easy to set up.

ScreenShot2019-09-15at15.42.39-2019-09-15-15-02.pngScreenShot2019-09-15at15.42.48-2019-09-15-15-02.png

This is what we want to achieve: we have an ordinary window that includes a disclosure button. When the button is pressed, the window expands to show more content.

Multiple strategies, nearly same outcome
0) Strategies
Each of these would need different outlets and different constraints, which is one reason I’m not taking you through all of them.

0a): Using Window Frame
I just mention this for the sake of completeness, since this was how you would have solved the problem before autolayout: you work out how much space you need to add at the bottom, respectively how much you need to shorten the window, and you manipulte the window’s frame. This is not a good solution; autolayout was created to help avoid many of the problems associated with this: don’t do it.

1) Using Stack Views
Stackviews automagically arrange their subviews, which is why the content in stackview always needs to have size constraints set. This sounds like a simple and straightforward solution, only…

ScreenShot2019-09-15at16.04.39-2019-09-15-15-02.png
Despite having set the content hugging priority for the stackView to 1000, there does not appear to be a setting that says ‘adjust the stackview to match its content height; adjust the window to match the stackview height’.

Showing and hiding flamingos is trivial: you need to create an outlet for the flamingoView in your viewController and an action for the button:

@IBAction func discloseFlamingos(_ sender: NSButton) {
switch sender.state{
case .on:
flamingoView.isHidden = false
case .off:
flamingoView.isHidden = true
default: print("disclosure buttons don't have a mixed state")
}
}

@IBOutlet var flamingoView: NSImageView!

so that’s quick and painless and unproblematic. (With a slight caveat: you cannot drag from the imageView TO the viewController to create the outlet, you have to create the outlet and connect it in the storyboard.)

The disclosureButton and label are grouped in a stackview. If we put an autolayout constraint between this stackview and the top image view…

… spoiler: nothing happens.

When I redid the step of putting items into the stackView, the innerStack (with button and label) now sticks to the top image view, but the window size is determined by all of the items in the stackView, which means that the window does not shrink back.
(I am starting here with a manually resized window, the ‘expand window frame to show all’ part is working, the ‘contract window frame to save space’ part is not.

ScreenShot2019-09-15at16.36.36-2019-09-15-15-02.pngScreenShot2019-09-15at16.36.44-2019-09-15-15-02.pngScreenShot2019-09-15at16.36.51-2019-09-15-15-02.png

I’m sure there’s some constraint trickery I could use, but I am concerned about the ‘I add items to the stackview and they behave in a certain manner; I add them again and they are evaluated differently’ part. What if the evaluation does not depend on in what order I add things in IB, but happens at runtime? I want my users to have a consistent, pleasant experience: this is too random to be useful to me.

2) Add and remove views
I’m adding this option for the sake of completion. If your auxillary view contains a lot of information and would eat a lot of memory, and most users will never expand it, this is an option to consider – lazily load the view if necessary, never load it if not. This does not get around the problem with constraints, so I will end this branch without example.

3) Hide view, deactivate constraints
This is where the autolayout magic sets in.
Arrange the two views and the disclosureButton/Label stack as before. Connect the flamingoView and the button’s action as in Section 1.

Add constraints to the top view for the top, leading and trailing edges, and set a constraint to the bottom space to Container calculated on the collapsed size of the window, eg with a constant of 50.

This is the magic trick without which you will experience a lot of frustration later: the priority of this constraint should be 999 (or lower). If it is 1000, it is non-optional, and deactivating it still leads to ambiguous constraints.

You will experience a lot of frustration. When you are creating a set of ambiguous constraints, even if you create outlets for those constraints and activate/deactivate them, you’ll confuse Xcode, and in its confusion, it’ll panic. Suddenly views are all over the place and resized and superimposed on each other and you’ll have to pick them apart again.

Remember how I just said that I don’t feel that Stack Views are ready for production on macOS, however tempting they are? (On iOS, as far as I can tell, they work as intended/advertised. But I just ran into a little quirk preparing this solution (I’ll share the constraints in a moment): For the stack view version, I had embedded the button and its label in a stack view. When I ran my solution, it looked like this (IB in the background for comparison):
ScreenShot2019-09-15at21.41.15-2019-09-15-15-02.png

That’s right: the button moved up, the label vanished, and the window took up the full size to include space for the hidden view. When I expanded the button, it moved down and the label appeared as intended.

Taken out of the stack view, and chained with autolayout constraints, we get:
ScreenShot2019-09-15at21.42.56-2019-09-15-15-02.png

which is _exactly_ what I’m after: the window resizes to accommodate only the top image view and the button/label combo.

There should be no autolayout difference between two items and two items wrapped in a stackView, the stackView should add convenience, not add labour and WTF moments (where IS my label???)

The magic, as mentioned, lies in the constraints.

ScreenShot2019-09-15at22.07.01-2019-09-15-15-02.pngScreenShot2019-09-15at22.07.29-2019-09-15-15-02.png

The top view has two size constraints, height and width, and three fixed constraints: top, leading, and trailing.
The top view has an optional (priority 999) constraint to the bottom of the window. The fixed value for this constraint is 50 – enough to make space for the button/label combo and leave a little margin at the bottom; it is NOT the distance shown in this window. This constraint has an outlet.

The label/button combo as a leading constraint for the button, a constraint to the container top, a constraint from the label to the container top, and a constraint setting space between button and label.

The bottom view has height and width constraints, and leading and trailing.
The bottom view also has one constraint to the bottom of the container and one constraint to the top view. Both these constraints have outlets.

The outlets are:
@IBOutlet var flamingoBottomConstraint: NSLayoutConstraint!
@IBOutlet var churchBottomConstraint: NSLayoutConstraint!
@IBOutlet var churchToFlamingo: NSLayoutConstraint!

Note that these are strong outlets, in a break with transitions, but weak outlets for constraints frequently ending with the constraint going walkabout and the app crashing. Change the flamingoBottomConstraint to ‘weak’ and try it yourself.

override func viewDidLoad() {
super.viewDidLoad()
flamingoView.isHidden = true
flamingoBottomConstraint.isActive = false
churchBottomConstraint.isActive = true
churchToFlamingo.isActive = false
}

@IBAction func discloseFlamingos(_ sender: NSButton) {
switch sender.state{
case .on:
flamingoView.isHidden = false
flamingoBottomConstraint.isActive = true
churchBottomConstraint.isActive = false
churchToFlamingo.isActive = true
case .off:
flamingoView.isHidden = true
flamingoBottomConstraint.isActive = false
churchBottomConstraint.isActive = true
churchToFlamingo.isActive = false
default: print("This button has only two states")
}
}

@IBOutlet weak var flamingoView: NSImageView!

@IBOutlet var flamingoBottomConstraint: NSLayoutConstraint!
@IBOutlet var churchBottomConstraint: NSLayoutConstraint!
@IBOutlet var churchToFlamingo: NSLayoutConstraint!

If you had more items, you’d group them better and/or draw out the settings into separate functions. You can also ensure that you set things up correctly once in viewDidLoad before simplifying the disclose function to a much more opaque form:

@IBAction func discloseFlamingos(_ sender: NSButton) {
flamingoView.isHidden.toggle()
flamingoBottomConstraint.isActive.toggle()
churchBottomConstraint.isActive.toggle()
churchToFlamingo.isActive.toggle()
}

This works perfectly well, but it is not, maybe, helpful when six months down the line you want to know how you achieved this effect.

4) Instead of hiding the view, set its height to 0
You will need the same constraints as explained in Section 3. However, the only outlet you’ll need is to the height constraint of the flamingo view, and the disclose method now reads:

@IBAction func discloseFlamingos(_ sender: NSButton) {
switch sender.state{
case .on: flamingoHeight.constant = 70
case .off:
flamingoHeight.constant = 0
default: print("This button has only two states")
        }
        }

This has the advantage of having fewer constraints that need to be manipulated, as well as being easier to remember, but it feels a little less smooth and would be a lot more work to convert into a lazy loading method.

My personal feeling is that I prefer the hide/activateConstraints method, simply because ‘set height to 0’ feels like a hack while ‘hide and deactivate’ feels like I’m using elements as intended.

5) So what if you want to show/hide an entire form instead of a single imageView? This is a case for embedding a view: you treat the embedView (which is really an NSView) exactly like you did the flamingoView, give it exactly the same constraints (if you don’t set a width/height for it, the content will not display correctly) and, with the same number of constraints and the same effort, you can now show and hide a whole form or as much content as you want to. For extra convenience, you can embed an NSTabViewController, and present context-sensitive content with the same interface.

And more…

Usage Example

Frequently in forms and inspectors where you want to show an overview and details in the same space. A disclosure space allows the interface to be less cluttered and makes fewer demands on the user in terms of moving their attention to another pane/window/a popover that disappears when not used. Especially in apps with large featuresets, you can use disclosure to allow customisation and give each user quick access to the tools THEY need.

Alternatives

Popovers when the information viewed is less permanent. You can change the visual appearance (the show/hide buttons in Source Lists and collapsing sections in tableviews fulfill the same purpose). Very rarely will you want to choose a different form of collapsed content; Xcode’s code folding is one such example.

Extensions

With the introduction of embedded views I don’t feel I can take this much further. One thing to work out would be multiple disclosure views, but I’d probably be inclined to keep them modular and treat each content-disclosureButton-hiddenContent sequence as a unit.

Bonus bug report:

If you’re experimenting with things, you may be tempted to connect an IBOutlet declaration in your view controller with the constraint in the storyboard. If you drop the connector onto the constraint, the circle fills and all seems to be fine… until you try to run the app, at which point the window does not find its content and throws a hissy fit.
This thing happened:
ScreenShot2019-09-15at23.52.00-2019-09-15-15-02.png

So the contentView of the viewController is set to an NSLayoutConstraint… which is not great when you actually try to display the contents of the view. This should not be possible, and yet it can happen, and it’ll pretty much ruin your day if you didn’t pay attention, so always ALWAYS make connections to constraints in the document outine of the storyboard and never try to connect with an existing constraint in the view itself.