I animate the color of CAShapeLayers stored in an Array using CABasicAnimation. The animation displays erratically depending on the animation.duration and I cannot figure out why. I suspect an issue with animation.beginTime = CACurrentMediaTime() + delay
Animation Description
The animation consists in successively flashing shapes to yellow before turning them to black once the animation ends.
Current State of the animation
When the animation duration is above a certain time, it works properly.
For instance with a duration of 2 seconds:
But when I shorten the duration, the result substantially differs.
For instance, with a duration of 1 second:
You will notice that the animation has already cached/ended for the first 10 bars or so, then waits and starts animating the remainder of the shapes.
Likewise, with a duration of 0.5s:
In this case, it seems an even larger number of animation has already ended (shapes are black) before it displays some animation after a certain time. You can also notice that although the shape color animation is supposed to last the same duration (0.5s) some feels quicker than others.
The Code
The animation is called in the viewDidAppear method of the UIViewController class.
I have created a UIView custom class to draw my shapes and I animate them using an extension of the class.
The code to animate the color:
enum ColorAnimation{
case continuousSwap
case continousWithNewColor(color: UIColor)
case randomSwap
case randomWithNewColor(color: UIColor)
case randomFromUsedColors
}
func animateColors(for duration: Double,_ animationType: ColorAnimation, colorChangeDuration swapColorDuration: Double){
guard abs(swapColorDuration) != Double.infinity else {
print("Error in defining the shape color change duration")
return
}
let animDuration = abs(duration)
let swapDuration = abs(swapColorDuration)
let numberOfSwaps = Int(animDuration / min(swapDuration, animDuration))
switch animationType {
case .continousWithNewColor(color: let value):
var fullAnimation = [CABasicAnimation]()
for i in (0...numberOfSwaps) {
let index = i % (self.pLayers.count)
let fromValue = pLayers[index].pattern.color
let delay = Double(i) * swapDuration / 3
let anim = colorAnimation(for: swapDuration, fromColor: value, toColor: fromValue, startAfter: delay)
fullAnimation.append(anim)
}
for i in (0...numberOfSwaps) {
CATransaction.begin()
let index = i % (self.pLayers.count)
CATransaction.setCompletionBlock {
self.pLayers[index].shapeLayer.fillColor = UIColor.black.cgColor
}
pLayers[index].shapeLayer.add(fullAnimation[i], forKey: "fillColorShape")
CATransaction.commit()
}
default:
()
}
}
The segment the whole duration of the animation by the duration of the color change (e.g. if the whole animation is 10s and each shape changes color in 1s, it means 10 shapes will change color).
I then create the CABasicaAnimation objects using the method colorAnimation(for: fromColor, toColor, startAfter:).
func colorAnimation(for duration: TimeInterval, fromColor: UIColor, toColor: UIColor, reverse: Bool = false, startAfter delay: TimeInterval) -> CABasicAnimation {
let anim = CABasicAnimation(keyPath: "fillColor")
anim.fromValue = fromColor.cgColor
anim.toValue = toColor.cgColor
anim.duration = duration
anim.autoreverses = reverse
anim.beginTime = CACurrentMediaTime() + delay
return anim
}
Finally I add the animation to the adequate CAShapeLayer.
The code can obviously be optimized but I chose to proceed by these steps to try to find why it was not working properly.
Attempts so far
So far, I have tried:
with and without setting the animation.beginTime in the colorAnimation method, including with and without CACurrentMediaTime(): if I don't set the animation.beginTime with CACurrentMediaTime, I simply do not see any animation.
with and without pointing animation.delegate = self: it did not change anything.
using DispatchQueue (store the animations in global and run it in main) and as suspected, the shapes did not animate.
I suspect something is not working properly with the beginTime but it might not be the case, or only this because even when the shapes animate, the shape animation duration seems to vary whilst it should not.
Thank very much in advance to have a look to this issue. Any thoughts are welcome even if it seems far-fetched it can open to new ways to address this!
Best,
Actually there is a relationship between duration and swapColorDuration
func animateColors(for duration: Double,_ animationType: ColorAnimation, colorChangeDuration swapColorDuration: Double)
when you call it, you may need to keep this relationship
let colorChangeDuration: TimeInterval = 0.5
animateColors(for: colorChangeDuration * TimeInterval(pLayers.count), .continousWithNewColor(color: UIColor.black), colorChangeDuration: colorChangeDuration)
Also here :
let numberOfSwaps = Int(animDuration / min(swapDuration, animDuration)) - 1
This value maybe a little higher than you need.
or
The problem lies in this let index = i % (self.pLayers.count)
if numberOfSwaps > self.pLayers.count, some bands will be double animations.
let numberOfSwaps1 = Int(animDuration / min(swapDuration, animDuration))
let numberOfSwaps = min(numberOfSwaps1, self.pLayers.count)
in the rest is
for i in (0..<numberOfSwaps) {... }
Now if numberOfSwaps < self.pLayers.count. It's not finished.
if numberOfSwaps is larger, It is fine.
If double animations are required, changes the following:
pLayers[index].shapeLayer.add(fullAnimation[i], forKey: nil)
or pLayers[index].shapeLayer.add(fullAnimation[i], forKey: "fillColorShape" + String(i))
Related
I'm creating a reusable multi state switch extends from UIControl that looks similar to the default UISwitch, with more than two states. The implementation is like adding CALayers for each of the state and one additional Layer for highlighting the selected state on the UIView.
The UI looks like this
The problem is that I could not recognize touch of a state when the user tapped just outside the squared border as indicated in the image. There is a simple convenience method (getIndexForLocation) I have added to return the index of selected state, given the touch point. With the returned selected index I displace the highlight indicator's position to move on center of the selected state.
func getIndexForLocation(_ point: CGPoint) -> Int {
var index = selectedIndex
let sLayers = self.controlLayer.sublayers
for i in 0..<totalStates {
if sLayers![i].frame.contains(point) {
index = i
}
}
return index
}
The above method is called from endTracking(touch:for event) method of UIControl with the last touch point.
How can I change this method, so that I can get index of the touched/selected state, even If the user has touched around the image. The touch area can be approximately the area of highlight circle placed on top of the center of the state image.
self.controlLayer is the container Layer whose sublayers are all the states and the highlight indicator.
The method that adds does position animation with selected index is provided below
func performSwitch(to index: Int) -> () {
guard selectedIndex != index else {
return
}
let offsetDiff = CGFloat((index - selectedIndex) * (stateSize + 2))
let oldPosition = indicatorPosition
indicatorPosition.x += offsetDiff
let newPosition = indicatorPosition
let animation: CABasicAnimation = CABasicAnimation(keyPath: "position")
animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.785, 0.135, 0.15, 0.86)
animation.duration = 0.6
animation.fromValue = NSValue(cgPoint: oldPosition!)
animation.toValue = NSValue(cgPoint: newPosition!)
animation.autoreverses = false
animation.delegate = self
animation.isRemovedOnCompletion = false
animation.fillMode = kCAFillModeForwards
self.selectedIndex = index
self.stateIndicator.add(animation, forKey: "position")
}
Any help would be greatly appreciated.
You are testing by saying if sLayers![i].frame.contains(point). The simplest solution, therefore, is make the frame of each "button" much bigger — big enough to encompass the whole area you wish to be touchable as this "button". Remember, the drawing can be small even if the frame is big.
Also, just as an aside, your code is silly because you are basically performing hit-testing. Hit-testing for layers is built-in, so once you have your frames right, all you have to do is call hitTest(_:) on the superlayer. That will tell you which layer was tapped.
(Of course, it would be even better to use subviews instead of layers. Views can detect touches; layers can't.)
In order to rotate circle in 360 I use CABasicAnimation, in this way :
func startRotate(from : Double) {
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = from
rotation.toValue = NSNumber(value: M_PI * 2 - from)
rotation.duration = 10
rotation.isCumulative = true
rotation.repeatCount = 1
self.cdImage.layer.add(rotation, forKey: "rotationAnimation")
}
At the first time the cdImage (view) start from value 0.
When the animation arrived to the middle (after 5 sec) I want to stop the animation and set a transform for the cdImage view, for example return the view 20 degree back and resume it from the new position.
by this way:
1. I call to stopRotate function
func stopRotate(from : Double) {
self.cdImage.layer.removeAnimation(forKey: "rotationAnimation")
}
I set a new transform
self.cdImage.transform = CGAffineTransform(rotationAngle: imageAngle * CGFloat(M_PI) / 180);
call to startRotate with a new value
startRotate(imageAngle * CGFloat(M_PI) / 180)
I suppose that animation will start from the new position and rotate again the view in 360 degree, but actually the animation start the circle from 0 degree, how I can resume the new animation from the new position?
If you want to pick up from where you left off, when you stop the animation, you want to:
capture how much it has rotated (see https://stackoverflow.com/a/9712319/1271826);
determine the in-flight value by examining the presentation layer;
set the transform of the layer accordingly (so that it stays there when you remove the animation); and
remove the animation
Thus:
var angle: CGFloat?
func stopRotate() {
let transform = cdImage.layer.presentation()!.transform
angle = atan2(transform.m12, transform.m11);
cdImage.layer.transform = transform
cdImage.layer.removeAnimation(forKey: "rotationAnimation")
}
You can then start the animation from that angle when you restart it.
I'm writing a little bit complex animation, which goes in 2 steps:
Change opacity to 0 of UIViews that are not need to be visible and move a UIImageView (which has alpha = 1) to another CGPoint (position).
Change opacity of another UIView to 1 and the opacity of the UIImageView from the previous step to 0, and then after the animation of this step is finished, remove UIImageView from superview.
I've done it this way:
The first step is done without an explicit CATransaction. These 2 animations just have beginTime set to CACurrentMediaTime(). And I'm applying changes to the views right after layer.addAnimation(...) call.
Everything works fine here.
In the second step implementation I call CATransaction.begin() at the beginning.
Inside begin/commit calls to CATransaction I create and add 2 CABasicAnimations to 2 different layers: one for changing the opacity from 0 to 1 (for UIView), and one for changing the opacity from 1 to 0 (for UIImageView). Both animations have beginTime set to CACurrentMediaTime() + durationOfThePreviousStep.
And right after CATransaction.begin() I call CATransaction.setCompletionBlock({...}), and in this completion block I apply changes to these two views: set their new alphas and remove UIImageView from superview.
The problem is, at the end of this whole animation the UIView that has alpha animated to 1 flashes, which means its alpha sets back to 0 (though I've set its alpha to 1 in the completion block) and right after this the completion block executes and its alpha goes up to 1 again.
Well, the question is, how to get rid of this flashing? Maybe this animation can be done in better way?
P.S. I'm not using UIView animations because I'm interested in custom timing functions for these animations.
EDIT 1:
Here's the code. I've deleted UIImageView alpha animation because it's not really necessary.
var totalDuration: CFTimeInterval = 0.0
// Alpha animations.
let alphaAnimation = CABasicAnimation()
alphaAnimation.keyPath = "opacity"
alphaAnimation.fromValue = 1
alphaAnimation.toValue = 0
alphaAnimation.beginTime = CACurrentMediaTime()
alphaAnimation.duration = 0.15
let alphaAnimationName = "viewsFadeOut"
view1.layer.addAnimation(alphaAnimation, forKey: alphaAnimationName)
view1.alpha = 0
view2.layer.addAnimation(alphaAnimation, forKey: alphaAnimationName)
view2.alpha = 0
view3.layer.addAnimation(alphaAnimation, forKey: alphaAnimationName)
view3.alpha = 0
view4.layer.addAnimation(alphaAnimation, forKey: alphaAnimationName)
view4.alpha = 0
// Image View moving animation.
// Add to total duration.
let rect = /* getting rect */
let newImagePosition = view.convertPoint(CGPoint(x: CGRectGetMidX(rect), y: CGRectGetMidY(rect)), fromView: timeView)
let imageAnimation = CABasicAnimation()
imageAnimation.keyPath = "position"
imageAnimation.fromValue = NSValue(CGPoint: imageView!.layer.position)
imageAnimation.toValue = NSValue(CGPoint: newImagePosition)
imageAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionDefault)
imageAnimation.beginTime = CACurrentMediaTime()
imageAnimation.duration = 0.3
imageView!.layer.addAnimation(imageAnimation, forKey: "moveImage")
imageView!.center = newImagePosition
totalDuration += imageAnimation.duration
// Time View alpha.
CATransaction.begin()
CATransaction.setCompletionBlock {
self.timeView.alpha = 1
self.imageView!.removeFromSuperview()
self.imageView = nil
}
let beginTime = CACurrentMediaTime() + totalDuration
let duration = 0.3
alphaAnimation.fromValue = 0
alphaAnimation.toValue = 1
alphaAnimation.beginTime = beginTime
alphaAnimation.duration = duration
timeView.layer.addAnimation(alphaAnimation, forKey: "timeViewFadeIn")
/* imageView alpha animation is not necessary, so I removed it */
CATransaction.commit()
EDIT 2: Piece of code that cause the problem:
CATransaction.begin()
CATransaction.setCompletionBlock {
self.timeView.alpha = 1
}
let duration = 0.3
let alphaAnimation = CABasicAnimation()
alphaAnimation.keyPath = "opacity"
alphaAnimation.fromValue = 0.0
alphaAnimation.toValue = 1.0
alphaAnimation.duration = duration
timeView.layer.addAnimation(alphaAnimation, forKey: "timeViewFadeIn")
CATransaction.commit()
Maybe the problem is because the timeView has a UITextField and a UIScrollView with 4 subviews. I've tried to replace timeView with a snapshot of timeView (UIImageView), but that didn't help.
EDIT 3:
New code. With this code, timeView always has alpha = 1, and it also animates from 0 to 1.
CATransaction.begin()
CATransaction.setCompletionBlock {
self.imageView!.removeFromSuperview()
self.imageView = nil
}
let alphaAnimation = CABasicAnimation()
alphaAnimation.keyPath = "opacity"
alphaAnimation.fromValue = 0.0
alphaAnimation.toValue = 1.0
alphaAnimation.duration = 0.3
alphaAnimation.beginTime = beginTime
timeView.layer.addAnimation(alphaAnimation, forKey: "timeViewFadeIn")
timeView.alpha = 1.0
CATransaction.commit()
Just looking at your code, I would expect it to flash. Why? Because you have animated timeView's layer's opacity from 0 to 1, but you have not set it to 1 (except in the completion handler, which will happen later). Thus, we animate the presentation layer from 0 to 1 and then the animation ends and it is revealed that the opacity of the real layer was 0 all along.
So, set timeView's layer's opacity to 1 before your animation gets going. Also, since you are using a delayed beginTime, you will need to set your animation's fillMode to "backwards".
I was able to get good results by modifying your test code to be self-contained and to look like this; there is a delay, the view fades in, and there is no flash at the end:
CATransaction.begin()
let beginTime = CACurrentMediaTime() + 1.0 // arbitrary, just testing
let alphaAnimation = CABasicAnimation()
alphaAnimation.keyPath = "opacity"
alphaAnimation.fromValue = 0.0
alphaAnimation.toValue = 1.0
alphaAnimation.duration = 1.0 // arbitrary, just testing
alphaAnimation.fillMode = "backwards"
alphaAnimation.beginTime = beginTime
timeView.layer.addAnimation(alphaAnimation, forKey: "timeViewFadeIn")
timeView.layer.opacity = 1.0
CATransaction.commit()
There are some other things about your code that I find rather odd. It is somewhat risky using a transaction completion block in this way; I don't see why you don't give your animation a delegate. Also, you are reusing alphaAnimation multiple times; I can't recommend that. I would create a new CABasicAnimation for each animation, if I were you.
Here's my setup, using Sprite Kit. First, I create a simple sprite node within a SKScene, like so:
let block = SKSpriteNode(color: UIColor.redColor(), size: CGSizeMake(90, 160))
block.zPosition = 2
block.shadowCastBitMask = 1
addChild(block)
Then add a light node to the scene:
let light = SKLightNode()
light.categoryBitMask = 1
light.falloff = 1
addChild(light)
Sure enough, the block now casts a nice little shadow:
Now I fade the block by manipulating its alpha value, for example by running an action:
let fadeOut = SKAction.fadeAlphaTo(0.0, duration: 5.0)
block.runAction(fadeOut)
Here's the awkward situation: while the block becomes more and more translucent, the shadow stays exactly the same. This is how it looks like just a moment before the end of the action:
And once the alpha drops to 0.0 entirely, the shadow suddenly disappears, from one frame to the next.
It would be much nicer, however, to have the shadow slowly become weaker and weaker, as the object casting it becomes more and more transparent.
Question:
Is an effect like this possible with Sprite Kit? If so, how would you go about it?
This is a little tricky because the shadow cast by an SKLightNode isn't affected by the node's alpha property. What you need to do is fade out the alpha channel of the shadowColor property of the SKLightNode at the same time you're fading out your block.
The basic steps are:
Store the light's shadowColor and that color's alpha channel for reference.
Create a SKAction.customActionWithDuration which:
Re-calculates the value for the alpha channel based on the original and how much time has past so far in the action.
Sets the light's shadowColor to its original color but with the new alpha channel.
Run the block's fade action and the shadow's fade action in parallel.
Example:
let fadeDuration = 5.0 // We're going to use this a lot
// Grab the light's original shadowColor so we can use it later
let shadowColor = light.shadowColor
// Also grab its alpha channel so we don't have to do it each time
let shadowAlpha = CGColorGetAlpha(shadowColor.CGColor)
let fadeShadow = SKAction.customActionWithDuration(fadeDuration) {
// The first parameter here is the node this is running on.
// Ideally you'd use that to get the light, but I'm taking
// a shortcut and accessing it directly.
(_, time) -> Void in
// This is the original alpha channel of the shadow, adjusted
// for how much time has past while running the action so far
// It will go from shadowAlpha to 0.0 over fadeDuration
let alpha = shadowAlpha - (shadowAlpha * time / CGFloat(fadeDuration))
// Set the light's shadowColor to the original color, but replace
// its alpha channel our newly calculated one
light.shadowColor = shadowColor.colorWithAlphaComponent(alpha)
}
// Make the action to fade the block too; easy!
let fadeBlock = SKAction.fadeAlphaTo(0.0, duration: fadeDuration)
// Run the fadeBlock action and fadeShadow action in parallel
block.runAction(SKAction.group([fadeBlock, fadeShadow]))
The following is one way to ensure that the shadow and block fade-in/fade-out together. To use this approach, you will need to declare light and block as properties of the class.
override func didEvaluateActions() {
light.shadowColor = light.shadowColor.colorWithAlphaComponent(block.alpha/2.0)
}
EDIT: Here's how to implement the above.
class GameScene: SKScene {
let light = SKLightNode()
let block = SKSpriteNode(color: UIColor.redColor(), size: CGSizeMake(90, 160))
override func didMoveToView(view: SKView) {
/* Setup your scene here */
block.zPosition = 2
block.shadowCastBitMask = 1
block.position = CGPointMake(100, 100)
addChild(block)
light.categoryBitMask = 1
light.falloff = 1
addChild(light)
let fadeOut = SKAction.fadeAlphaTo(0.0, duration: 5.0);
let fadeIn = SKAction.fadeAlphaTo(1.0, duration: 5.0);
block.runAction(SKAction.sequence([fadeOut,fadeIn,fadeOut]))
}
override func didEvaluateActions() {
light.shadowColor = light.shadowColor.colorWithAlphaComponent(block.alpha/2.0)
}
}
I want to animate 3 different images at specific point in time such that it behaves this way.
1) 1st image moves from (Xx, Yx) to (Xz,Yz)
2) Wait 10 seconds
3) 2nd image appears in place at Xa,Yb
4) Wait half as long as in step 2
5) Fade out 2nd image
6) 3rd image appears at the same place as 2nd image
If each of these image's animations are on their own CALayers, can I use CAKeyframeAnimation with multiple layers? If not, what's another way to go about doing staggered animations?
I'm trying to animate a playing card move from offscreen to a particular spot and then few other tricks to appear on screen several seconds later.
Edited
When I wrote this, I thought you could not use a CAAnimationGroup to animate multiple layers. Matt just posted an answer demonstrating that you can do that. I hereby eat my words.
I've taking the code in Matt's answer and adapted it to a project which I've uploaded to Github (link.)
The effect Matt's animation creates is of a pair of feet walking up the screen. I found some open source feet and installed them in the project, and made some changes, but the basic approach is Matt's. Props to him.
Here is what the effect looks like:
(The statement below is incorrect)
No, you can't use a keyframe animation to animate multiple layers. A given CAAnimation can only act on a single layer. This includes group layers, by the way.
If all you're doing is things like moving images on a straight line, fading out, and fading in, why don't you use UIView animation? Take a look at the methods who's names start with animateWithDuration:animations: Those will let you create multiple animations at the same time, and the completion block can then trigger additional animations.
If you need to use layer animation for some reason, you can use the beginTime property (which CAAnimation objects have because they conform to the CAMediaTiming protocol.) For CAAnimations that are not part of an animation group, you use
animation.beginTime = CACurrentMediaTime() + delay;
Where delay is a double which expresses the delay in seconds.
If the delay is 0, the animation would begin.
A third option would be to set your view controller up as the delegate of the animation and use the animationDidStop:finished: method to chain your animations. This ends up being the messiest approach to implement, in my opinion.
The claim that a single animation group cannot animate properties of different layers is not true. It can. The technique is to attach the animation group to the superlayer and refer to the properties of the sublayers in the individual animations' key paths.
Here is a complete example just for demonstration purposes. When launched, this project displays two "footprints" that proceed to step in alternation, walking off the top of the screen.
class ViewController: UIViewController, CAAnimationDelegate {
let leftfoot = CALayer()
let rightfoot = CALayer()
override func viewDidLoad() {
super.viewDidLoad()
self.leftfoot.name = "left"
self.leftfoot.contents = UIImage(named:"leftfoot")!.cgImage
self.leftfoot.frame = CGRect(x: 100, y: 300, width: 50, height: 80)
self.view.layer.addSublayer(self.leftfoot)
self.rightfoot.name = "right"
self.rightfoot.contents = UIImage(named:"rightfoot")!.cgImage
self.rightfoot.frame = CGRect(x: 170, y: 300, width: 50, height: 80)
self.view.layer.addSublayer(self.rightfoot)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.start()
}
}
func start() {
let firstLeftStep = CABasicAnimation(keyPath: "sublayers.left.position.y")
firstLeftStep.byValue = -80
firstLeftStep.duration = 1
firstLeftStep.fillMode = .forwards
func rightStepAfter(_ t: Double) -> CABasicAnimation {
let rightStep = CABasicAnimation(keyPath: "sublayers.right.position.y")
rightStep.byValue = -160
rightStep.beginTime = t
rightStep.duration = 2
rightStep.fillMode = .forwards
return rightStep
}
func leftStepAfter(_ t: Double) -> CABasicAnimation {
let leftStep = CABasicAnimation(keyPath: "sublayers.left.position.y")
leftStep.byValue = -160
leftStep.beginTime = t
leftStep.duration = 2
leftStep.fillMode = .forwards
return leftStep
}
let group = CAAnimationGroup()
group.duration = 11
group.animations = [firstLeftStep]
for i in stride(from: 1, through: 9, by: 4) {
group.animations?.append(rightStepAfter(Double(i)))
group.animations?.append(leftStepAfter(Double(i+2)))
}
group.delegate = self
self.view.layer.add(group, forKey: nil)
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
print("done")
self.rightfoot.removeFromSuperlayer()
self.leftfoot.removeFromSuperlayer()
}
}
Having said all that, I should add that if you are animating a core property like the position of something, it might be simpler to make it a view and use a UIView keyframe animation to coordinate animations on different views. Still, the point is that to say that this cannot be done with CAAnimationGroup is just wrong.