I have animation
func startRotate360() {
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 1
rotation.isCumulative = true
rotation.repeatCount = Float.greatestFiniteMagnitude
self.layer.add(rotation, forKey: "rotationAnimation")
}
What I want is ability to stop animation by setting its repeat count to 1, so it completes current rotation (simply remove animation is not ok because it looks not good)
I try following
func stopRotate360() {
self.layer.animation(forKey: "rotationAnimation")?.repeatCount = 1
}
But I get crash and in console
attempting to modify read-only animation
How to access writable properties ?
Give this a go. You can in fact change CAAnimations that are in progress. There are so many ways. This is the fastest/simplest. You could even stop the animation completely and resume it without the user even noticing.
You can see the start animation function along with the stop. The start animation looks similar to yours while the stop grabs the current rotation from the presentation layer and creates an animation to rotate until complete. I also smoothed out the duration to be a percentage of the time needed to complete based on current rotation z to full rotation based on the running animation. Then I remove the animation with the repeat count and add the new animation. You can see the view rotate smoothly to the final position and stop. You will get the idea. Drop it in and run it and see what you think. Hit the button to start and hit it again to see it finish rotation and stop.
import UIKit
class ViewController: UIViewController {
var animationView = UIView()
var button = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
animationView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
animationView.backgroundColor = .green
animationView.center = view.center
self.view.addSubview(animationView)
let label = UILabel(frame: animationView.bounds)
label.text = "I Spin"
animationView.addSubview(label)
button = UIButton(frame: CGRect(x: 20, y: animationView.frame.maxY + 60, width: view.bounds.width - 40, height: 40))
button.setTitle("Animate", for: .normal)
button.setTitleColor(.blue, for: .normal)
button.addTarget(self, action: #selector(ViewController.pressed), for: .touchUpInside)
self.view.addSubview(button)
}
func pressed(){
if let title = button.titleLabel?.text{
let trans = CATransition()
trans.type = "rippleEffect"
trans.duration = 0.6
button.layer.add(trans, forKey: nil)
switch title {
case "Animate":
//perform animation
button.setTitle("Stop And Finish", for: .normal)
rotateAnimationRepeat()
break
default:
//stop and finish
button.setTitle("Animate", for: .normal)
stopAnimationAndFinish()
break
}
}
}
func rotateAnimationRepeat(){
//just to be sure because of how i did the project
animationView.layer.removeAllAnimations()
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 0.5
rotation.repeatCount = Float.greatestFiniteMagnitude
//not doing cumlative
animationView.layer.add(rotation, forKey: "rotationAnimation")
}
func stopAnimationAndFinish(){
if let presentation = animationView.layer.presentation(){
if let currentRotation = presentation.value(forKeyPath: "transform.rotation.z") as? CGFloat{
var duration = 0.5
//smooth out duration for change
duration = Double((CGFloat(Double.pi * 2) - currentRotation))/(Double.pi * 2)
animationView.layer.removeAllAnimations()
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = currentRotation
rotation.toValue = Double.pi * 2
rotation.duration = duration * 0.5
animationView.layer.add(rotation, forKey: "rotationAnimation")
}
}
}
}
Result:
2019 typical modern syntax
Setup the arc and the layer like this:
import Foundation
import UIKit
class RoundChaser: UIView {
private let lineThick: CGFloat = 10.0
private let beginFraction: CGFloat = 0.15
// where does the arc drawing begin?
// 0==top, .25==right, .5==bottom, .75==left
private lazy var arcPath: CGPath = {
let b = beginFraction * .pi * 2.0
return UIBezierPath(
arcCenter: bounds.centerOfCGRect(),
radius: bounds.width / 2.0 - lineThick / 2.0,
startAngle: .pi * -0.5 + b,
// recall that .pi * -0.5 is the "top"
endAngle: .pi * 1.5 + b,
clockwise: true
).cgPath
}()
private lazy var arcLayer: CAShapeLayer = {
let l = CAShapeLayer()
l.path = arcPath
l.fillColor = UIColor.clear.cgColor
l.strokeColor = UIColor.purple.cgColor
l.lineWidth = lineThick
l.lineCap = CAShapeLayerLineCap.round
l.strokeStart = 0
l.strokeEnd = 0
// if both are same, it is hidden. initially hidden
layer.addSublayer(l)
return l
}()
then initialization is this easy
open override func layoutSubviews() {
super.layoutSubviews()
arcLayer.frame = bounds
}
finally animation is easy
public func begin() {
CATransaction.begin()
let e : CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
e.duration = 2.0
e.fromValue = 0
e.toValue = 1.0
// recall 0 will be our beginFraction, see above
e.repeatCount = .greatestFiniteMagnitude
self.arcLayer.add(e, forKey: nil)
CATransaction.commit()
}
}
Maybe this is not the best solution but it works, as you say you can not modify properties of the CABasicAnimation once is created, also we need to remove the rotation.repeatCount = Float.greatestFiniteMagnitude, if notCAAnimationDelegatemethodanimationDidStop` is never called, with this approach the animation can be stoped without any problems as you need
step 1: first declare a variable flag to mark as you need stop animation in your custom class
var needStop : Bool = false
step 2: add a method to stopAnimation after ends
func stopAnimation()
{
self.needStop = true
}
step 3: add a method to get your custom animation
func getRotate360Animation() ->CAAnimation{
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 1
rotation.isCumulative = true
rotation.isRemovedOnCompletion = false
return rotation
}
step 4: Modify your startRotate360 func to use your getRotate360Animation() method
func startRotate360() {
let rotationAnimation = self.getRotate360Animation()
rotationAnimation.delegate = self
self.layer.add(rotationAnimation, forKey: "rotationAnimation")
}
step 5: Implement CAAnimationDelegate in your class
extension YOURCLASS : CAAnimationDelegate
{
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if(anim == self.layer?.animation(forKey: "rotationAnimation"))
{
self.layer?.removeAnimation(forKey: "rotationAnimation")
if(!self.needStop){
let animation = self.getRotate360Animation()
animation.delegate = self
self.layer?.add(animation, forKey: "rotationAnimation")
}
}
}
}
This works and was tested
Hope this helps you
Related
I am trying to use a CABasicAnimation for the timing function with custom objects (not UIView).
I'm trying to implement #CIFilter's answer from here which is to use the CALayer's presentation layer that is animated to evaluate the timing function.
I'm doing it all in viewDidAppear, so a valid view exists, but no matter what I do, the Presentation layer is always nil.
Note that I have to add the animation to the view's layer and not the layer I've added to it for it to animate at all. And if I uncomment the lines commented out below I can see that the animation works (but only when animating the root layer). Regardless, the Presentation layer is nil.
I've looked at dozen's of tutorials and SO answers, and it seems this should just work, so I suppose I must be doing something stupid.
I am just trying to use the CoreAnimation timing functions. I have UICubicTimingParameters working, but seems like going the CA route offers much more functionality which would be nice.
import UIKit
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let newView = UIView(frame: view.frame)
view.addSubview(newView)
let evaluatorLayer = CALayer()
evaluatorLayer.frame = CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0)
evaluatorLayer.borderWidth = 8.0
evaluatorLayer.borderColor = UIColor.purple.cgColor
evaluatorLayer.timeOffset = 0.3
evaluatorLayer.isHidden = true
// evaluatorLayer.isHidden = false
newView.layer.addSublayer(evaluatorLayer)
let basicAnimation = CABasicAnimation(keyPath: "bounds.origin.x")
basicAnimation.duration = 1.0
basicAnimation.fromValue = 0.0
basicAnimation.toValue = 100.0
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
basicAnimation.speed = 0.0
// basicAnimation.speed = 0.1
newView.layer.add(basicAnimation, forKey: "evaluate")
if let presentationLayer = newView.layer.presentation() {
let evaluatedValue = presentationLayer.bounds.origin.x
print("evaluatedValue: \(evaluatedValue)")
}
else {
print(evaluatorLayer.presentation())
}
}
}
Not sure if your code is going to do what you expect, but...
I think the reason .presentation() is nil is because you haven't given UIKit an opportunity to apply the animation.
Try this:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let newView = UIView(frame: view.frame)
view.addSubview(newView)
let evaluatorLayer = CALayer()
evaluatorLayer.frame = CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0)
evaluatorLayer.borderWidth = 8.0
evaluatorLayer.borderColor = UIColor.purple.cgColor
evaluatorLayer.timeOffset = 0.3
evaluatorLayer.isHidden = true
// evaluatorLayer.isHidden = false
newView.layer.addSublayer(evaluatorLayer)
let basicAnimation = CABasicAnimation(keyPath: "bounds.origin.x")
basicAnimation.duration = 1.0
basicAnimation.fromValue = 0.0
basicAnimation.toValue = 100.0
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
basicAnimation.speed = 0.0
// basicAnimation.speed = 0.1
newView.layer.add(basicAnimation, forKey: "evaluate")
DispatchQueue.main.async {
if let presentationLayer = newView.layer.presentation() {
let evaluatedValue = presentationLayer.bounds.origin.x
print("async evaluatedValue: \(evaluatedValue)")
}
else {
print("async", evaluatorLayer.presentation())
}
}
if let presentationLayer = newView.layer.presentation() {
let evaluatedValue = presentationLayer.bounds.origin.x
print("immediate evaluatedValue: \(evaluatedValue)")
}
else {
print("immediate", evaluatorLayer.presentation())
}
}
My debug output is:
immediate nil
async evaluatedValue: 0.0
Edit
I'm still not sure what your goal is, but give this a try...
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let newView = UIView(frame: view.frame)
view.addSubview(newView)
let evaluatorLayer = CALayer()
evaluatorLayer.frame = CGRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0)
evaluatorLayer.borderWidth = 8.0
evaluatorLayer.borderColor = UIColor.purple.cgColor
evaluatorLayer.isHidden = true
//evaluatorLayer.isHidden = false
newView.layer.addSublayer(evaluatorLayer)
let basicAnimation = CABasicAnimation(keyPath: "bounds.origin.x")
basicAnimation.duration = 1.0
basicAnimation.fromValue = 0.0
basicAnimation.toValue = 100.0
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
basicAnimation.speed = 0.0
//basicAnimation.speed = 1.0
// set timeOffset on the animation, not on the layer itself
basicAnimation.timeOffset = 0.3
// add animation to evaluatorLayer
evaluatorLayer.add(basicAnimation, forKey: "evaluate")
DispatchQueue.main.async {
// get presentation layer of evaluatorLayer
if let presentationLayer = evaluatorLayer.presentation() {
let evaluatedValue = presentationLayer.bounds.origin.x
print("async evaluatedValue: \(evaluatedValue)")
}
else {
print("async", evaluatorLayer.presentation())
}
}
}
In this example, we apply the .timeOffset on the animation, not on the layer. And, we add the animation to the evaluatorLayer, not to the newView.layer.
Output (for my quick test):
async evaluatedValue: 30.000001192092896
I'm trying to recreate the Activity ring that Apple uses in their activity apps. Here is an image for those unaware.
Apple Progress Ring.
I've done a decent job of recreating it, something I especially struggled with was the overlapping shadow. In the end, the workaround I used was to split the ring into two parts, the first 75% and the last 25% so I could have the last 25% have a shadow and appear to overlap with itself.
Now that I have done this, the animation timing has become more difficult. I now have three animations that I need to take care of.
The first 75% of the ring
The last 25% of the ring
Rotating the ring if it surpasses 100%
Here is a video demonstrating this. Streamable Link.
For illustrative purposes, here is the last 25% coloured differently so you can visualise it.
As you can see, the timing of the animation is a bit janky. So I have my timings set as follows
If the ring is filled at 75% or less, it takes 2.25 seconds to fill with a timing function of ease out
If the ring is filled between 75 and 100%, the first 75% takes 1 second to fill and the last 25% takes 1.25 seconds to fill with a timing function of ease out.
If the ring is filled over 100%, the first 75% takes 1 second, the last 25% takes 1 second and the rotation for the ring also takes 1 second with a timing function of ease out.
My question is, is it possible to link these seperate CABasicAnimations so I can set a total time of 2.25 seconds as well as set a timing function for that group so timing is calculated dynamically for each animation and a timing function affects all three?
Here is my code so far, it consists of 3 animation functions.
percent = How much to fill the ring
gradientMaskPart1 = first 75% ring layer
gradientMaskPart2 = last 25% ring layer
containerLayer = layer that holds both gradientMaskParts and is rotated to simute the ring overlapping itself.
private func animateRing() {
let needsMultipleAnimations = percent <= 0.75 ? false : true
CATransaction.begin()
if needsMultipleAnimations { CATransaction.setCompletionBlock(ringEndAnimation) }
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.fromValue = currentFill
basicAnimation.toValue = percent > 0.75 ? 0.75 : percent
currentFill = Double(percent)
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
basicAnimation.duration = needsMultipleAnimations ? 1 : 2.25
basicAnimation.timingFunction =
needsMultipleAnimations ? .none : CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
gradientMaskPart1.add(basicAnimation, forKey: "basicAnimation")
CATransaction.commit()
}
private func ringEndAnimation() {
let needsMultipleAnimations = percent <= 1 ? false : true
CATransaction.begin()
if needsMultipleAnimations { CATransaction.setCompletionBlock(rotateRingAnimation) }
let duration = needsMultipleAnimations ? 1 : 1.25
let timingFunction: CAMediaTimingFunction? =
needsMultipleAnimations ? .none : CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.fromValue = 0
basicAnimation.toValue = percent <= 1 ? (percent-0.75)*4 : 1
basicAnimation.duration = duration
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
basicAnimation.timingFunction = timingFunction
self.gradientMaskPart2.isHidden = false
self.gradientMaskPart2.add(basicAnimation2, forKey: "basicAnimation")
CATransaction.commit()
}
private func rotateRingAnimation() {
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.fromValue = 2*CGFloat.pi
rotationAnimation.toValue = 2*CGFloat.pi+((2*(percent-1)*CGFloat.pi))
rotationAnimation.duration = 1
rotationAnimation.fillMode = CAMediaTimingFillMode.forwards
rotationAnimation.isRemovedOnCompletion = false
rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
self.containerLayer.add(rotationAnimation, forKey: "rotation")
}
This solution demonstrates how to animate several layers synchronously with their dedicated CABasicAnimation by using CAAnimationGroup.
Create a container CALayer subclass for layers that should be animated synchronously. Let's call it TestLayer
Define the property for every layer you need to animate.
Override needsDisplay(forKey:) in the TestLayer to receive the display call when animation modifies the particular layer via keyPath.
Override init(layer:) in the TestLayer to assign your sublayers to the testLayer.presentation() layer that renders the animation of the TestLayer.
Here is the whole code with TestView that hosts TestLayer.
class TestLayer: CALayer {
#NSManaged #objc dynamic var layer1: CAGradientLayer
#NSManaged #objc dynamic var layer2: CAGradientLayer
override init() {
super.init()
let gradientLayer1 = CAGradientLayer()
let gradientLayer2 = CAGradientLayer()
gradientLayer1.colors = [UIColor.red.cgColor, UIColor.green.cgColor]
gradientLayer2.colors = [UIColor.green.cgColor, UIColor.red.cgColor]
gradientLayer1.startPoint = CGPoint(x: 0.5, y: 0.5)
gradientLayer1.endPoint = CGPoint(x: 1.0, y: 0.5)
gradientLayer2.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer2.endPoint = CGPoint(x: 0.5, y: 0.5)
addSublayer(gradientLayer1)
addSublayer(gradientLayer2)
layer1 = gradientLayer1
layer2 = gradientLayer2
}
override init(layer: Any) {
super.init(layer: layer)
if let testLayer = layer as? TestLayer {
layer1 = testLayer.layer1
layer2 = testLayer.layer2
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSublayers() {
var bounds1 = self.bounds
bounds1.size.height /= 2.0
var bounds2 = bounds1
bounds2.origin.y = bounds1.maxY
super.layoutSublayers()
layer1.frame = bounds1
layer2.frame = bounds2
}
override class func needsDisplay(forKey key: String) -> Bool {
if key == "layer1" || key == "layer2" {
return true
}
return super.needsDisplay(forKey: key)
}
}
--
class TestView: UIView {
override class var layerClass: AnyClass {
get {
return TestLayer.self
}
}
var testLayer: TestLayer {
get {
return layer as! TestLayer
}
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func startAnimation() {
let animLayer1 = CABasicAnimation(keyPath: "layer1.startPoint")
animLayer1.fromValue = CGPoint(x: 0.5, y: 0.5)
animLayer1.toValue = CGPoint(x: 0.0, y: 0.5)
animLayer1.duration = 1.0
animLayer1.autoreverses = true
let animLayer2 = CABasicAnimation(keyPath: "layer2.endPoint")
animLayer2.fromValue = CGPoint(x: 0.5, y: 0.5)
animLayer2.toValue = CGPoint(x: 1.0, y: 0.5)
animLayer2.duration = 1.0
animLayer2.autoreverses = true
let group = CAAnimationGroup()
group.duration = 1.0
group.autoreverses = true
group.repeatCount = Float.greatestFiniteMagnitude
group.animations = [animLayer1, animLayer2]
testLayer.add(group, forKey: "TestAnim")
}
}
If you want to test it, create a dummy view controller, and override its viewWillAppear like this:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let testView = TestView()
testView.frame = view.bounds
testView.translatesAutoresizingMaskIntoConstraints = true
testView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(testView)
testView.startAnimation()
}
Here, I am creating a vanishing circular layer animation. My code is following:
import UIKit
class VanishingLoader: UIView {
private let loaderLayer = CAShapeLayer()
private var layerPaths = [UIBezierPath]()
private let animationframeDuration = 0.25
private var lastFrameDuration = 0.0
weak var parentView: UIView?
var parenViewCenter: CGPoint?{
if let frame = parentView?.bounds{
return CGPoint(x: frame.midX, y: frame.midY)
}
return nil
}
let animationGroup = CAAnimationGroup()
required init?(coder: NSCoder) {
super.init(coder: coder)
}
init(onView view: UIView) {
super.init(frame: view.frame)
parentView = view
layerPaths = preparedPathList
if layerPaths.count > 0 {
let path = layerPaths[0]
addVanishingStrokeLayer(path, withStrokeColor: UIColor.systemGreen, andStrokeWidth: nil)
}
}
private func addVanishingStrokeLayer(_ path: UIBezierPath, withStrokeColor strokeColor:UIColor?, andStrokeWidth strokeWidth: CGFloat?){
let initialPath = layerPaths[0].cgPath
loaderLayer.path = initialPath
loaderLayer.fillColor = UIColor.clear.cgColor
if let loaderLayerColor = strokeColor{
loaderLayer.strokeColor = loaderLayerColor.cgColor
}else{
loaderLayer.strokeColor = defaultStrokeColor.cgColor
}
if let lineWidth = strokeWidth{
loaderLayer.lineWidth = lineWidth
}else{
loaderLayer.lineWidth = 5.0
}
loaderLayer.add(vanishingAnimationGroup(), forKey: "vanishingGroupAnimation")
self.layer.addSublayer(loaderLayer)
}
var preparedPathList: [UIBezierPath]{
var pathList = [UIBezierPath]()
var multiplier : CGFloat = 0.3
for _ in 0..<8 {
if let path = getPathForLayer(withMultiplier: multiplier){
pathList.append(path)
multiplier += 0.10
}
}
return pathList
}
private func getPathForLayer(withMultiplier multiplier: CGFloat = 1.0)->UIBezierPath?{
let radius = 5.0
let path = UIBezierPath(arcCenter: .zero , radius: radius, startAngle: 0, endAngle: 4 * CGFloat.pi, clockwise: true)
return path
}
private func prepareForwardAnimationFrame(forPathCount count: Int)->CASpringAnimation{
let initialPath = layerPaths[count]
let finalPath = layerPaths[count + 1]
let pathAnimation = CASpringAnimation(keyPath: "path")
pathAnimation.fromValue = initialPath.cgPath
pathAnimation.toValue = finalPath.cgPath
pathAnimation.initialVelocity = 1
pathAnimation.damping = 5
pathAnimation.beginTime = lastFrameDuration
pathAnimation.duration = animationframeDuration
return pathAnimation
}
private func vanishingAnimationGroup()->CAAnimationGroup{
var animationPaths = [CASpringAnimation]()
for count in 0..<(layerPaths.count - 1){
let anim = prepareForwardAnimationFrame(forPathCount: count)
animationPaths.append(anim)
lastFrameDuration = anim.beginTime + animationframeDuration
}
animationGroup.animations = animationPaths
animationGroup.duration = (Double(animationPaths.count) * animationframeDuration)
animationGroup.repeatCount = .infinity
animationGroup.fillMode = .forwards
animationGroup.isRemovedOnCompletion = false
return animationGroup
}
public func hide(){
self.removeFromSuperview()
}
public func show(){
if let view = parentView{
view.addSubview(self)
}
}
}
From the ViewController, have to add the following two lines to see the effect.
let loader = VanishingLoader(onView: placeHolderImageView)
loader.show()
The problem, I am facing is when the animation sequence finishes, it goes back to the initial path which is the smallest of the seven paths and that gives a sudden jerking effect. Please help me.
First of all, this should be a key frame animation, not a grouped animation. Second, the change from last to first doesn’t animate because you didn’t animate it. Add another key frame that performs the missing animation.
I rewrote your animation as a key frame animation, and so far, I have this, but I don't think it is what you want, so please say more clearly what the intended effect is supposed to be:
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
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()
}
}
I have an upload UIButton and I'm trying to implement an animation where I have a group of individual dots that line up horizontally underneath the button and have the very left dot fade in/out, then have the dot next to it fade in/out, and so on all the way to the right most dot, then start the cycle over again from the left. I can get this working for one dot, but what would be the most efficient way to do this for multiple dots?
func dotAnimation(){
let xCoord = self.recButt.frame.origin.x + 25
let yCoord = self.recButt.frame.origin.y + 5
let radius = 3
let dotPath = UIBezierPath(ovalIn: CGRect(x: Int(xCoord), y: Int(yCoord), width: radius, height: radius))
let layer = CAShapeLayer()
layer.path = dotPath.cgPath
layer.strokeColor = UIColor(red: 95.00/255, green: 106.00/255, blue: 255/255, alpha: 1.00).cgColor
self.view.layer.addSublayer(layer)
let animation : CABasicAnimation = CABasicAnimation(keyPath: "opacity");
animation.autoreverses = true
animation.fromValue = 0
animation.toValue = 1
animation.duration = 2.0
layer.add(animation, forKey: nil)
}
You are describing a CAReplicatorLayer. Here is one that does the sort of thing you're after, using bars instead of dots:
Here's the code for that. Note that there is only one red bar layer; the replication into five, and the offset fading animation, is handled by the containing replicator layer:
let lay = CAReplicatorLayer()
lay.frame = CGRect(0,0,100,20)
let bar = CALayer()
bar.frame = CGRect(0,0,10,20)
bar.backgroundColor = UIColor.red.cgColor
lay.addSublayer(bar)
lay.instanceCount = 5
lay.instanceTransform = CATransform3DMakeTranslation(20, 0, 0)
let anim = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
anim.fromValue = 1.0
anim.toValue = 0.2
anim.duration = 1
anim.repeatCount = .infinity
bar.add(anim, forKey: nil)
lay.instanceDelay = anim.duration / Double(lay.instanceCount)
// and now just add `lay` to the interface
Here is another version of animating three dots in any UILabel
private var displayLink: CADisplayLink?
private var loadingLabeltext: String = ""
private var loadingLabel: UILabel? = {
let label = UILabel()
label.textColor = UIColor.appRed
label.textAlignment = .center
label.font = UIFont.init(name: "CirceRounded-Bold", size: 15)
label.minimumScaleFactor = 0.6
return label
}()
override func viewDidLoad(_ animated: Bool) {
super.viewDidLoad(animated)
view.addSubview(loadingLabel!)
loadingLabel!.text = "Loading ..."
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
animateLabelDots(label: loadingLabel!)
}
private func animateLabelDots(label: UILabel) {
guard var text = label.text else { return }
text = String(text.dropLast(3))
loadingLabeltext = text
displayLink = CADisplayLink(target: self, selector: #selector(showHideDots))
displayLink?.add(to: .main, forMode: .common)
displayLink?.preferredFramesPerSecond = 4
}
#objc private func showHideDots() {
if !loadingLabeltext.contains("...") {
loadingLabeltext = loadingLabeltext.appending(".")
} else {
loadingLabeltext = "Loading "
}
loadingLabel!.text = loadingLabeltext
}
And when you want to stop it just invalidate and deinitizlize display link like this :
private func hideLoadingSpinner() {
displayLink?.invalidate()
displayLink = nil
UIView.animate(withDuration: 0.5, animations: {
loadingLabel?.text = "Finished!"
loadingLabel?.alpha = 0
})
}