Skip to content

Instantly share code, notes, and snippets.

@NickEntin
Last active September 25, 2020 06:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save NickEntin/35f1994f3b588e78e6456a438ec623d5 to your computer and use it in GitHub Desktop.
Save NickEntin/35f1994f3b588e78e6456a438ec623d5 to your computer and use it in GitHub Desktop.
Exploring the complexity behind UIView frames
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