Chris Birch
Chris Birch

Reputation: 2041

CALayer animation beginTime

I'm developing a keyframe animation editor for iOS that allows the user to create simple animations and view them, plotted against a timeline. The user is able to drag the timeline in order to change the current time.

This means that I need to be able to start an animation at a user specified time.

Whilst I can achieve this behaviour, i also am experiencing an annoying glitch every time I re-add the animation to the layer. This glitch causes the first frame of the animation to flash very quickly before CoreAnimation respects the begin time. I can mitigate the effects of this ever so slightly by setting the layer.alpha to 0 before the flush, and 1 after the flush however this still results in a nasty single frame(ish) flash!

I have recreated a sample view controller that demonstrates the code required to "change the time" of the running animation but as this project is so simple, you don't really see the negative effects of the flush: https://gist.github.com/chrisbirch/5cafca50804cf9d778ccd0fdc9e68d56

Basic idea behind the code is as follows:

Each time the user changes the current time, I restart the animation and fiddle with the CALayer timing properties like so (addStoppedAnimation:line 215):

ani = createGroup()

animatableLayer.speed = 0

animatableLayer.add(ani, forKey: "an animation key")
let time = CFTimeInterval(slider.value)
animatableLayer.timeOffset = 0
animatableLayer.beginTime = animatableLayer.superlayer!.convertTime(CACurrentMediaTime(), from: nil) - time

CATransaction.flush()
animatableLayer.timeOffset = time// offset
print("Time changed \(time)")

The glitch is caused by me having to call CATransaction.flush right before I set timeOffset. Failure to call this flush results in the begin time being ignored it would seem.

I feel like I have scoured the entire internet looking for a solution to this problem but alas Im think I'm stuck.

My question is this:

Can anyone shed any light onto why it is I need to call CATransaction.flush in order for the beginTime value that I set to take effect? Looking at apple code I didn't ever see them using flush for this purpose so perhaps I have got something obvious wrong!

Many thanks in advance

Chris

Upvotes: 0

Views: 1453

Answers (1)

agibson007
agibson007

Reputation: 4393

Using your test code from the gist I have updated it to check for an animation so that there is no need to readd it. You could us a unique ID to track all animations and store it in a dictionary with view attributes. I did not implement this part but that is how I would do it. Hopefully I understood your issue enough. Also I used Xcode 9 and I am not sure of the code differences. I changed a few logic pieces so let me know if this fixes the issue.

UUID().uuidString //for unique string in implementation

//from your code just slightly altered.
//
//  ViewController.swift
//  CATest


//
//  CATestViewController.swift
//  SimpleCALayerTest


import UIKit

class CATestViewController: UIViewController, CAAnimationDelegate{


    var slider : UISlider!
    var animatableLayer : CALayer!

    var animationContainerView : UIView!

    var centerY : CGFloat!
    var startTranslationX : CGFloat!
    var endTranslationX : CGFloat!

    let duration = 10.0

    ///boring nibless view setup code
    override func loadView() {
        let marginX = CGFloat(10)
        let marginY = CGFloat(10)
        let view = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 400))

        view.backgroundColor = .lightGray

        slider = UISlider(frame: CGRect(x: marginX, y: 0, width: 200, height: 50))
        slider.maximumValue = Float(duration)
        slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)

        slider.addTarget(self, action: #selector(sliderDragStart(_:)), for: .touchDown)
        slider.addTarget(self, action: #selector(sliderDragEnd(_:)), for: .touchUpInside)

        //A view to house an animated sublayer
        animationContainerView = UIView(frame: CGRect(x: marginX, y: 50, width: 200, height: 70))



        //add a play button that will allow the animation to be played without hindrance from the slider
        let playButton = UIButton(frame: CGRect(x: marginX, y: animationContainerView.frame.maxY + marginY, width: 200, height: 50))
        playButton.setTitle("Change Frame", for: .normal)
        playButton.addTarget(self, action: #selector(playAnimation), for: .touchUpInside)
        view.addSubview(playButton)

        //add a stopped ani button that will allow the animation to be played using slider
        let addStoppedAniButton = UIButton(frame: CGRect(x: playButton.frame.origin.x, y: playButton.frame.maxY + marginY, width: playButton.frame.width, height: playButton.frame.size.height))
        addStoppedAniButton.setTitle("Pause", for: .normal)
        addStoppedAniButton.addTarget(self, action: #selector(cmPauseTapped(_:)), for: .touchUpInside)
        view.addSubview(addStoppedAniButton)



        let animatableLayerWidth = animationContainerView.bounds.width / CGFloat(4)
        centerY = animationContainerView.bounds.midY
        startTranslationX = animatableLayerWidth / CGFloat(2)
        endTranslationX = animationContainerView.bounds.width - animatableLayerWidth / CGFloat(2)


        animationContainerView.backgroundColor = .white
        animationContainerView.layer.borderColor = UIColor.black.withAlphaComponent(0.5).cgColor
        animationContainerView.layer.borderWidth = 1

        view.addSubview(slider)
        view.addSubview(animationContainerView)

        //Now add a layer to animate to the container
        animatableLayer = CALayer()
        animatableLayer.backgroundColor = UIColor.yellow.cgColor
        animatableLayer.borderWidth = 1
        animatableLayer.borderColor = UIColor.black.withAlphaComponent(0.5).cgColor
        var r = animationContainerView.bounds.insetBy(dx: 0, dy: 4)
        r.size.width = animatableLayerWidth
        animatableLayer.frame = r
        animationContainerView.layer.addSublayer(animatableLayer)

        self.view = view
    }

    @objc func cmPauseTapped(_ sender : UIButton){
        if animatableLayer.speed == 0{
            resume()
        }else{
            pause()
        }
    }

    @objc func sliderChanged(_ sender: UISlider){
        if animatableLayer.speed == 0{
            let time = CFTimeInterval(sender.value)
            animatableLayer.speed = 0
            animatableLayer.timeOffset = time// offset
            print("Time changed \(time)")
        }
    }

    var animations = [CAAnimation]()

    func addAnimations(){
        let ani = CAAnimation()
        animations.append(ani)
    }

    @objc func sliderDragStart(_ sender: UISlider)
    {
        if animatableLayer.speed > 0{
            animatableLayer.speed = 0
        }
        addStoppedAnimation()
    }

    func pause(){
        //just updating slider
        if slider.value != Float(animatableLayer.timeOffset){
            UIView.animate(withDuration: 0.3, animations: {
                self.slider.setValue(Float(self.animatableLayer.timeOffset), animated: true)
            })
        }

        animatableLayer.timeOffset = animatableLayer.convertTime(CACurrentMediaTime(), from: nil)
        animatableLayer.speed = 0

    }

    func resume(){
        if let _ = animatableLayer.animationKeys()?.contains("an animation key"){
            animatableLayer.speed = 1.0;
            let pausedTime = animatableLayer.timeOffset
            animatableLayer.beginTime = 0.0;
            let timeSincePause = animatableLayer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
            animatableLayer.beginTime = timeSincePause;
            return
        }

        print("Drag End with need to readd animation")
        ani = createGroup()
        animatableLayer.speed = 1
        animatableLayer.add(ani, forKey: "an animation key")
        let time = CFTimeInterval(slider.value)
        animatableLayer.timeOffset = time
        animatableLayer.beginTime = CACurrentMediaTime()

    }
    @objc func sliderDragEnd(_ sender: UISlider){
        resume()
    }

    //Animations

    var ani : CAAnimationGroup!

    func createGroup() -> CAAnimationGroup
    {
        let ani = CAAnimationGroup()

        ani.isRemovedOnCompletion = false
        ani.duration = 10
        ani.delegate = self

        ani.animations = [createTranslationAnimation(),createColourAnimation()]
        return ani
    }
    func createTranslationAnimation() -> CAKeyframeAnimation
    {
        let ani = CAKeyframeAnimation(keyPath: "position")
        ani.delegate = self
        ani.isRemovedOnCompletion = false
        ani.duration = 10
        ani.values = [CGPoint(x:0,y:centerY),CGPoint(x:endTranslationX,y:centerY)]
        ani.keyTimes = [0,1]
        return ani
    }

    func createColourAnimation() -> CAKeyframeAnimation
    {
        let ani = CAKeyframeAnimation(keyPath: "backgroundColor")
        ani.delegate = self
        ani.isRemovedOnCompletion = false

        ani.duration = 10
        ani.values = [UIColor.red.cgColor,UIColor.blue.cgColor]
        ani.keyTimes = [0,1]

        return ani
    }

    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        print("Animation Stopped")
    }

    func animationDidStart(_ anim: CAAnimation) {
        print("Animation started")
    }

    func addStoppedAnimation()
    {
        if let _ = animatableLayer.animationKeys()?.contains("an animation key"){
            slider.value += 0.5
            sliderChanged(slider)
            return
            //we do not want to readd it
        }
        ani = createGroup()
        animatableLayer.speed = 0
        animatableLayer.add(ani, forKey: "an animation key")
        let time = CFTimeInterval(slider.value)
        animatableLayer.timeOffset = time
        animatableLayer.beginTime = CACurrentMediaTime()
    }

    @objc func playAnimation(){
      addStoppedAnimation()
    }

}

Upvotes: 1

Related Questions