Constraints & Transformations

Building frameworks is hard, we all know that. It’s even harder when you’re building and maintaining a behemoth like UIKit and you have to make sure client code doesn’t break when a new version of your framework is released. Backward compatibility allows existing apps to keep functioning on newer versions of iOS, even if they are built for an older version. However, sometimes implementing new features with backward compatibility means introducing inconsistent behaviour between different versions of the framework.

The Problem

Prior to the release of iOS 8, mixing Auto Layout with view affine transformations was a shady business: some configurations just didn’t work as expected, so it was mostly avoided or worked around (this Stack Overflow question on the topic has been quite popular). But soon after iOS 8 was released it became apparent that one of the many changes Apple introduced was how Auto Layout and transformations interact. To illustrate, here are two screenshots of the same app running on iOS 7 and iOS 8:

Notice that on iOS 7, views in the left column are misplaced, while on iOS 8 they are positioned as expected.

Notice that on iOS 7, views in the left column are misplaced, while on iOS 8 they are positioned as expected.

This app has three rows of transformed views: one for translation, one for rotation and one for scaling. Transformed views in the left column are laid out using Auto Layout, while transformed views in the right column are positioned manually by setting the center property. The transforms themselves are the same across columns. There are also untransformed grey views that serve as “anchors” for the transformed views. Views in the left column are connected via Top and Leading constraints to their “anchor” views (though using Bottom, Trailing, Left or Right attributes would have similar results). Notice that on iOS 7, views in the left column are misplaced (red), while on iOS 8 they are positioned as expected (green). The sample app is available on GitHub.

The behaviour of misplaced views is present when running iOS 7 or older, or when linking against iOS 7 SDK. Clearly, this looks like a fix introduced in iOS 8 with backward compatibility for older apps. What exactly is happening, and why?

Investigation

For the purposes of debugging this behaviour, we’ll focus on translation, since it’s the simplest example of the three. First, let’s launch Reveal to take a quick look at the view hierarchy and attributes. Here’s how it looks on iOS 7:

Notice that the red view is visually offset by (-10, 10) from its grey “anchor” view, while the green view is correctly offset by (-20, 20), which corresponds to its transform matrix. With the red view selected (a), you can also see that its alignment rect is misplaced (b), while for the green view (c) it correctly matches its “anchor” view (d).

Let’s take a look at the same app running on iOS 8:

The views are now aligned, and all alignment rectangles match their “anchor” views. Clearly Auto Layout behaviour has changed: center values of constraint-positioned views are not the same between iOS 7 and iOS 8.

Diving Deeper

As a starting point in debugging this behaviour we’ll determine what alters the position of a view when using Auto Layout, as this appears to have changed between iOS 7 and iOS 8.

We can do this by using the same sample app and overriding the setFrame: and setCenter: instance methods in the GradientView class. By placing breakpoints in these methods we can see that the overridden setFrame: method is not called by UIKit at all during layout on iOS 7 or iOS 8. Only the setCenter: method is called to update the view’s position. This makes sense when dealing with transformed views, since the frame property cannot be used reliably on views with a non-identity transformation applied:

If the transform property is not the identity transform, the value of this property is undefined and therefore should be ignored. – UIView Class Reference

The frame property of a view is actually calculated by the underlying CALayer as a derivative of the layer’s bounds, anchorPoint, position and transform properties in the parent layer’s coordinate space.

The stack trace leading to the setCenter: call during a layout pass reveals internal UIKit methods used to apply the layout:

With the help of Hopper and using the _applyISEngineLayoutValues method as a starting point, it is possible to disassemble UIKit and analyse its implementation of Auto Layout. It appears to have changed quite significantly in iOS 8, with a private function __UILayoutEngineSolutionIsInRationalEdges now determining the “layout engine solution style” used throughout UIKit. “Layout engine” here refers to NSISLayoutEngine: a private class in Foundation framework that implements the iterative linear arithmetic constraint solver, the heart of Auto Layout. The function itself returns YES if the app is linked against iOS 8 SDK, and NO otherwise, thus enabling backward compatibility with apps that haven’t been updated for iOS 8.

Some of the methods that provide legacy behaviour depending on the layout solution style include:

  • -[UIView setTransform:]
  • -[UIView setBounds:]
  • -[UIScrollView setContentInset:]
  • -[UIScrollView _nsis_contentSize]
  • -[UIWindow _initializeLayoutEngine]
  • -[UIView _updateLayoutEngineHostConstraints]
  • -[UIView _preferredLayoutEngineToUserScalingCoefficients]
  • … and more.

Critically, the layout solution style also determines which method is used to convert the solution from the solver into the resulting view geometry:

  • _nsis_origin:bounds:inEngine: method is used with “legacy” solution style. Its implementation performs geometry calculations that take view’s transform property into account, which corresponds to the behaviour we can see on iOS 7.
  • _nsis_center:bounds:inEngine: method is used with “modern” solution style. Its implementation doesn’t use view’s transform matrix, so the results of its calculations are unaffected by the applied transformations.

Conclusions

As it turns out, the observed differences in the behaviour of Auto Layout between iOS 7 and iOS 8 are caused by a deliberate change in UIKit. View transformations no longer seem to factor into Auto Layout’s calculations.

So what does this mean in practice?

If your app:

  • Links against the iOS 8.0 SDK or later, and
  • Supports iOS 7, and
  • Uses Auto Layout on views with non-identity transforms

You may have to resort to one of the following workarounds:

  • If you’re only using rotation and/or scaling transformations, try using Centre X/Centre Y constraints instead of Top/Bottom/Left/Right/Trailing/Leading constraints: if the transformed view is laid out by its centre, results tend to be correct.
  • Place any transformed views inside a container view, and:
    • Constrain the container view rather than the transformed view.
    • The transformed view could be laid out in the container either manually, or using Centre X and Centre Y constraints. Using Equal Width/Height constraints between the transformed view and container view, however, will not give expected results in this case.
  • Do not use explicit constraints for these views: use autoresizing masks. Fall back to the behaviour provided by UIKit for views with translatesAutoresizingMaskIntoConstraints property being YES. You will probably need to configure the autoresizingMask property of your views in code.

If your app doesn’t need to support iOS 7, you’re in luck! Auto Layout’s behaviour in iOS 8 and higher is more obvious and correct.

I do wish that Apple had been more transparent when introducing these changes: this would have allowed developers to make more informed decisions without going through trial, error and disassembly. It also would have made it easier to update apps to support the latest versions of the system frameworks.

Vlas Voloshin
Vlas Voloshin
Developer
by Itty Bitty Apps