2

I would like to have a YouTube like morphing play/pause animation in the app I am developing. It looks like the animation here. I am not sure how to go about it. I know I need a CABasicAnimation and CAShapeLayer. I know I need two (or maybe more?) UIBezierPath-s. One will be the path of the shapeLayer, the other one the toValue of the animation. Something like:

shape.path = pausePath().cgPath

let animation = CABasicAnimation(keyPath: "path")
animation.toValue = trainglePath().cgPath
animation.duration = 1
shape.add(animation, forKey: animation.keyPath)

But I am not sure how to go about these paths. I have the following questions:

  1. Is the way the path is made important? For example will a line drawn from left to right be animated differently than a line made from right to left? I mean something like path.move(to: startPoint); path.addLine(to: endPoint1) opposed to path.move(to: endPoint1); path.addLine(to: startPoint)
  2. Will two paths be enough? One for the start button and one for the pause button? If yes - what is the proper way of drawing them so that they animate "correctly"? I don't ask for code here (not that I mind having code as an answer) but some general explanation will be enough for starters.
surToTheW
  • 772
  • 2
  • 10
  • 34

2 Answers2

15

The key thing to get path animations to work is for both the starting and ending path to have the same number of control points. If you look at that animation it looks to me like the initial "play" triangle is drawn as 2 closed quadrilaterals that touch, where the right quadrilateral has 2 of it's points together so that it looks like a triangle. Then the animation shifts the control-points of those quadrilaterals apart and turns them both into the rectangles of the pause symbol.

If you map that out on graph paper it should be pretty easy to create the before and after control points.

Consider the (very crude) illustration below. The top part shows the "play" triangle divided into 2 quadrilaterals, and shows the right part (in yellow) as a quadrilateral with the points on its right side separated slightly to show the idea. In practice you'd set up that quadrilateral with those two control points having the exact same coordinates.

I labeled the control points for each quadrilateral using numbers to show the order of drawing them (For each quadrilateral: moveTo first point, lineTo 2nd/3rd/4th point, and finally a lineTo the first point, to close the polygon.)

The bottom illustration shows the points shifted to give the pause symbol.

If you create a CABasicAnimation of a CAShapeLayer starting with the control points like the top illustration and the end with the control points like the bottom illustration you should get an animation very much like the one you linked to.

enter image description here

I just created a demo program that creates an animation like what I described. Here is what it looks like:

enter image description here

I stroke the paths in black and fill them with cyan so it's easier to tell what's going on.

(That is a little more finished than my hand drawing <grin>.)

You can look at the project that generates that animation at this Github link.

Duncan C
  • 128,072
  • 22
  • 173
  • 272
11

Here is a small implementation based on CAShapeLayer, that basically clones the behavior described in the link.

class PlayPauseButton: UIControl {

    // public function can be called from out interface
    func setPlaying(_ playing: Bool) {
        self.playing = playing
        animateLayer()
    }

    private (set) var playing: Bool = false
    private let leftLayer: CAShapeLayer
    private let rightLayer: CAShapeLayer

    override init(frame: CGRect) {
        leftLayer = CAShapeLayer()
        rightLayer = CAShapeLayer()
        super.init(frame: frame)

        backgroundColor = UIColor.white
        setupLayers()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("Not implemented")
    }

    private func setupLayers() {
        layer.addSublayer(leftLayer)
        layer.addSublayer(rightLayer)

        leftLayer.fillColor = UIColor.black.cgColor
        rightLayer.fillColor = UIColor.black.cgColor
        addTarget(self,
                  action: #selector(pressed),
                  for: .touchUpInside)
    }

    @objc private func pressed() {
        setPlaying(!playing)
    }

    private func animateLayer() {
        let fromLeftPath = leftLayer.path
        let toLeftPath = leftPath()
        leftLayer.path = toLeftPath

        let fromRightPath = rightLayer.path
        let toRightPath = rightPath()
        rightLayer.path = toRightPath

        let leftPathAnimation = pathAnimation(fromPath: fromLeftPath,
                                              toPath: toLeftPath)
        let rightPathAnimation = pathAnimation(fromPath: fromRightPath,
                                               toPath: toRightPath)

        leftLayer.add(leftPathAnimation,
                      forKey: nil)
        rightLayer.add(rightPathAnimation,
                       forKey: nil)
    }

    private func pathAnimation(fromPath: CGPath?,
                               toPath: CGPath) -> CAAnimation {
        let animation = CABasicAnimation(keyPath: "path")
        animation.duration = 0.33
        animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
        animation.fromValue = fromPath
        animation.toValue = toPath
        return animation
    }

    override func layoutSubviews() {
        leftLayer.frame = leftLayerFrame
        rightLayer.frame = rightLayerFrame

        leftLayer.path = leftPath()
        rightLayer.path = rightPath()
    }

    private let pauseButtonLineSpacing: CGFloat = 10

    private var leftLayerFrame: CGRect {
        return CGRect(x: 0,
                      y: 0,
                      width: bounds.width * 0.5,
                      height: bounds.height)
    }

    private var rightLayerFrame: CGRect {
        return leftLayerFrame.offsetBy(dx: bounds.width * 0.5,
                                       dy: 0)
    }

    private func leftPath() -> CGPath {
        if playing {
            let bound = leftLayer.bounds
                                 .insetBy(dx: pauseButtonLineSpacing,
                                          dy: 0)
                                 .offsetBy(dx: -pauseButtonLineSpacing,
                                           dy: 0)

            return UIBezierPath(rect: bound).cgPath
        }

        return leftLayerPausedPath()
    }

    private func rightPath() -> CGPath {
        if playing {
            let bound = rightLayer.bounds
                                 .insetBy(dx: pauseButtonLineSpacing,
                                          dy: 0)
                                .offsetBy(dx: pauseButtonLineSpacing,
                                          dy: 0)
            return UIBezierPath(rect: bound).cgPath
        }

        return rightLayerPausedPath()
    }

    private func leftLayerPausedPath() -> CGPath {
        let y1 = leftLayerFrame.width * 0.5
        let y2 = leftLayerFrame.height - leftLayerFrame.width * 0.5

        let path = UIBezierPath()
        path.move(to:CGPoint(x: 0, y: 0))
        path.addLine(to: CGPoint(x: leftLayerFrame.width,
                                 y: y1))
        path.addLine(to: CGPoint(x: leftLayerFrame.width,
                                 y: y2))
        path.addLine(to: CGPoint(x: 0,
                                 y: leftLayerFrame.height))
        path.close()

        return path.cgPath
    }

    private func rightLayerPausedPath() -> CGPath {
        let y1 = rightLayerFrame.width * 0.5
        let y2 = rightLayerFrame.height - leftLayerFrame.width * 0.5
        let path = UIBezierPath()

        path.move(to:CGPoint(x: 0, y: y1))
        path.addLine(to: CGPoint(x: rightLayerFrame.width,
                                 y: rightLayerFrame.height * 0.5))
        path.addLine(to: CGPoint(x: rightLayerFrame.width,
                                 y: rightLayerFrame.height * 0.5))
        path.addLine(to: CGPoint(x: 0,
                                 y: y2))
        path.close()
        return path.cgPath
    }
}

The code should be pretty straight forward. Here is how you could use it,

let playButton = PlayPauseButton(frame: CGRect(x: 0,
                                               y: 0,
                                               width: 200,
                                               height: 200))
view.addSubview(playButton)

You dont need to handle anything from outside. The button itself handles animation. You could use target/action for changing detecting the button click. Also, you could add animate the status from outside using public method,

playButton.setPlaying(true) // animate to playing
playButton.setPlaying(false) // animate to paused state

And here is how it looks,

enter image description here

Sandeep
  • 20,908
  • 7
  • 66
  • 106
  • 1
    Wow, that's a complete answer to the question! (voted). – Duncan C Aug 13 '18 at 17:48
  • 1
    You should provide some explanation of what you did and how/why it works though (Or I guess you could refer them to my answer for that, since I covered the explanation part and you covered the implementation part.) – Duncan C Aug 13 '18 at 17:53
  • Thanks a lot for the code, it gave me the idea to have to CAShapeLayers and two animations instead of one, but I am accepting the other answer because I followed its explanation and found it very clear. I cannot accept both, so just voted your answer up. – surToTheW Aug 14 '18 at 19:46
  • I'm surprised that making the leftPath() and rightPath() functions return rectangles worked. In order for a path animation to animate correctly from start to end, both the start and end paths need to have the same number of control points, drawn in the same order. I guess, internally, the `UIBezierPath(rect:)` method starts at the top left, moves to the top right, to the bottom right, the bottom left, and back to the top left, just like your `rightLayerPausedPath()` and `lefttLayerPausedPath()` methods do. – Duncan C Aug 13 '20 at 11:29