Modify a value during a CAAnimation - ios

Say you have a simple animation
let e : CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
e.duration = 2.0
e.fromValue = 0
e.toValue = 1.0
e.repeatCount = .greatestFiniteMagnitude
self.someLayer.add(e, forKey: nil)
of a layer
private lazy var someLayer: CAShapeLayer
It's quite easy to read the value of strokeEnd each animation step.
Just change to a custom layer
private lazy var someLayer: CrazyLayer
and then
class CrazyLayer: CAShapeLayer {
override func draw(in ctx: CGContext) {
print("Yo \(presentation()!.strokeEnd)")
super.draw(in: ctx)
}
}
It would be very convenient to actually be able to set the property values of the layer at each step.
Example:
class CrazyLayer: CAShapeLayer {
override func draw(in ctx: CGContext) {
print("Yo \(presentation()!.strokeEnd)")
strokeStart = presentation()!.strokeEnd - 0.3
super.draw(in: ctx)
}
}
Of course you can't do that -
attempting to modify read-only layer
Perhaps you could have a custom animatable property
class LoopyLayer: CAShapeLayer {
#NSManaged var trick: CGFloat
override class func needsDisplay(forKey key: String) -> Bool {
if key == "trick" { return true }
return super.needsDisplay(forKey: key)
}
... somehow for each frame
strokeEnd = trick * something
backgroundColor = alpha: trick
}
How can I "manually" set the values of the animatable properties, each frame?
Is there perhaps a way to simply supply (override?) a function which calculates the value each frame? How to set values each frame, on some of the properties, from a calculation of other properties or perhaps another custom property?
(Footnote 1 - a hacky workaround is to abuse a keyframe animation, but that's not really the point here.)
(Footnote 2 - of course, it's often better to just simply animate in the ordinary old manual way with CADisplayLink, and not bother about CAAnimation, but this QA is about CAAnimation.)

Roughly speaking, CoreAnimation gives you the ability to perform the interpolation between certain values according to a certain timing function.
CAAnimation has a private method that applies its interpolated value to the layer at a given time.
The injection of that value into the presentation layer happens after the presentation layer has been created out of the instance of the model layer's class (init(layer:) call), within the stack of display method call before the draw(in ctx: CGContext) method call.
So, assuming that presentation layer's class is the same as your custom layer's class, one way around that is to have the animated property different from the property that is being used for rendering of the layer's contents. You could animate interpolatedValue property, and have another dynamic property strokeEnd that will on the fly calculate appropriate value on every frame out of the interpolatedValue value.
In the edge case, interpolatedValue could be just the animation progress from 0 to 1, and based on that you can dynamically calculate your strokeEnd value within your presentation layer. You can even ask your delegate about the value for a given progress:
#objc #NSManaged open dynamic var interpolatedValue: CGFloat
open override class func needsDisplay(forKey key: String) -> Bool {
var result = super.needsDisplay(forKey: key)
result = result || key == "interpolatedValue"
return result
}
open var strokeEnd : CGFloat {
get {
return self.delegate.crazyLayer(self, strokeEndFor: self.interpolatedValue)
}
set {
...
}
}
let animation = CABasicAnimation(keyPath: "interpolatedValue")
animation.duration = 2.0
animation.fromValue = 0.0
animation.toValue = 1.0
crazyLayer.add(animation, forKey: "interpolatedValue")
Another way around that could be to use CAKeyframeAnimation where you can specify which value has to be assigned to the animatable property at a corresponding time.
In case you might want to assign on each frame a complex value out of interpolated components, I have a project where I do that for animation of NSAttributedString value by interpolation of its attributes, and the article where I describe the approach.

Related

How to animate drawing in Swift, but also change a UIImageView's scale?

I'd like to animate a drawing sequence. My code draws a spiral into a UIImageView.image. The sequence changes the image contents, but also changes the scale of the surrounding UIImageView. The code is parameterized for the number of revolutions of the spiral:
func drawSpiral(rotations:Double) {
let scale = scaleFactor(rotations) // do some math to figure the best scale
UIGraphicsBeginImageContextWithOptions(mainImageView.bounds.size, false, 0.0)
let context = UIGraphicsGetCurrentContext()!
context.scaleBy(x: scale, y: scale) // some animation prohibits changes!
// ... drawing happens here
myUIImageView.image = UIGraphicsGetImageFromCurrentImageContext()
}
For example, I'd like to animate from drawSpiral(2.0) to drawSpiral(2.75) in 20 increments, over a duration of 1.0 seconds.
Can I setup UIView.annimate(withDuration...) to call my method with successive intermediate values? How? Is there a better animation approach?
Can I setup UIView.annimate(withDuration...) to call my method with successive intermediate values
Animation is merely a succession of timed intermediate values being thrown at something. It is perfectly reasonable to ask that they be thrown at your code so that you can do whatever you like with them. Here's how.
You'll need a special layer:
class MyLayer : CALayer {
#objc var spirality : CGFloat = 0
override class func needsDisplay(forKey key: String) -> Bool {
if key == #keyPath(spirality) {
return true
}
return super.needsDisplay(forKey:key)
}
override func draw(in con: CGContext) {
print(self.spirality) // in real life, this is our signal to draw!
}
}
The layer must actually be in the interface, though it can be impossible for the user to see:
let lay = MyLayer()
lay.frame = CGRect(x: 0, y: 0, width: 1, height: 1)
self.view.layer.addSublayer(lay)
Subsequently, we can initialize the spirality of the layer:
lay.spirality = 2.0
lay.setNeedsDisplay() // prints: 2.0
Now when we want to "animate" the spirality, this is what we do:
let ba = CABasicAnimation(keyPath:#keyPath(MyLayer.spirality))
ba.fromValue = lay.spirality
ba.toValue = 2.75
ba.duration = 1
lay.add(ba, forKey:nil)
CATransaction.setDisableActions(true)
lay.spirality = 2.75
The console shows the arrival of a succession of intermediate values over the course of 1 second!
2.03143266495317
2.04482554644346
2.05783333256841
2.0708108600229
2.08361491002142
2.0966724678874
2.10976020619273
2.12260236591101
2.13551922515035
2.14842618256807
2.16123360767961
2.17421661689878
2.18713565543294
2.200748950243
2.21360073238611
2.2268518730998
2.23987507075071
2.25273013859987
2.26560932397842
2.27846492826939
2.29135236144066
2.30436328798532
2.31764804571867
2.33049770444632
2.34330793470144
2.35606706887484
2.36881992220879
2.38163591921329
2.39440815150738
2.40716737508774
2.42003352940083
2.43287514150143
2.44590276479721
2.45875595510006
2.47169743478298
2.48451870679855
2.49806520342827
2.51120449602604
2.52407149970531
2.53691896796227
2.54965999722481
2.56257836520672
2.57552136480808
2.58910304307938
2.60209316015244
2.6151298135519
2.62802086770535
2.64094598591328
2.6540260463953
2.6669240295887
2.6798157542944
2.69264766573906
2.70616912841797
2.71896715462208
2.73285858333111
2.74564798176289
2.75
2.75
2.75
Those are exactly the numbers that would be thrown at an animatable property, such as when you change a view's frame origin x from 2 to 2.75 in a 1-second duration animation. But now the numbers are coming to you as numbers, and so you can now do anything you like with that series of numbers. If you want to call your method with each new value as it arrives, go right ahead.
Personally, in more complicated animations I would use lottie the animation itself is built with Adobe After Effect and exported as a JSON file which you will manage using the lottie library this approach will save you time and effort when you port your app to another platform like Android as they also have an Android Lottie which means the complicated process of creating the animation is only done once.
Lottie Files has some examples animations as well for you to look.
#Matt provided the answer and gets the checkmark. I'll recap some points for emphasis:
UIView animation is great for commonly animated properties, but if
you need to vary a property not on UIView's animatable list, you can't use it. You must
create a new CALayer and add a CABasicAnimation(keyPath:) to it.
I tried but was unable to get my CABasicAnimations to fire by adding them to the default UIView.layer. I needed to create a custom CALayer
sublayer to the UIView.layer - something like
myView.layer.addSublayer(myLayer)
Leave the custom sublayer installed and re-add the CABasicAnimation to that sublayer when (and only when) you want to animate drawing.
In the custom CALayer object, be sure to override class func needsDisplay(forKey key: String) -> Bool with your key property (as #Matt's example shows), and also override func draw(in cxt: CGContext) to do your drawing. Be sure to decorate your key property with #objc. And reference the key property within the drawing code.
A "gotcha" to avoid: in the UIView object, be sure to null out the usual draw method (override func draw(_ rect: CGRect) { }) to avoid conflict between animated and non-animated drawing on the separate layers. For coordinating animated and non-animated content in the same UIView, it's good (necessary?) to do all your drawing from your custom layer.
When doing that, use myLayer.setNeedsDisplay() to update the non-animated content within the custom layer; use myLayer.add(myBasicAnimation, forKey:nil) to trigger animated drawing within the custom layer.
As I said above, #Matt answered - but these items seemed worth emphasizing.

iOS: Unable to detect touch outside the bounds of CALayer

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.)

Custom animation of two properties in CALayer by a new custom property

I can animate a CALayer object by changing a property using CABasicAnimation as with this code:
let animationOfPropertyOpacity = CABasicAnimation(keyPath: "opacity")
animationOfPropertyOpacity.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animationOfPropertyOpacity.fromValue = 0
animationOfPropertyOpacity.toValue = 1
myLayer.addAnimation(animationOfPropertyOpacity, forKey: "fade")
In this case the animation will create a linear interpolation between 0 to 1. I can do the same with other properties like scale, position or rotation.
What If I want to move from point A (0,3) to point B (3,0) but making a circle respect to point C (0,0)?
I want to keep using fromValue to toValue, but animating a custom property that I've made called rotation. Rotation will calculate each interpolation value based on point C as center and making a radius from that center.
Where do I need to put the function that calculates each step?
I've tried subclassing CALayer and adding a custom property called rotation. Then adding code to needsDisplayForKey to redraw the layer:
#NSManaged var rotation: CGFloat
override class func needsDisplayForKey(key: String) -> Bool {
if (key == "rotation") {
return true
}
return super.needsDisplayForKey(key)
}
This calls drawInContext and makes the whole CALayer black. I have a print in drawInContext and it doesn't get called when changing other properties like opacity, it seems it's not necessary to redraw the layer.
There should be a method that is being called each frame of the animation in CALayer where I could implement a function before applying the changes.
I'm also unable to find the right place to implement the functionality of the custom property that I've created to actually reflect the change even without animation. Let's say a property customOpacity that just inverts the effects of opacity and updating the layer accordingly. I only could make that through a function updateRotation(rotation: CGFloat).

How to resume animation from the stop point?

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")

Implicit CALayer animations for custom property in Swift

I've created a animatable Core Graphics drawing using PaintCode. It's basically a circle meter (not unlike the Apple Watch rings), which basically fills up as a timer counts down. The meterLevel controls the fill level of the circle meter, from 0 to 100. Basically, if a timer is set to 10 seconds, I set the meterLevel every 1 seconds to 90, 80, 70, etc...
This works good, however the animation is only drawn ever 1 second, and looks quite choppy. Instead, I'd like it be a smooth continuous filling meter.
Looking around it seemed like subclassing CALayer and creating an implicit animation for the meterLevel property might be the way to go. So here is what I have:
import UIKit
class MeterControlView: UIView
{
var meterLevel: Int = 0 {
didSet {
self.layer.setValue(meterLevel, forKey: "meterLevel")
}
}
var meterText: String = "00:00:00" {
didSet {
self.layer.setValue(meterText, forKey: "meterText")
}
}
override class func layerClass() -> AnyClass {
return MeterControlLayer.self
}
override func drawRect(rect: CGRect) {
// Do nothing
}
}
class MeterControlLayer: CALayer
{
#NSManaged
var meterLevel: Int
var meterText: String = "00:00:00"
override class func needsDisplayForKey(key: String) -> Bool {
if (key == "meterLevel") {
return true
}
return super.needsDisplayForKey(key)
}
override func actionForKey(key: String) -> CAAction? {
if (key == "meterLevel") {
let anim: CABasicAnimation = CABasicAnimation.init(keyPath: key)
anim.fromValue = self.presentationLayer()?.meterLevel
anim.duration = 1.0
return anim
} else {
return super.actionForKey(key)
}
}
override func drawInContext(ctx: CGContext) {
super.drawInContext(ctx)
UIGraphicsPushContext(ctx)
XntervalStyleKit.drawMeterControl(frame: self.bounds, meterTime: meterText, meterLevelValue: CGFloat(meterLevel))
UIGraphicsPopContext()
}
}
This unfortunately, doesn't work exactly the way I would expect. The animation is still a bit choppy, though closer to what I want.
My question is more general though, is this the right way to go about accomplishing what I want to do? I couldn't figure out the right way to set the layer properties meterLevel and meterText, without using setValueForKey:. Is that the right way to do this?
Animation/Graphics is definitely new to me. I'm an embedded C software guy trying to get into iOS development as a hobby.
The animation is still a bit choppy, though closer to what I want.
Given this, it seems as if Core Animation is actually drawing your layer every frame (or trying to, anyway).
Unfortunately, once you perform custom layer drawing each frame, your performance becomes main thread-bound: that is, normally, for properties that Core Animation can natively animate (such as bounds), the animation itself is rendered in the render server, which operates out-of-process from your application and has its own, high-priority render thread. The main thread of your application is free to do whatever it wants during these types of animation without any interruption to the animation.
drawInContext(_:), however, is called on the main thread of your application. If you put a log statement or breakpoint there, is it called many times over the course of your animation duration? If so, then the layer is properly animating. Your drawing operations within this function may be what's holding up the animation.
Try setting drawsAsynchronously to true on your layer. This defers drawing commands to a background thread, and it can improve performance sometimes. (Note that most, if not all, UIGraphics and Core Graphics functions are thread safe as of iOS 4, so background drawing is safe.)
Additionally, depending on how complex your animation is, you may want to draw several intermediate representations in advance (in the background and in parallel, if possible). Store these somewhere in memory if they aren't too large so you can simply display some of these bitmaps in your layer instead of drawing them just-in-time.
I wrote a UIView subclass called ConcentricProgressRingView which does something similar to what you're trying to do.
https://github.com/lionheart/ConcentricProgressRingView
Here's an example of what it looks like:
Usage
At the top of your UIViewController, import the module:
import ConcentricProgressRingView
Then, in your viewDidLoad:
let rings = [
ProgressRing(color: UIColor(.RGB(232, 11, 45)), backgroundColor: UIColor(.RGB(34, 3, 11))),
ProgressRing(color: UIColor(.RGB(137, 242, 0)), backgroundColor: UIColor(.RGB(22, 33, 0))),
ProgressRing(color: UIColor(.RGB(0, 200, 222)), backgroundColor: UIColor(.RGB(0, 30, 28)))
]
let progressRingView = try! ConcentricProgressRingView(center: view.center, radius: radius, margin: margin, rings: rings, defaultColor: UIColor.clearColor(), defaultWidth: 18)
view.addSubview(progressRingView)
Once you've instantiated your ConcentricProgressRingView instance, animate a specific ring to a percentage with setProgress.
ring.arcs[1].setProgress(0.5, duration: 2)
How it works
I'm not exactly sure what I'm doing differently from your example code in the question, but I'm creating a CABasicAnimation and setting a few parameters on it to tweak the animation behavior. The code is open source, so check it out. Hope this helps!

Resources