1
votes

I'm currently playing with CALayers a bit. For demo purposes I'm creating a custom UIView that renders a fuel gauge. The view has two sub-layers:

  • one for the background
  • one for the hand

The layer that represents the hand is then simple rotated accordingly to point at the correct value. So far, so good. Now I want the view to resize its layers whenever the size of the view is changed. To achieve this, I created an override of the layoutSubviews method like this:

public override func layoutSubviews()
{
    super.layoutSubviews()

    if previousBounds == nil || !previousBounds!.equalTo(self.bounds)
    {
        previousBounds = self.bounds
        self.updateLayers(self.bounds)
    }
}

As the method is being called many times, I'm using previousBounds to make sure I only perform the update on the layers when the size has actually changed.

At first, I had just the following code in the updateLayers method to set the frames of the sub-layers:

backgroundLayer.frame = bounds.insetBy(dx: 5, dy: 5)
handLayer.frame = bounds.insetBy(dx: 5, dy: 5)

That worked fine - until the handLayer was rotated. In that case some weird things happen to its size. I suppose it is because the frame gets applied after the rotation and of course, the rotated layer doesn't actually fit the bounds and is thus resized to fit.

My current solution is to temporarily create a new CATransaction that suppresses animations, reverting the transformation back to identity, setting the frame and then re-applying the transformation like this:

CATransaction.begin()
CATransaction.setDisableActions(true)

let oldTransform = scaleLayer.transform
handLayer.transform = CATransform3DIdentity
handLayer.frame = bounds.insetBy(dx: 5, dy: 5)
handLayer.transform = oldTransform

CATransaction.commit()

I already tried omitting the CATransaction and instead applying the handLayer.affineTransform to the bounds I'm setting, but that didn't yield the expected results - maybe I did it wrong (side question: How to rotate a given CGRect around its center without doing all the maths myself)?

My question is simply: Is there a recommended was of setting the frame of a transformed layer or is the solution I found already "the" way to do it?

EDIT
Kevvv provided some sample code, which I've modified to demonstrate my problem:

class ViewController: UIViewController {
    let customView = CustomView(frame: .init(origin: .init(x: 200, y: 200), size: .init(width: 200, height: 200)))
    let backgroundLayer = CALayer()
    let handLayer = CAShapeLayer()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(customView)
        
        customView.backgroundColor = UIColor.blue
        
        backgroundLayer.backgroundColor = UIColor.yellow.cgColor
        backgroundLayer.frame = customView.bounds
        
        let handPath = UIBezierPath()
        handPath.move(to: backgroundLayer.position)
        handPath.addLine(to: .init(x: 0, y: backgroundLayer.position.y))
        
        handLayer.frame = customView.bounds
        handLayer.path = handPath.cgPath
        handLayer.strokeColor = UIColor.black.cgColor
        handLayer.backgroundColor = UIColor.red.cgColor
        
        customView.layer.addSublayer(backgroundLayer)
        customView.layer.addSublayer(handLayer)

        handLayer.transform = CATransform3DMakeRotation(5, 0, 0, 1)

        let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
        customView.addGestureRecognizer(tap)
    }
    
    @objc func tapped(_ sender: UITapGestureRecognizer) {
        customView.frame = customView.frame.insetBy(dx:10, dy:10)
        /*let animation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
        let fromValue = self.handLayer.transform
        let toValue = CGFloat.pi * 2
        animation.duration = 2
        animation.fromValue = fromValue
        animation.toValue = toValue
        animation.valueFunction = CAValueFunction(name: .rotateZ)
        self.handLayer.add(animation, forKey: nil)*/
    }
}

class CustomView: UIView {
    var previousBounds: CGRect!
    override func layoutSubviews() {
        super.layoutSubviews()
        if previousBounds == nil || !previousBounds!.equalTo(self.bounds) {
            previousBounds = self.bounds
            self.updateLayers(self.bounds)
        }
    }

    func updateLayers(_ bounds: CGRect) {
        guard let sublayers = self.layer.sublayers else { return }
        for sublayer in sublayers {
            sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
        }
    }
}

If you add this to a playground, then run and tap the control, you'll see what I mean. Watch the red "square".

1

1 Answers

1
votes

Do you mind explaining what the "weird things happening to the size" means? I tried to replicate it, but couldn't find the unexpected effects:

class ViewController: UIViewController {
    let customView = CustomView(frame: .init(origin: .init(x: 200, y: 200), size: .init(width: 200, height: 200)))
    let backgroundLayer = CALayer()
    let handLayer = CAShapeLayer()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(customView)
        
        backgroundLayer.backgroundColor = UIColor.yellow.cgColor
        backgroundLayer.frame = customView.bounds
        
        let handPath = UIBezierPath()
        handPath.move(to: backgroundLayer.position)
        handPath.addLine(to: .init(x: 0, y: backgroundLayer.position.y))
        
        handLayer.frame = customView.bounds
        handLayer.path = handPath.cgPath
        handLayer.strokeColor = UIColor.black.cgColor
        
        customView.layer.addSublayer(backgroundLayer)
        customView.layer.addSublayer(handLayer)
        
        let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
        customView.addGestureRecognizer(tap)
    }
    
    @objc func tapped(_ sender: UITapGestureRecognizer) {
        let animation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
        let fromValue = self.handLayer.transform
        let toValue = CGFloat.pi * 2
        animation.duration = 2
        animation.fromValue = fromValue
        animation.toValue = toValue
        animation.valueFunction = CAValueFunction(name: .rotateZ)
        self.handLayer.add(animation, forKey: nil)
    }
}

class CustomView: UIView {
    var previousBounds: CGRect!
    override func layoutSubviews() {
        super.layoutSubviews()
        if previousBounds == nil || !previousBounds!.equalTo(self.bounds) {
            previousBounds = self.bounds
            self.updateLayers(self.bounds)
        }
    }

    func updateLayers(_ bounds: CGRect) {
        guard let sublayers = self.layer.sublayers else { return }
        for sublayer in sublayers {
            sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
        }
    }
}

Edit

I think the issue is that the red box is resized with a frame. Since a frame is always upright even if it's rotated, if you were to do an inset from a frame, it'd look like this:

enter image description here

However, if you were to resize the red box with bounds:

sublayer.bounds = bounds.insetBy(dx: 5, dy: 5)
sublayer.position = self.convert(self.center, from: self.superview)

instead of:

sublayer.frame = bounds.insetBy(dx: 5, dy: 5)

You'll probably have to re-center the handPath and everything else in it accordingly as well.