AutoLayout with Xamarin.Mac

Jérémie Laval

As part of our (almost) weekly preview releases of the iOS designer, we recently revamped our property panel using native Cocoa widgets.

This proved to be an interesting challenge on the layout side of things since, historically, Cocoa didn’t offer any layout primitives like Gtk+ or WPF. And the property panel, being fairly dynamic in nature, needs to adapt to a lot of situations.

This changed with Mac OS X Lion where AutoLayout (otherwise known as layout constraints) was introduced to provide a better tool to describe a user interface in terms of relationships between individual views.

An Introduction to Cocoa constraints

First, let’s see exactly what a constraint is. A constraint is used to describe a characteristic (position or size) of a view with respect to a set of constants and, optionally, another view in the same hierarchy.

For those of you who have done any mechanical engineering and CAD in their life, constraints should come naturally to you if you think of the Cocoa version as a simple rigid joint between elements.

The root of the constraint system is the simple linear equation:

view1.attr = m ⋅ view2.attr + c

If a constraint doesn’t depend on another view, the equation will simply reduce to:

view1.attr = c

The job of the constraint system is to gather all those equation in a system and solve it (i.e. set each view Frame property) so that they are all respected.

Of course, living in the real world, it may happen that resolution is impossible because a set of constraints collides with others. In that case, the layout is declared ambiguous and the constraint system will repeatedly retry the process after removing one of the offending constraints until it reaches a stable system.

Another simpler, and more visual, way to understand Cocoa constraint is that it’s a way to say “I want this view’s left edge to be 13 pixels from the right side of this other view.”

If you want to further “see” constraints, AppKit provides a way to programmatically visualize constraints in any of your window by calling the NSWindow’s method VisualizeConstraints with an array of constraints.

This is, for instance, what you would see if you enabled this with the new designer property panel:

Capture d’écran 2013-08-02 à 15.07.19

Further Apple documentation links:

Writing constraints

The Basics

Constraints are created using the NSLayoutConstraint class. To instantiate it you have either the option to use the Create method (where you pass every parameter of the constraint) or an ASCII-based DSL.

I tend to prefer the first option just because our .NET wrapping makes it more readable than the other version (and we will see that we can do better anyway).

When you have created your set of constraints, you can use the AddConstraint (1 parameter constraint) or AddConstraints (constraint array parameter) from NSView to commit the constraint to the layout system. If the constraint involves the view container, you would usually add them to the latter.

If you want to remove constraints, you can use the equivalent Remove* calls or simply remove the NSView from its parent which will automatically dispose the constraints that were attached to it.

Following is an example of adding two views to a superview and constraining them to display one below the other inside their container.

var mainView = ...;
var view1 = new CustomView ();
var view2 = new CustomView ();

// View-level constraints to set constant size values
view1.AddConstraints (new[] {
    NSLayoutConstraint.Create (view1, NSLayoutAttribute.Height, NSLayoutRelation.Equal, null, NSLayoutAttribute.NoAttribute, 1, 14),
    NSLayoutConstraint.Create (view1, NSLayoutAttribute.Width, NSLayoutRelation.Equal, null, NSLayoutAttribute.NoAttribute, 1, 80),
});
view2.AddConstraints (new[] {
    NSLayoutConstraint.Create (view2, NSLayoutAttribute.Height, NSLayoutRelation.Equal, null, NSLayoutAttribute.NoAttribute, 1, 11),
    NSLayoutConstraint.Create (view2, NSLayoutAttribute.Width, NSLayoutRelation.Equal, null, NSLayoutAttribute.NoAttribute, 1, 80),
});

mainView.AddSubview (view1);
mainView.AddSubview (view2);

// Container view-level constraints to set the position of each subview
mainView.AddConstraints (new[] {
    NSLayoutConstraint.Create (view1, NSLayoutAttribute.Left, NSLayoutRelation.Equal, mainView, NSLayoutAttribute.Left, 1, 10),
    NSLayoutConstraint.Create (view1, NSLayoutAttribute.Top, NSLayoutRelation.Equal, mainView, NSLayoutAttribute.Top, 1, 10),

    NSLayoutConstraint.Create (view2, NSLayoutAttribute.Left, NSLayoutRelation.Equal, mainView, NSLayoutAttribute.Left, 1, 10),
    NSLayoutConstraint.Create (view2, NSLayoutAttribute.Top, NSLayoutRelation.Equal, view1, NSLayoutAttribute.Bottom, 1, 5),
});

Adding some Sugar

Using constraints like this is manageable, but you will soon realize that you end up repeating the same constraints over and over again—either in the form of vertically/horizontally aligned rows of elements or to make a subview fill its container.

As such, I wrote a little set of extensions methods that lets you do these common tasks with a lighter syntax:

// Constraining a button inside a container with 30 pixels padding
container.AddSubview (button);

// Using the flexibility of Linq expressions
container.DoConstraints (
       button.ConstraintTo (container, (b, c) => b.Left == c.Left + 30),
       button.ConstraintTo (container, (b, c) => b.Right == c.Right - 30),
       button.ConstraintTo (container, (b, c) => b.Top == c.Top - 30),
       button.ConstraintTo (container, (b, c) => b.Bottom == c.Bottom + 30)
);

// Or even simpler
container.AddConstraints (button.ConstraintFill (container, 30));

You can see the underlying code in this Gist. Feel free to take inspiration from it to write your own helper.

Finally, know that with Maverick, Apple is finally addressing some of those common constraint patterns by introducing the NSStackView class, although it will be several releases until this is generally available to users.

Constraints resolution

Constraints are resolved in a special layout phase called just before the view hierarchy is drawn to the screen. After that phase, each view involved will have its Frame and Bounds properties set to the right values (which means you shouldn’t set those manually anymore).

In some cases, however, you want to access these values earlier for animation purposes or to apply some height-for-width strategy—like wrapping a text field with long content depending on the final width of its container.

In that scenario, you can “force” evaluation of a subset of your NSView hierarchy constraints by calling the view’s LayoutSubtreeIfNeeded method or globally by calling NSWindow LayoutIfNeeded method.

An example, expander using constraints

We are going to see how the expander used in the property panel was implemented with constraints.

Here is the source code of the expander:

using System;
using System.Drawing;
using MonoMac.AppKit;
using MonoMac.ObjCRuntime;
using MonoMac.Foundation;
using MonoMac.CoreAnimation;

namespace Xamarin.Mac.Examples
{
    public class Expander : NSView
    {
        const int HeaderHeight = 22;

        NSGradient backgroundGradient;
        NSColor strokeColor;

        NSTextField label;
        NSView childView;

        NSLayoutConstraint heightConstraint;
        NSTrackingArea trackingArea;

        bool expanded = true;
        float height;
        int heightFluctuating;

        // This ctor is used by CoreAnimation
        public ReactiveExpander (IntPtr handle) : base (handle)
        {
        }

        public ReactiveExpander ()
        {
            label = new NSTextField {
                Bordered = false,
                Editable = false,
                DrawsBackground = false,
                Bezeled = false,
                TextColor = NSColor.FromDeviceRgba (.21f, .21f, .21f, 1),
                TranslatesAutoresizingMaskIntoConstraints = false
            };
            label.Cell.Font = NSFont.BoldSystemFontOfSize (10);

            AddSubview (label);

            AddConstraints (new NSLayoutConstraint[] {
                NSLayoutConstraint.Create (label, NSLayoutAttribute.Left, NSLayoutRelation.Equal, this, NSLayoutAttribute.Left, 1, 10),
                NSLayoutConstraint.Create (label, NSLayoutAttribute.CenterY, NSLayoutRelation.Equal, this, NSLayoutAttribute.Top, 1, HeaderHeight / 2),
            });

            backgroundGradient = new NSGradient (NSColor.FromCalibratedRgba (0.80f, 0.81f, 0.855f, 1.0f),
                                                 NSColor.FromCalibratedRgba (0.94f, 0.94f, 0.95f, 1.0f));
            strokeColor = NSColor.FromCalibratedRgba (0.70f, 0.70f, 0.70f, 1.0f);

            UpdateTrackingAreas ();
        }

        public override void UpdateTrackingAreas ()
        {
            if (trackingArea != null)
                RemoveTrackingArea (trackingArea);
            var area = Bounds;
            area.Height = HeaderHeight;
            trackingArea = new NSTrackingArea (area, NSTrackingAreaOptions.ActiveInKeyWindow | NSTrackingAreaOptions.MouseEnteredAndExited, this, null);
            AddTrackingArea (trackingArea);
        }

        public override void MouseUp (NSEvent theEvent)
        {
            Expanded = !Expanded;
        }

        public override void DrawRect (RectangleF dirtyRect)
        {
            var headerFrame = new RectangleF (PointF.Empty, new SizeF (Bounds.Width, HeaderHeight));
            backgroundGradient.DrawInRect (headerFrame, -90);
            strokeColor.SetFill ();
            NSGraphics.RectFill (new RectangleF (0, 0, Bounds.Width, 1));
            NSGraphics.RectFill (new RectangleF (0, HeaderHeight - 1, Bounds.Width, 1));
        }

        public override SizeF IntrinsicContentSize {
            get {
                return new SizeF (-1, HeaderHeight);
            }
        }

        public bool Expanded {
            get {
                return expanded;
            }
            set {
                expanded = value;
                if (childView != null) {
                    if (!expanded && heightFluctuating == 0)
                        height = Bounds.Height;
                    heightFluctuating++;
                    NSAnimationContext.RunAnimation (ctx => {
                        ctx.TimingFunction = CAMediaTimingFunction.FromName (CAMediaTimingFunction.EaseOut);
                        ((NSView)childView.Animator).AlphaValue = !expanded ? 0 : 1;
                        ((NSLayoutConstraint)heightConstraint.Animator).Constant = expanded ? HeaderHeight : -height + HeaderHeight;
                    }, () => heightFluctuating--);
                }
            }
        }

        public string Label {
            get {
                return label.StringValue;
            }
            set {
                label.StringValue = value;
            }
        }

        public override bool IsFlipped {
            get {
                return true;
            }
        }

        public NSView ChildView {
            get {
                return childView;
            }
            set {
                if (childView != null && value != null)
                    ReplaceSubviewWith (childView, value);
                else if (childView == null && value != null)
                    AddSubview (value);
                else if (childView != null && value == null)
                    childView.RemoveFromSuperview ();

                childView = value;
                childView.TranslatesAutoresizingMaskIntoConstraints = false;
                if (value != null) {
                    AddConstraints (new NSLayoutConstraint[] {
                        NSLayoutConstraint.Create (childView, NSLayoutAttribute.Top, NSLayoutRelation.Equal, this, NSLayoutAttribute.Top, 1, HeaderHeight),
                        NSLayoutConstraint.Create (childView, NSLayoutAttribute.Left, NSLayoutRelation.Equal, this, NSLayoutAttribute.Left, 1, 0),
                        NSLayoutConstraint.Create (childView, NSLayoutAttribute.Width, NSLayoutRelation.Equal, this, NSLayoutAttribute.Width, 1, 0),
                        (heightConstraint = NSLayoutConstraint.Create (this, NSLayoutAttribute.Height, NSLayoutRelation.GreaterThanOrEqual, childView, NSLayoutAttribute.Height, 1, HeaderHeight))
                    });
                }
            }
        }
    }
}

The idea of this code is to host two views, a text field as a header label and a child view with the content. The header is painted as a subset of our bounds with a gradient and border in DrawRect.

These subviews are constrained to fit inside the container view, one below the other. The only part where the container uses its child view is for the total height of the widget, which is deemed equal to the height of our child view and the additional padding necessary to draw the header.

After NSLayoutConstraint objects are created, the only aspect that can be changed without having to remove the constraint and create another one is its Constant parameter.

Thus, the idea is to use this with our vertical constraint by changing the constant to minus the height of our child view (after constraints are resolved and the Bounds property returns a valid value) so that, thanks to normal NSView clipping, the child view effectively “collapses”.

There are several more points to highlight in this code:

TranslatesAutoresizingMaskIntoConstraints

When you start using constraints, you should make a habit of setting that property to false on any views that are involved with constraints.

The goal of this property is to provide backward compatibility with the legacy autoresizing mask facility by automatically generating a pair of constraints mimicking the autoresizing mask set on a view.

However, as soon as you start adding more constraints, these auto-generated ones will usually provoke conflicts in the resolution phase. The safest thing is to always disable them.

IntrinsicContentSize

This new property is used by the constraint system to advertise the intrinsic size of your views. The idea is that the value returned here corresponds to the minimum size required for your views to display its content properly.

If you think of a button hosting an icon image and a label, for instance, its intrinsic content size is the minimum width/height required to display both of those items on a single line with no padding applied.

When returning an intrinsic size, you may also set one of the members to -1 to tell the system that the view has no specific intrinsic value in that direction.

In our case, for instance, the expander must at least display its header (hence its intrinsic height) but doesn’t care about its width (the text field we use internally will report its eventual intrinsic width itself).

Animations

As we saw earlier, the only component that can be changed later in a constraint is its Constant property.

Since NSLayoutConstraint implements the NSAnimatablePropertyContainer protocol, it has an Animator property that can be used to animate value changes to Constant.

We use that facility in the Expander property setter to provide a smooth transition between the opening and closed state of our expander (while also changing the alpha value at the same time).

Feedback usabilla icon