Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

layoutSubviews vs frame didSet to detect when the view's frame changed

Tags:

ios

swift

uiview

Say I have a UIView subclass that needs to update something when its frame changes. E.g. when it's on an even pixel, it'll be red, and blue when it's on an odd pixel.

I've seen at least four ways to do this:

  1. view.layoutSubviews():

    override func layoutSubviews() {
        super.layoutSubviews()
        backgroundColor = calculateColorFromFrame(frame)
    }
    
  2. view.frame didSet:

    override var frame: CGRect {
        didSet {
            backgroundColor = calculateColorFromFrame(frame)
        }
    }
    
  3. Add a KVO observer on the view's frame.

  4. On the view's wrapping UIViewController, implement viewDidLayoutSubviews().

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        view.updateBackgroundColorForNewFrame()
    }
    
  5. Any other way you know of?

Which of these methods is preferred, and is one inherently better than the other? What's Apple's official recommendation?

like image 882
Senseful Avatar asked Sep 05 '18 18:09

Senseful


People also ask

When should I use layoutSubviews?

layoutSubviews() The system calls this method whenever it needs to recalculate the frames of views, so you should override it when you want to set frames and specify positioning and sizing. However, you should never call this explicitly when your view hierarchy requires a layout refresh.

What does setNeedsLayout do?

Invalidates the current layout of the receiver and triggers a layout update during the next update cycle.

What is layoutIfNeeded in Swift?

layoutIfNeeded()Lays out the subviews immediately, if layout updates are pending.


1 Answers

  1. layoutSubviews: You can rely on this being called on a view whose size has changed, or if the view has received setNeedsLayout. If the view's position changes but its size doesn't change, the system doesn't automatically call layoutSubviews on that view.

  2. frame didSet: This works for a view that is laid out using autoresizingMask. It does not work for a view laid out using normal constraints. Instead, auto layout sets the view's center and bounds. However, these are implementation details subject to change in different versions of UIKit. I tested on the iOS 12.0 Simulator that comes with Xcode 10 beta 6.

  3. KVO on frame: This won't work in some circumstances for the same reason as #2. Also, frame is not documented to be KVO-compliant, so UIKit is allowed to change frame without notifying observers.

  4. viewDidLayoutSubviews: This can only work if the view is the view property of the view controller, and then only if the view receives the layoutSubviews message (see #1). Also it's important to understand that when this is called, the view controller's view and its direct subviews have been laid out, but deeper subviews have not been laid out yet. (See this answer for a fuller explanation.)

Note also that a view's frame is computed from some properties of its layer: the layer's position, bounds.size, anchorPoint, and transform. If anything sets those layer properties directly, none of the above methods will work.

Because of all this, there's no particularly great way to be notified when the view's position changes. The technically correct way is probably to use a CAAction. When the layer detects that its position is changing, it asks its delegate (which is the view itself, for a view's layer) for an action to run. It asks by sending the actionForLayer:forKey: message. So you can override action(forLayer:forKey:) in your view subclass to be notified reliably. However, the layer asks for the action before changing its position (and hence its frame). It runs the action after changing its position. So you have to go through a little dance like this:

override func action(for layer: CALayer, forKey event: String) -> CAAction? {
    class MyAction: CAAction {
        init(view: MyView, nextAction: CAAction?) {
            self.view = view
            self.nextAction = nextAction
        }
        let view: MyView
        let nextAction: CAAction?
        func run(forKey event: String, object: Any, arguments: [AnyHashable : Any]?) {


            // THIS IS WHERE YOU LOOK AT THE NEW FRAME AND ACT ACCORDINGLY.


            print("This is the action. Frame now: \(view.frame)")
            nextAction?.run(forKey: event, object: object, arguments: arguments)
        }
    }

    let action = super.action(for: layer, forKey: event)
    if event == "position" {
        return MyAction(view: self, nextAction: action)
    } else {
        return action
    }
}

The UIView implementation of actionForLayer:forKey: normally returns nil. But inside an animation block (including when the interface is rotating between portrait and landscape), it returns (for some keys) an action that installs a CAAnimation on the layer. So it's important to run the action returned by super if there is one.

like image 65
rob mayoff Avatar answered Oct 29 '22 07:10

rob mayoff