I'm learning how to use CoreAnimation with various UI elements. Ultimately I would like to perform complex, queued animations on all ui elements on screen as I transition to the next view.
Now however I am attempting something really simple. I want to transform (scale up) a UILabel on button tap.
I have the following function:
func animateUIElementsOut(element : AnyObject) {
var animation : CABasicAnimation = CABasicAnimation(keyPath: "transform");
var transform : CATransform3D = CATransform3DMakeScale(2, 2, 1);
animation.setValue(NSValue(CATransform3D: transform), forKey: "scaleText");
animation.duration = 2.0;
element.layer?.addAnimation(animation, forKey: "scaleText");
}
I am calling the function on a button tap like this:
animateUIElementsOut(greetingsLabel);
The compiler isn't giving off any warnings, however the animation is not working. What am I doing wrong here?
As per #David Rönnqvist suggestions the working code looks like this (fromValue / toValue added):
func animateUIElementsOut(element : AnyObject) {
var animation : CABasicAnimation = CABasicAnimation(keyPath: "transform");
animation.delegate = self;
var transform : CATransform3D = CATransform3DMakeScale(2, 2, 1);
animation.fromValue = NSValue(CATransform3D: CATransform3DIdentity);
animation.toValue = NSValue(CATransform3D: transform);
animation.duration = 2.0;
element.layer?.addAnimation(animation, forKey: nil);
}
The animation doesn't have neither a toValue or a fromValue.
You are setting the transform for the "scaleText" key on the animation but the animation doesn't have a corresponding property.
To make the animation itself work you should set the transform as the toValue of the animation. However, there are still things missing to make the whole thing work. The model value of the layer is never changed so when the animation finished and is removed, you will see the old (model) values again.
If you are only interested in making a scaling animation, I would recommend that you use the higher level UIView animation APIs instead:
UIView.animateWithDuration(2.0) {
element.transform = CGAffineTransformMakeScale(2, 2)
}
If on the other hand you are interested in working with Core Animation, there are a couple more things to do to make it all work.
configure the animation to go from the current model value (using the fromValue property)
configure the animation to go to the new value (as explained above)
update the model value to the new value before adding the animation.
As a side trivia, the reason that you could set a value for "scaleText" without there being any such property is that layers and animations work a bit differently than most classes with key-value coding. You can set and get any value on both a layer and a view using key-value coding. This means that this is valid code:
let layer = CALayer()
layer.setValue(1, forKey: "foo")
var foo = layer.valueForKey("foo") // is equal to 1
let anim = CABasicAnimation(keyPath: "bounds")
anim.setValue(2, forKey: "bar")
var bar = anim.valueForKey("bar") // is equal to 2
However, if you try and do the same on almost any other object, you will get a runtime exception saying that the class isn't key-value coding compliant for that key. For example, this code:
let view = UIView(frame: CGRectZero)
view.setValue(3, forKey: "baz")
Produces this runtime exception:
Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<UIView 0x10dc08b30> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key baz.'
Related
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))
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.)
I have ViewController() and Circle.swift.
In my Circle.swift I have all animation code and from the ViewController I just call it.
So, in Circle file I have:
func commonSetup() {
...
fillingLayer.strokeStart = start
anim.fromValue = start
anim.toValue = end
fillingLayer.strokeEnd = end
fillingLayer.addAnimation(anim, forKey: "circleAnim")
}
where I set start = 0.0 and end = 0.55 above the class Circle: UIView {.
In my ViewController I have a button, on click on it I call:
func pressed() {
start = 0.55
end = 0.9
Circle().commonSetup()
}
but it doesn't work. My animation doesn't resume. How can I fix it? I want to at first load from 0 to 0.55, and after button clicked - from 0.55 to 0.9.
What I do not see in my code?
You shouldn't change value of fromValue, toValue and strokeStart as often as you do:
func commonSetup() {
...
fillingLayer.strokeStart = 0;
anim.fromValue = START_VALUE //value expressed in points
anim.toValue = END_VALUE //value expressed in points
fillingLayer.strokeEnd = end
fillingLayer.addAnimation(anim, forKey: "circleAnim")
}
You should set values of fromValue, toValue and strokeStart properties and doesn't change them. You can control your animation only by changing strokeEnd property.
The problem is you're creating a new instance of circle when you say Circle().commonSetup() You need to call commonSetup on the instance of circle that you've created before.
So it might look more like this. In your view controller you'd have a variable for the circle.
let progressCircle = Circle()
Then, in your pressed function:
func pressed() {
start = 0.55
end = 0.9
progressCircle.commonSetup()
}
If you wanted to use it as a class function instead of an instance function (which probably would be incorrect for something like this), you would need to define the commonSetup function with the class keyword, as follows:
class func commonSetup() {
//code
}
Then, you would use it like: Circle.commonSetup(). Note the lack of parenthesis on Circle. This is because you're running a class function, not initializing a new instance of the class. However... making this a class function would be weird to do in your case, since your function is really working on instances and not a class-level action.
I usually make a habit of using the proper keys for my animations as well. So yours would say: fillingLayer.addAnimation(anim, forKey: "strokeEnd")
This drives me mad. Whenever I change value of CATextLayer.foregroundColor property the change is animated no matter what I do. For example I have CATextLayer called layer which is sublayer of CALayer superlayer:
layer.removeAllAnimations()
superlayer.removeAllAnimations()
superlayer.actions = ["sublayers" : NSNull()]
CATransaction.begin()
CATransaction.setAnimationDuration(0)
CATransaction.disableActions()
CATransaction.setValue(kCFBooleanTrue, forKey:kCATransactionDisableActions)
layer.actions = ["foregroundColor" : NSNull()]
layer.actions = ["content" : NSNull()]
layer.foregroundColor = layoutTheme.textColor.CGColor
CATransaction.commit()
And it will still animates! Please help, how can I disable the implicit animations?
The problem is that CATransaction.disableActions() does not do what you think it does. You need to say CATransaction.setDisableActions(true).
And then you can get rid of all the other stuff you're saying, as it is pointless. This code alone is sufficient to change the color without animation:
CATransaction.setDisableActions(true)
layer.foregroundColor = UIColor.redColor().CGColor // or whatever
(You can wrap it in a begin()/commit() block if you have some other reason to do so, but there is no need just in order to switch off implicit layer property animation.)
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.