Search code examples
ioscalayercaanimation

Trouble positioning a sublayer correctly during animation


The question is how should I define and set my shape layer's position and how should it be updated so that the layer appears where I'm expecting it to during the animation? Namely, the shape should be stuck on the end of the stick.

I have a CALayer instance called containerLayer, and it has a sublayer which is a CAShapeLayer instance called shape. containerLayer is supposed to place shape at a specific position unitLoc like this:

class ContainerLayer: CALayer, CALayerDelegate { 

    // ...
    override func layoutSublayers() {
        super.layoutSublayers()
        if !self.didSetup {
            self.setup()
            self.didSetup = true
        }
        updateFigure()
        setNeedsDisplay()
    }

    func updateFigure() {
        figureCenter = self.bounds.center
        figureDiameter = min(self.bounds.width, self.bounds.height)
        figureRadius = figureDiameter/2

        shapeDiameter = round(figureDiameter / 5)
        shapeRadius = shapeDiameter/2

        locRadius = figureRadius - shapeRadius
        angle = -halfPi
        unitLoc = CGPoint(x: self.figureCenter.x + cos(angle) * locRadius, y: self.figureCenter.y + sin(angle) * locRadius)

        shape.bounds = CGRect(x: 0, y: 0, width: shapeDiameter, height: shapeDiameter)
        shape.position = unitLoc
        shape.updatePath()
    }
    // ...
}

I'm having trouble finding the right way to specify what this position should be before, and during a resize animation which changes containerLayer.bounds. I do understand that the problem I'm having is that I'm not setting the position in such a way that the animation will display it the way that I'm expecting it would.

I have tried using a CABasicAnimation(keyPath: "position") to animate the position, and it improved the result over what I had tried previously, but it's still off.

@objc func resize(sender: Any) {

    // MARK:- animate containerLayer bounds & shape position
    // capture bounds value before changing
    let oldBounds = self.containerLayer.bounds
    // capture shape position value before changing
    let oldPos = self.containerLayer.shape.position

    // update the constraints to change the bounds
    isLarge.toggle()
    updateConstraints()
    self.view.layoutIfNeeded()
    let newBounds = self.containerLayer.bounds
    let newPos = self.containerLayer.unitLoc

    // set up the bounds animation and add it to containerLayer
    let baContainerBounds = CABasicAnimation(keyPath: "bounds")
    baContainerBounds.fromValue = oldBounds
    baContainerBounds.toValue = newBounds
    containerLayer.add(baContainerBounds, forKey: "bounds")

    // set up the position animation and add it to shape layer
    let baShapePosition = CABasicAnimation(keyPath: "position")
    baShapePosition.fromValue = oldPos
    baShapePosition.toValue = newPos
    containerLayer.shape.add(baShapePosition, forKey: "position")
    
    containerLayer.setNeedsLayout()
    self.view.layoutIfNeeded()
}

I also tried using the presentation layer like this to set the position, and it also seems to get it close, but it's still off.

class ViewController: UIViewController {
    //...
    override func loadView() {
        super.loadView()
        displayLink = CADisplayLink(target: self, selector: #selector(animationDidUpdate))
        displayLink.add(to: RunLoop.main, forMode: RunLoop.Mode.default)
        //...
    }

    @objc func animationDidUpdate(displayLink: CADisplayLink) {
        let newCenter = self.containerLayer.presentation()!.bounds.center
        let new = CGPoint(x: newCenter.x + cos(containerLayer.angle) * containerLayer.locRadius, y: newCenter.y + sin(containerLayer.angle) * containerLayer.locRadius)
        containerLayer.shape.position = new
    }
    //...
}

class ContainerLayer: CALayer, CALayerDelegate {
    // ...
    func updateFigure() {
        //...
        //shape.position = unitLoc
        //...
    }
    // ...
}

enter image description here


Solution

  • With some slight exaggeration, I was able to make it clearer what's happening in your code. In my example, the circle layer is supposed to remain 1/3 the height of the background view:

    enter image description here

    At the time the animation starts, the background view has already been set to its ultimate size at the end of the animation. You don't see that, because animation relies on portraying the layer's presentation layer, which is unchanged; but the view itself has changed. Therefore, when you position the shape of the shape layer, and you do it in terms of the view, you are sizing and positioning it at the place it will need to be when the animation ends. Thus it jumps to its final size and position, which makes sense only when we reach the end of the animation.

    Okay, but now consider this:

    enter image description here

    Isn't that nicer? How is it done? Well, using the principles I have already described elsewhere, I've got a layer with a custom animatable property. The result is that on every frame of the animation, I get an event (the draw(in:) method for that layer). I respond to this by recalculating the path of the shape layer. Thus I am giving the shape layer a new path on every frame of the animation, and so it behaves smoothly. It stays in the right place, it resizes in smooth proportion to the size of the background view, and its stroke thickness remains constant throughout.