iosswiftcore-animationcabasicanimationcaanimation

Resume CABasicAnimation backwards by setting .speed equal to -1


EDIT: i've refactored the question a bit and solved part of the issue, now the question comes down to why does the presentation layer glitches/flashes when the animation is resumed. At this point tho i'm accepting any answer that makes the animation resume both forwards and backwards at will with no issue. I'm not sure the approach i'm using is the right one, i'm still pretty new to Swift.

Note: Sample project at the bottom, for having a better understanding of the issue.

In my project i'm pausing a CABasicAnimation by setting the layer .speed property to 0, then i'm changing the animation value interactively by setting the layer's .timeOffset property equal to a UISlider .value property whenever the user scrolls the slider. By code:

layer.speed = 0 

Then when the user slides:

layer.timeOffset = CFTimeInterval(sender.value)

Now i want to resume the animation backwards or forwards at will whenever the user gesture on the slider ends, so from the starting point related to the current animation value. The only viable solution i've found which runs smoothly is the following, but it works only going forwards:

let pausedTime = layer.timeOffset
layer.speed = 1.0
layer.timeOffset = 0.0
layer.beginTime = 0.0
let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
layer.beginTime = timeSincePause

Then i can simply pause it again at the completion of the animation:

 DispatchQueue.main.asyncAfter(deadline: .now()+1) {
    layer.timeOffset = 0
    layer.speed = 0
 }

From my understanding, .speed not only defines the actual speed of the animation combined with the .duration property, but also the direction of the animation: if i set a layer's speed equal to -1 then the animation completes backwards. Referring to this answer in regards to how CAMediaTiming works, i was trying to change the up above snippet's parameters to resume the animation going backwards with no luck. I thought this would work:

let pausedTime = layer.timeOffset
layer.timeOffset = 0.0
layer.beginTime = 0.0
let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
layer.beginTime = timeSincePause
layer.timeOffset = pausedTime*2
layer.speed = -1.0

but the layer is never animated like so. The issue seems to be related to the convertTime method.

Then i found this question which is basically the same of mine, and the only answer has a decent solution. Refactoring a bit the code, i can just say:

   layer.beginTime = CACurrentMediaTime()
   layer.speed = -1
   DispatchQueue.main.asyncAfter(deadline: .now()+1) {
       layer.timeOffset = 0
       layer.speed = 0
   }

However, when the animation is played backwards is very glitchy, in particular the presentation layer flashes both at the resume and on completion. I've tried various solutions with no luck, some speculations i've made:

Both of these however doesn't explain while it flickers/flashes both on resume and on completion. Additionally, it seems to me that the animation may have a duration of 1 when resumed forwards, but only of 1-timeOffset when resumed backwards, not sure tho.

Really not sure what's the actual problem and how to fix this mess. All suggestions are more than welcomed.

For anyone interested, here's a sample project similar to mine, inspired by another question (animation is running forward, to run it backwards and catch the glitch just call resumeLayerBackwards()). I know the code should be refactored, but still for the purpose it's fine. Just copy, paste and run:

import UIKit

class ViewController: UIViewController {

var perspectiveLayer: CALayer = {
    let perspectiveLayer = CALayer()
    perspectiveLayer.speed = 0.0
    return perspectiveLayer
}()

var mainView: UIView = {
    let view = UIView()
    return view
}()

private let slider: UISlider = {
    let slider = UISlider()
    slider.addTarget(self, action: #selector(slide(sender:event:)) , for: .valueChanged)
    return slider
}()

override func viewDidLoad() {
    super.viewDidLoad()
    view.addSubview(slider)
    animate()
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    slider.frame = CGRect(x: view.bounds.size.width/3,
                          y: view.bounds.size.height/10*8,
                          width: view.bounds.size.width/3,
                          height: view.bounds.size.height/10)
}

@objc private func slide(sender: UISlider, event: UIEvent) {
    if let touchEvent = event.allTouches?.first {
        
      switch touchEvent.phase {
      case .ended:
        resumeLayer(layer: perspectiveLayer)
      default:
        perspectiveLayer.timeOffset = CFTimeInterval(sender.value)
      }
        
    }
}

private func resumeLayer(layer: CALayer) {
    let pausedTime = layer.timeOffset
    layer.speed = 1.0
    layer.timeOffset = 0.0
    layer.beginTime = 0.0
    let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
    layer.beginTime = timeSincePause
    DispatchQueue.main.asyncAfter(deadline: .now()+1) {
        layer.timeOffset = 1.0
        layer.speed = 0.0
    }
}

private func resumeLayerBackwards(layer: CALayer) {  
        layer.beginTime = CACurrentMediaTime()
        layer.speed = -1
        DispatchQueue.main.asyncAfter(deadline: .now()+1) {
            layer.timeOffset = 0
            layer.speed = 0
        }
}


private func animate() {
    var transform:CATransform3D = CATransform3DIdentity
    var topSleeve:CALayer
    var middleSleeve:CALayer
    var bottomSleeve:CALayer
    var topShadow:CALayer
    var middleShadow:CALayer
    let width:CGFloat = 300
    let height:CGFloat = 150
    var firstJointLayer:CALayer
    var secondJointLayer:CALayer
    
    mainView = UIView(frame:CGRect(x: 50, y: 50, width: width, height: height*3))
    mainView.backgroundColor = UIColor.yellow
    view.addSubview(mainView)
            
    perspectiveLayer.frame = CGRect(x: 0, y: 0, width: width, height: height*2)
    mainView.layer.addSublayer(perspectiveLayer)
    
    firstJointLayer = CATransformLayer()
    firstJointLayer.frame = mainView.bounds
    perspectiveLayer.addSublayer(firstJointLayer)
    
    topSleeve = CALayer()
    topSleeve.frame = CGRect(x: 0, y: 0, width: width, height: height)
    topSleeve.anchorPoint = CGPoint(x: 0.5, y: 0)
    topSleeve.backgroundColor = UIColor.red.cgColor
    topSleeve.position = CGPoint(x: width/2, y: 0)
    firstJointLayer.addSublayer(topSleeve)
    topSleeve.masksToBounds = true
    
    secondJointLayer = CATransformLayer()
    secondJointLayer.frame = mainView.bounds
    secondJointLayer.frame = CGRect(x: 0, y: 0, width: width, height: height*2)
    secondJointLayer.anchorPoint = CGPoint(x: 0.5, y: 0)
    secondJointLayer.position = CGPoint(x: width/2, y: height)
    firstJointLayer.addSublayer(secondJointLayer)
    
    middleSleeve = CALayer()
    middleSleeve.frame = CGRect(x: 0, y: 0, width: width, height: height)
    middleSleeve.anchorPoint = CGPoint(x: 0.5, y: 0)
    middleSleeve.backgroundColor = UIColor.blue.cgColor
    middleSleeve.position = CGPoint(x: width/2, y: 0)
    secondJointLayer.addSublayer(middleSleeve)
    middleSleeve.masksToBounds = true
    
    bottomSleeve = CALayer()
    bottomSleeve.frame = CGRect(x: 0, y: height, width: width, height: height)
    bottomSleeve.anchorPoint = CGPoint(x: 0.5, y: 0)
    bottomSleeve.backgroundColor = UIColor.gray.cgColor
    bottomSleeve.position = CGPoint(x: width/2, y: height)
    secondJointLayer.addSublayer(bottomSleeve)
    
    firstJointLayer.anchorPoint = CGPoint(x: 0.5, y: 0)
    firstJointLayer.position = CGPoint(x: width/2, y: 0)
    
    topShadow = CALayer()
    topSleeve.addSublayer(topShadow)
    topShadow.frame = topSleeve.bounds
    topShadow.backgroundColor = UIColor.black.cgColor
    topShadow.opacity = 0
    
    middleShadow = CALayer()
    middleSleeve.addSublayer(middleShadow)
    middleShadow.frame = middleSleeve.bounds
    middleShadow.backgroundColor = UIColor.black.cgColor
    middleShadow.opacity = 0
    
    transform.m34 = -1/700
    perspectiveLayer.sublayerTransform = transform
    
    var animation = CABasicAnimation(keyPath: "transform.rotation.x")
    animation.fillMode = CAMediaTimingFillMode.forwards
    animation.isRemovedOnCompletion = false
    animation.duration = 1
    animation.fromValue = 0
    animation.toValue = -90*Double.pi/180
    firstJointLayer.add(animation, forKey: nil)
    
    animation = CABasicAnimation(keyPath: "transform.rotation.x")
    animation.fillMode = CAMediaTimingFillMode.forwards
    animation.isRemovedOnCompletion = false
    animation.duration = 1
    animation.fromValue = 0
    animation.toValue = 180*Double.pi/180
    secondJointLayer.add(animation, forKey: nil)
    
    animation = CABasicAnimation(keyPath: "transform.rotation.x")
    animation.fillMode = CAMediaTimingFillMode.forwards
    animation.isRemovedOnCompletion = false
    animation.duration = 1
    animation.fromValue = 0
    animation.toValue = -160*Double.pi/180
    bottomSleeve.add(animation, forKey: nil)
    
    animation = CABasicAnimation(keyPath: "bounds.size.height")
    animation.fillMode = CAMediaTimingFillMode.forwards
    animation.isRemovedOnCompletion = false
    animation.duration = 1
    animation.fromValue = perspectiveLayer.bounds.size.height
    animation.toValue = 0
    perspectiveLayer.add(animation, forKey: nil)
    
    
    animation = CABasicAnimation(keyPath: "position.y")
    animation.fillMode = CAMediaTimingFillMode.forwards
    animation.isRemovedOnCompletion = false
    animation.duration = 1
    animation.fromValue = perspectiveLayer.position.y
    animation.toValue = 0
    perspectiveLayer.add(animation, forKey: nil)
    
    animation = CABasicAnimation(keyPath: "opacity")
    animation.fillMode = CAMediaTimingFillMode.forwards
    animation.isRemovedOnCompletion = false
    animation.duration = 1
    animation.fromValue = 0
    animation.toValue = 0.5
    topShadow.add(animation, forKey: nil)
    
    animation = CABasicAnimation(keyPath: "opacity")
    animation.fillMode = CAMediaTimingFillMode.forwards
    animation.isRemovedOnCompletion = false
    animation.duration = 1
    animation.fromValue = 0
    animation.toValue = 0.5
    middleShadow.add(animation, forKey: nil)
}
}

Solution

  • I managed to remove the glitch for resumeLayerBackwards(layer:) in the sample project. Two problems there in fact:

    1. there is an empty screen after animation has visually finished
    2. the empty screen is visible for 1 - .timeOffset seconds

    So, seems like the problem is that animation in fact plays not just for .timeOffset period, but for the whole .duration period. And the empty screen appears because there is no animation defined for 1 - .timeOffset block.

    Just to recall: CALayer also adopts CAMediaTiming protocol, as CAAnimation does (with all the properties defined: although some of them seem not be very clear how to be applied to a layer).

    With speed = -1 after .timeOffset seconds passed — the property .timeOffset becomes equal to zero. It means that animation has reached its beginning and therefore (with negative speed) it is finished. Though it is not that obvious — seems like it is removed because of the .fillMode property. To fix this I've added perspectiveLayer.fillMode = .forwards to animate() method.

    To have animation completed exactly after .timeOffset seconds instead of the whole .duration — use .repeatDuration property. I've added layer.repeatDuration = layer.timeOffset to your resumeLayerBackwards(layer:) method.

    The project works only with both lines added.

    I can't say that the solution is really logical for me, although it works. Negative speed works a bit unpredictable as for me. In my project I used to reverse animation by swapping begin and end values in cloned animation object.