-
-
Save NickEntin/35f1994f3b588e78e6456a438ec623d5 to your computer and use it in GitHub Desktop.
Exploring the complexity behind UIView frames
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import PlaygroundSupport | |
import UIKit | |
// The `UIView.frame` property is commonly seen in layout code as a way to read and manipulate the size and position of | |
// views in the hierarchy. The documentation for `frame` briefly describes its behavior: | |
// | |
// The frame rectangle, which describes the view’s location and size in its superview’s coordinate system. | |
// | |
// This rectangle defines the size and position of the view in its superview’s coordinate system. Use this rectangle | |
// during layout operations to set the size and position the view. Setting this property changes the point specified | |
// by the center property and changes the size in the bounds rectangle accordingly. The coordinates of the frame | |
// rectangle are always specified in points. | |
// | |
// It describes the `frame` as a computed variable made up of the position from `center` and size from `bounds`. In | |
// reality, it's a lot more complicated than this. And in fact, there are some cases where the value of `frame` is | |
// undefined. Let's take a look at some of this complexity. | |
// In the simplest case, the `frame` is a simple reflection of the `bounds.size` and `center`. | |
let simpleView = UIView() | |
simpleView.bounds.size = .init(width: 300, height: 200) | |
simpleView.center = .init(x: 1000, y: 1000) | |
simpleView.frame // (x: 850, y: 900, w: 300, h: 200) | |
// Note that it's only the _size_ of the bounds that matters. Changing the `bounds.origin` has no affect on the frame, | |
// and vice versa. | |
let nonOriginBoundsView = UIView() | |
nonOriginBoundsView.bounds = .init(x: 100, y: 100, width: 300, height: 200) | |
nonOriginBoundsView.center = .init(x: 1000, y: 1000) | |
nonOriginBoundsView.frame // (x: 850, y: 900, w: 300, h: 200) | |
nonOriginBoundsView.frame = .init(x: 150, y: 250, width: 350, height: 450) | |
nonOriginBoundsView.bounds // (x: 100, y: 100, w: 350, h: 450) | |
nonOriginBoundsView.center // (x: 325, y: 475) | |
// The `frame` is officially undefined when using a non-identity `transform`. In practice, you can calculate the frame | |
// in many cases by applying the transform to the bounds and center. For simple transforms, this is easy. | |
let translatedView = UIView() | |
translatedView.bounds.size = .init(width: 300, height: 200) | |
translatedView.center = .init(x: 1000, y: 1000) | |
translatedView.transform = .init(translationX: 50, y: 50) | |
translatedView.frame // (x: 900, y: 950, w: 300, h: 200) | |
let scaledView = UIView() | |
scaledView.bounds.size = .init(width: 300, height: 200) | |
scaledView.center = .init(x: 1000, y: 1000) | |
scaledView.transform = .init(scaleX: 2, y: 2) | |
scaledView.frame // (x: 700, y: 800, w: 600, h: 400) | |
// It gets a bit more complicated once you involve rotations or skews in the transform. The `frame` is a simple rect, so | |
// it can't represent edges that aren't along the x/y axes. Instead, the `frame` acts as a bounding box for the | |
// transformed view. | |
let rotatedView = UIView() | |
rotatedView.bounds.size = .init(width: 300, height: 200) | |
rotatedView.center = .init(x: 1000, y: 1000) | |
rotatedView.transform = .init(rotationAngle: .pi / 4) | |
rotatedView.frame // (x: 823.223, y: 823.223, w: 353.553, h: 353.553) | |
// One interesting characteristic about the `frame` being a bounding box is that it will never have a negative value in | |
// either dimensions of its size. | |
let negativeScaledView = UIView() | |
negativeScaledView.bounds.size = .init(width: 300, height: 200) | |
negativeScaledView.center = .init(x: 1000, y: 1000) | |
negativeScaledView.transform = .init(scaleX: 2, y: 2) | |
negativeScaledView.frame // (x: 700, y: 800, w: 600, h: 400) | |
// The width and height are still positive, even though our view is flipped across both axes. | |
// Interestingly, this same charateristic is true of the `bounds` as well. Setting a negative width or height value on | |
// the `bounds` will adjust the `origin` appropriately to represent the same rect in space, but with a positive size. | |
let negativeSizeView = UIView() | |
negativeSizeView.bounds.size = .init(width: -100, height: 200) | |
negativeSizeView.bounds // (x: -100, y: 0, w: 100, h: 200) | |
// The other property that matters here is the layer's `anchorPoint`. | |
let anchoredView = UIView() | |
anchoredView.bounds.size = .init(width: 300, height: 200) | |
anchoredView.center = .init(x: 1000, y: 1000) | |
anchoredView.layer.anchorPoint = .init(x: 0, y: 0) | |
anchoredView.frame // (x: 1000, y: 1000, w: 300, h: 200) | |
// Seems odd that the _center_ isn't at (1000, 1000), right? In my opinion, `center` is a poorly named property. To | |
// understand what's going on here, we need to take a look at what these properties do a layer down. A layer down from | |
// the view is... the layer! (Yes, it's confusing. Fine, I'll stop with the puns for now.) | |
// As an interesting side note... on iOS, all views are backed by a layer. This differs from macOS, where a view may or | |
// may not use a layer as its backing store. | |
// Many of the properties on our view translate directly to a property on the `CALayer` that backs it. | |
let layerBackedView = UIView() | |
// Setting our view's `bounds` updates the `bounds` of the layer, and vice versa. | |
layerBackedView.bounds = .init(x: 50, y: 50, width: 100, height: 100) | |
layerBackedView.layer.bounds // (x: 50, y: 50, w: 100, h: 100) | |
layerBackedView.layer.bounds = .init(x: 0, y: 0, width: 200, height: 200) | |
layerBackedView.bounds // (x: 0, y: 0, w: 200, h: 200) | |
// Our layer doesn't have a `center` property though. Instead, it defines a `position`. | |
layerBackedView.center = .init(x: 100, y: 100) | |
layerBackedView.layer.position // (100, 100) | |
layerBackedView.layer.position = .init(x: 50, y: 50) | |
layerBackedView.center // (50, 50) | |
// That position is relative to the layer's anchor point. The anchor point is an (x,y) coordinate in the unit coordinate | |
// space, where `0` is the top/left edge of the view and `1` is the bottom/right edge of the view. | |
// By default, the anchor point is set to `(0.5, 0.5)`, the center of the layer. This is why the view's `center` | |
// defaults to the center point of the view's frame. There's no equivalent anchor point property on the view, so we need | |
// to look at the layer to know what the view's `center` refers to. | |
let anchoredLayer = CALayer() | |
anchoredLayer.bounds.size = .init(width: 100, height: 100) | |
anchoredLayer.position = .init(x: 50, y: 50) | |
anchoredLayer.frame // (x: 0, y: 0, w: 100, h: 100) | |
anchoredLayer.anchorPoint = .init(x: 0, y: 0) | |
anchoredLayer.frame // (x: 50, y: 50, w: 100, h: 100) | |
// The view's `transform` also maps to a `transform` property on the layer. | |
let transformedView = UIView() | |
transformedView.bounds.size = .init(width: 100, height: 100) | |
transformedView.layer.transform = CATransform3DMakeTranslation(100, 200, 0) | |
transformedView.transform.tx // 100 | |
transformedView.transform.ty // 200 | |
transformedView.transform = .init(translationX: 40, y: 60) | |
transformedView.layer.transform.m41 // 40 (m41 is equivalent to the tx field) | |
transformedView.layer.transform.m42 // 60 (m42 is equivalent to the ty field) | |
// A transform is applied to the layer based on its anchor point. | |
let transformedLayer = CALayer() | |
transformedLayer.bounds.size = .init(width: 300, height: 200) | |
transformedLayer.position = .init(x: 1000, y: 1000) | |
transformedLayer.anchorPoint = .init(x: 0.5, y: 0) | |
transformedLayer.frame // (x: 850, y: 1000, w: 300, h: 200) | |
transformedLayer.transform = CATransform3DMakeScale(2, 2, 1) | |
transformedLayer.frame // (x: 700, y: 1000, w: 600, h: 400) | |
// The width and height of the layer's `frame` changed, along with the x position, but the y position stayed fixed. This | |
// is because our layer is anchored at its top center point, so it was scaled out from there. When this layer is backing | |
// a view, this will be reflected in the view's `frame` as well. | |
// This is where things really get complicated though, since the layer's `transform` and view's `transform` properties | |
// don't have the same type. | |
// The view's `transform` property is a `CGAffineTransform`, which can represent a 2D transformation of the view. The | |
// layer's `transform` property, on the other hand, is a `CATransform3D`, which, as it's name suggests, can represent a | |
// 3D transformation of the layer. If we're only transforming in two dimensions, this all works fine. | |
transformedView.layer.transform = CATransform3DMakeTranslation(100, 200, 0) | |
CATransform3DIsAffine(transformedView.layer.transform) // true | |
transformedView.transform.tx // 100 | |
transformedView.transform.ty // 200 | |
// What if we're transforming in three dimensions, though? Under the hood, this is probably using the | |
// `CATransform3DGetAffineTransform(_:)` method to determine the value for `UIView.transform`. Let's take a look at the | |
// headerdoc for that method: | |
// | |
// Returns the affine transform represented by 't'. If 't' can not be represented exactly by an affine transform the | |
// returned value is undefined. | |
// | |
// Cool, so we can't rely on our view's `transform` property being valid if we've set a non-affine transform on our | |
// layer. What does this do to our view's properties though? | |
transformedView.layer.transform = CATransform3DMakeRotation(.pi / 4, 1, 1, 0) | |
transformedView.transform.isIdentity // true | |
// That doesn't seem right. What happens to the frame in this case? Let's visualize this. | |
let containerView = UIView() | |
containerView.backgroundColor = .white | |
containerView.bounds.size = .init(width: 200, height: 200) | |
transformedView.backgroundColor = .red | |
transformedView.center = .init(x: 100, y: 100) | |
containerView.addSubview(transformedView) | |
let frameVisualizationView = UIView(frame: transformedView.frame) | |
frameVisualizationView.backgroundColor = UIColor.green.withAlphaComponent(0.5) | |
frameVisualizationView.layer.zPosition = 1000 | |
containerView.addSubview(frameVisualizationView) | |
PlaygroundPage.current.liveView = containerView | |
// Our frame is still the bounding box of the view, but it's really the view's layer's transform that matters in | |
// calculating it, not the view's transform. | |
// Or at least that's how it works today. There's a nice big warning on the documentation for `UIView.frame`: | |
// | |
// If the `transform` property is not the identity transform, the value of this property is undefined and therefore | |
// should be ignored. | |
// | |
// So this could all change in the next release. Will it? Probably not. Is there more complexity in how the frame is | |
// generated that we haven't talked about here? Quite possibly. | |
// | |
// As we just saw above, checking `isIdentity` on our view's `transform` isn't actualy sufficient here for knowing | |
// whether the frame is undefined - we need to check our _layer's_ `transform`. If it's the identity transform, our | |
// `frame` is defined and safe to use. But unless we're sure that our layer will never be transformed, it's safer to set | |
// the underlying properties that our frame is calculated from directly. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment