So I've got a background view with a gradient sublayer, animating continuously to change the colors slowly. I'm doing it with a CATransaction, because I need to animate other properties as well:
CATransaction.begin()
gradientLayer.add(colorAnimation, forKey: "colors")
// other animations
CATransaction.setCompletionBlock({
// start animation again, loop forever
}
CATransaction.commit()
Now I want to replicate this gradient animation, let's say, for the title of a button for instance.
Note 1: I can't just "make a hole" in the button, if such a thing is possible, because I might have other opaque views between the button and the background.
Note 2: The gradient position on the button is not important. I don't want the text gradient to replicate the exact colors underneath, but rather to mimic the "mood" of the background.
So when the button is created, I add its gradient sublayer to a list of registered layers, that the background manager will update as well:
func register(layer: CAGradientLayer) {
let pointer = Unmanaged.passUnretained(layer).toOpaque()
registeredLayers.addPointer(pointer)
}
So while it's easy to animate the text gradient at the next iteration of the animation, I would prefer that the button starts animating as soon as it's added, since the animation usually takes a few seconds. How can I copy the background animation, i.e. set the text gradient to the current state of the background animation, and animate it with the right duration left and timing function?
The solution was indeed to use the beginTime property, as suggested by #Shivam Gaur's comment. I implemented it as follows:
// The background layer, with the original animation
var backgroundLayer: CAGradientLayer!
// The animation
var colorAnimation: CABasicAnimation!
// Variable to store animation begin time
var animationBeginTime: CFTimeInterval!
// Registered layers replicating the animation
private var registeredLayers: NSPointerArray = NSPointerArray.weakObjects()
...
// Somewhere in our code, the setup function
func setup() {
colorAnimation = CABasicAnimation(keyPath: "colors")
// do the animation setup here
...
}
...
// Called by an external class when we add a view that should replicate the background animation
func register(layer: CAGradientLayer) {
// Store a pointer to the layer in our array
let pointer = Unmanaged.passUnretained(layer).toOpaque()
registeredLayers.addPointer(pointer)
layer.colors = colorAnimation.toValue as! [Any]?
// HERE'S THE KEY: We compute time elapsed since the beginning of the animation, and start the animation at that time, using 'beginTime'
let timeElapsed = CACurrentMediaTime() - animationBeginTime
colorAnimation.beginTime = -timeElapsed
layer.add(colorAnimation, forKey: "colors")
colorAnimation.beginTime = 0
}
// The function called recursively for an endless animation
func animate() {
// Destination layer
let toLayer = newGradient() // some function to create a new color gradient
toLayer.frame = UIScreen.main.bounds
// Setup animation
colorAnimation.fromValue = backgroundLayer.colors;
colorAnimation.toValue = toLayer.colors;
// Update background layer
backgroundLayer.colors = toLayer.colors
// Update registered layers (iterate is a custom function I declared as an extension of NSPointerArray)
registeredLayers.iterate() { obj in
guard let layer = obj as? CAGradientLayer else { return }
layer.colors = toLayer.colors
}
CATransaction.begin()
CATransaction.setCompletionBlock({
animate()
})
// Add animation to background
backgroundLayer.add(colorAnimation, forKey: "colors")
// Store starting time
animationBeginTime = CACurrentMediaTime();
// Add animation to registered layers
registeredLayers.iterate() { obj in
guard let layer = obj as? CAGradientLayer else { return }
layer.add(colorAnimation, forKey: "colors")
}
CATransaction.commit()
}
Related
I'm using UIViewPropertyAnimator to run an array interactive animations, and one issue I'm having is that whenever the I reverse the animations I can't run the animations back forward again.
I'm using three functions to handle the animations in conjunction with a pan gesture recognizer.
private var runningAnimations = [UIViewPropertyAnimator]()
private func startInteractiveTransition(gestureRecognizer: UIPanGestureRecognizer, state: ForegroundState, duration: TimeInterval) {
if runningAnimations.isEmpty {
animateTransitionIfNeeded(gestureRecognizer: gestureRecognizer, state: state, duration: duration)
}
for animator in runningAnimations {
animator.pauseAnimation()
animationProgressWhenInterrupted = animator.fractionComplete
}
}
private func animateTransitionIfNeeded(gestureRecognizer: UIPanGestureRecognizer, state: ForegroundState, duration: TimeInterval) {
guard runningAnimations.isEmpty else {
return
}
let frameAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1) {
switch state {
case .expanded:
// change frame
case .collapsed:
// change frame
}
}
frameAnimator.isReversed = false
frameAnimator.addCompletion { _ in
print("remove all animations")
self.runningAnimations.removeAll()
}
self.runningAnimations.append(frameAnimator)
for animator in runningAnimations {
animator.startAnimation()
}
}
private func updateInteractiveTransition(gestureRecognizer: UIPanGestureRecognizer, fractionComplete: CGFloat) {
if runningAnimations.isEmpty {
print("empty")
}
for animator in runningAnimations {
animator.fractionComplete = fractionComplete + animationProgressWhenInterrupted
}
}
What I've noticed is after I reverse the animations and then call animateTransitionIfNeeded, frameAnimator is appended to running animations however when I call updateInteractiveTransition immediately after and check runningAnimations, it's empty.
So I'm led to believe that this may have to do with how swift handles memory possibly or how UIViewAnimating completes animations.
Any suggestions?
I've come to realize the issue I was having the result of how UIViewPropertyAnimator handles layout constraints upon reversal.
I couldn't find much detail on it online or in the official documentation, but I did find this which helped a lot.
Animator just animates views into new frames. However, reversed or not, the new constraints still hold regardless of whether you reversed the animator or not. Therefore after the animator finishes, if later autolayout again lays out views, I would expect the views to go into places set by currently active constraints. Simply said: The animator animates frame changes, but not constraints themselves. That means reversing animator reverses frames, but it does not reverse constraints - as soon as autolayout does another layout cycle, they will be again applied.
Like normal you set your constraints and call view.layoutIfNeeded()
animator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1) {
[unowned self] in
switch state {
case .expanded:
self.constraintA.isActive = false
self.constraintB.isActive = true
self.view.layoutIfNeeded()
case .collapsed:
self.constraintB.isActive = false
self.constraintA.isActive = true
self.view.layoutIfNeeded()
}
}
And now, since our animator has the ability to reverse, we add a completion handler to ensure that the correct constraints are active upon completion by using the finishing position.
animator.addCompletion { [weak self] (position) in
if position == .start {
switch state {
case .collapsed:
self?.constraintA.isActive = false
self?.constraintB.isActive = true
self?.view.layoutIfNeeded()
case .expanded:
self?.constraintA.isActive = false
self?.constraintB.isActive = true
self?.view.layoutIfNeeded()
}
}
}
The animator operates on animatable properties of views, such as the frame, center, alpha, and transform properties, creating the needed animations from the blocks you provide.
This is the crucial part of the documentation.
You can properly animate:
frame, center, alpha and transform, so you would not be able to animate properly NSConstraints.
You should modify frames of views inside of addAnimations block
I have an MTKView whose contents I draw into a UIView. I want to swap display from MTKView to UIView without perceptible changes. How to achieve?
Currently, I have
let strokeCIImage = CIImage(mtlTexture: metalTextureComposite...) // get MTLTexture
let imageCropCG = cicontext.createCGImage(strokeCIImage...) // convert to CGImage
let layerStroke = CALayer() // create layer
layerStroke.contents = imageCropCG // populate with CGImage
strokeUIView.layer.addSublayer(layerStroke) // add to view
strokeUIView.layerWillDraw(layerStroke) //heads up to strokeUIView
and a delegate method within layerWillDraw() that clears the MTKView.
strokeViewMetal.metalClearDisplay()
The result is that I'll see a frame drop every so often in which nothing is displayed.
In the hopes of cleanly separating the two tasks, I also tried the following:
let dispatchWorkItem = DispatchWorkItem{
print("lyr add start")
self.pageCanvasImage.layer.addSublayer(sublayer)
print("lyr add end")
}
let dg = DispatchGroup()
DispatchQueue.main.async(group: dg, execute: dispatchWorkItem)
//print message when all blocks in the group finish
dg.notify(queue: DispatchQueue.main) {
print("dispatch mtl clear")
self.strokeCanvasMetal.setNeedsDisplay() // clear MTKView
}
The idea being add the new CALayer to UIImageView, and THEN clear the MTKView.
Over many screen draws, I think this result in fewer frame drops during the View swap, but I'd like a foolproof solution with NO drops. Basically what I'm after is to only clear strokeViewMetal once strokeUIView is ready to display. Any pointers would be appreciated
Synchronicity issues between MTKView and UIView are resolved for 99% of my tests when I set MTKView's presentsWithTransaction property to true. According to Apple's documentation:
Setting this value to true changes this default behavior so that your
MTKView displays its drawable content synchronously, using whichever
Core Animation transaction is current at the time the drawable’s
present() method is called.
Once that is done, the draw loop has to be modified from:
commandEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
to:
commandEncoder.endEncoding()
commandBuffer.commit()
commandBuffer.waitUntilScheduled() // synchronously wait until the drawable is ready
drawable.present() // call the drawable’s present() method directly
This is done to prevent Core activities to end before we're ready to present MTKView's drawable.
With all of this set up, I can simply:
let strokeCIImage = CIImage(mtlTexture: metalTextureComposite...) // get MTLTexture
let imageCropCG = cicontext.createCGImage(strokeCIImage...) // convert to CGImage
let layerStroke = CALayer() // create layer
layerStroke.contents = imageCropCG // populate with CGImage
// the last two events will happen synchronously
strokeUIView.layer.addSublayer(layerStroke) // add to view
strokeViewMetal.metalClearDisplay() // empty out MTKView
With all of this said, I do see overlapping of the views every now and then, but at a much, much lower frequency
I am simply trying to change the background color of the last element of a CALayer array. Here is my entire View Class, however its only 2-3 lines that I actually try to access the last element of the CALayer.
Here is my progressViewClass and I put comments to where exactly my problem is:
class ProgressBarView: UIView {
//Variables for progress bar
var holdGesture = UILongPressGestureRecognizer()
let animation = CABasicAnimation(keyPath: "bounds.size.width")
var layerHolder = [CALayer]()
var widthIndex = CGPoint(x: 0, y: 0)
var nextXOffset = CGFloat(0.0)
var checkIfFull = CGFloat()
var newLayer : CALayer?
var progressBarAnimationDuration : CFTimeInterval = (MainController.sharedInstance.totalMiliSeconsToRecord / 10)
// Only override draw() if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func draw(_ rect: CGRect) {
}
func startProgressBar(){
if(RecordingViewController().currentCameraMode == .recordingMode || RecordingViewController().currentCameraMode == .record1stClipMode) {
newLayer = CALayer()
newLayer?.frame = CGRect(x: nextXOffset, y: 0, width: 0, height: self.bounds.height)
newLayer?.backgroundColor = UIColor(red:0.82, green:0.01, blue:0.11, alpha:1.0).cgColor
//print("before \(nextXOffset)")
newLayer?.anchorPoint = widthIndex
animation.fromValue = 0
animation.toValue = self.bounds.width - nextXOffset
animation.duration = progressBarAnimationDuration - ((MainController.sharedInstance.miliSecondsPassed) / 10)
self.layer.addSublayer(newLayer!)
//print("Long Press Began")
newLayer?.add(animation, forKey: "bounds.size.width")
}
else{
stopProgressBar()
}
}
func stopProgressBar(){
if(RecordingViewController().currentCameraMode != .recordingMode){
pauseLayer(layer: newLayer!)
newLayer?.frame = (newLayer?.presentation()!.frame)!
nextXOffset = (newLayer?.frame.maxX)!
layerHolder.append(newLayer!)
print("Layerholder has elements : \(layerHolder.count)")
}
}
// HERE IS MY PROBLEM
func highlightLastLayer(){
print("in highlight last layer Layerholder has elements : \(layerHolder.count)")
// I CAN HIDE THE CALAYER SO I BELIEVE IM ACCESSING THE CORRECT LAYER
// layerHolder.last?.isHidden = true
// This is suppose to change the last element background color to blue but doesnt
layerHolder.last?.backgroundColor = UIColor.blue.cgColor
}
// ALSO MY PROBLEM
func unhighlightLastLayer(){
print("inside unhighlight last layer")
// I CAN HIDE THE CALAYER SO I BELIEVE IM ACCESSING THE CORRECT LAYER
//layerHolder.last?.isHidden = false
// Changes CALayer back to red
layerHolder.last?.backgroundColor = UIColor(red:0.82, green:0.01, blue:0.11, alpha:1.0).cgColor
}
//Function to pause the Progress Bar
func pauseLayer(layer : CALayer){
let pausedTime : CFTimeInterval = layer.convertTime(CACurrentMediaTime(), from: nil)
layer.speed = 0.0
layer.timeOffset = pausedTime
}
}
Simply put, I create a progressView object in my viewController and then call those functions based on certain button input. This view is essentially a progress bar that you'd see in many video recording applications to show how much you have recorded. In the highlightLastLayer, I am trying to grab the last element of the "layerHolder" array and change its color to blue. Simple right? Doesn't work. Any ideas?
Where are you calling highlight and unhighlight. I am pretty sure you are doing this when you are "stopped" or "paused" because thats the only time you add anything to the layerHolder Array. You can only do this when you are not animating because when you are animating the presentation layer is shown instead of the "real" layer. Instead of setting the speed to zero, setup your layer to look like the current animation state and call layer. removeAllAnimations to kill the presentation layer and show the actual layer for your view instead. Now you can make all the changes that you want and they will actually show up.
I created the method below as part of custom CAAnimationGroup. The method first adds itself to weak reference to a CALayer assigned at initialization.
Then it iterates over it's own animations array and applies each animation 's toValue to the associated keyPath using KVC on the weak CALayer reference.
final public class FAAnimationGroup : CAAnimationGroup {
weak var weakLayer : CALayer?
override init() {
super.init()
animations = [CAAnimation]()
fillMode = kCAFillModeForwards
removedOnCompletion = true
}
override public func copyWithZone(zone: NSZone) -> AnyObject {
let animationGroup = super.copyWithZone(zone) as! FAAnimationGroup
animationGroup.weakLayer = weakLayer
return animationGroup
}
......
func applyFinalState() {
guard let animationLayer = weakLayer else {
return
}
animationLayer.addAnimation(self, forKey: self.animationKey)
if let groupAnimations = animations {
for animation in groupAnimations {
if let toValue = animation.toValue {
animationLayer.setValue(toValue, forKeyPath: animation.keyPath!)
}
}
}
}
}
So everything works accordingly for bounds, size, transform, and alpha for all my views just as expected with the current removedOnCompletion flag and fillMode values.
Once the animation is complete, I query the UIView, and it's backing layer. What is see is the frame reflects the correct result, the view's alpha reflects the animated opacity value. Great!
But here comes the fun part. When animation the opacity of a UISlider from 0.0 to 1.0. Once the animation is complete, I begin to adjust the UISlider value, and right as I move it, the alpha goes back to 0.0.
I tried to set the removedCompletion flag to false, and as expected, keeping the animation around kept the layer in it's final state, but that is not what I wanted. I need it to remove itself after finishing, since I did set the values directly on the the backing layer.
So after setting the removedCompletion back to true, I tried the following which has me completely stumped leading up to my question....
.....
if let groupAnimations = animations {
for animation in groupAnimations {
if let toValue = animation.toValue {
if animation.keyPath! == "opacity" {
animationLayer.owningView()!.setValue(toValue, forKeyPath: "alpha")
} else {
animationLayer.setValue(toValue, forKeyPath: animation.keyPath!)
}
}
}
}
In the code above, I would, instead of setting opacity on the layer, I set the alpha value on the owningView associated with animating layer (aka the layer's delegate). In this instance everything worked accordingly, I adjusted the slider and it did not reset to alpha 0.0
The fact that this is happening only with a UISlider is possibly irrelevant. I thought that by setting the UIView's properties, the backing layer will reflect the equivalent, and I assumed the vice versa to also be true.
Question
Why are the final alpha/opacity values in sync when I set the alpha of the view, but not reflected when I set the opacity on it's backing layer? What is the relationship between UIView and CALayer in this specific example?
From what I understood the two are very intricately interlinked, the UIView is kind of a wrapper full of access to the backing layer which redraws itself accordingly. What is this opacity/alpha relationship in the context of animations?
I have implemented a class BubbleAnimator, that should create a bubble-like transition between views and added it through the UIViewControllerTransitioningDelegate-protocol. The presenting animation works fine so far (that's why I haven't added all the code for this part).
But on dismissing the view, the 'fromViewController' flashes up at the very end of the animation. After this very short flash, the correct toViewController is displayed again, but this glitch is very annoying.
The following is the relevant animateTransition-method:
//Get all the necessary views from the context
let containerView = transitionContext.containerView()
let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)
let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)
//Presenting
if self.reverse == false {
//Add the destinationvc as subview
containerView!.addSubview(fromViewController!.view)
containerView!.addSubview(toViewController!.view)
/*...Animating the layer goes here... */
//Dismissing
} else {
containerView!.addSubview(toViewController!.view)
containerView!.addSubview(fromViewController!.view)
//Init the paths
let circleMaskPathInitial = UIBezierPath(ovalInRect: self.originFrame)
let extremePoint = CGPoint(x: originFrame.origin.x , y: originFrame.origin.y - CGRectGetHeight(toViewController!.view.bounds) )
let radius = sqrt((extremePoint.x*extremePoint.x) + (extremePoint.y*extremePoint.y))
let circleMaskPathFinal = UIBezierPath(ovalInRect: CGRectInset(originFrame, -radius, -radius))
//Create a layer
let maskLayer = CAShapeLayer()
maskLayer.path = circleMaskPathFinal.CGPath
fromViewController!.view.layer.mask = maskLayer
//Create and add the animation
let animation = CABasicAnimation(keyPath: "path")
animation.toValue = circleMaskPathInitial.CGPath
animation.fromValue = circleMaskPathFinal.CGPath
animation.duration = self.transitionDuration(transitionContext)
animation.delegate = self
maskLayer.addAnimation(animation, forKey: "path")
}
The cleanup takes place in the delegate method:
override public func animationDidStop(anim: CAAnimation, finished flag: Bool) {
self.transitionContext?.completeTransition(!(self.transitionContext?.transitionWasCancelled())!)
}
I guess, that I am doing something wrong with adding the views to the containerView, but I couldn't figure it out. Another possibility is, that the view's layer mask gets reset, when the function completeTransition is called.
Thanks to this blogpost I have finally been able to solve this problem. Short explanation:
The CAAnimation only manipulates the presentation-layer of the view, but does not change the model-layer. When the animation now finishes, it's value snaps back to the original and unchanged value of the model-layer.
Short and simply workaround:
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
The better solution, as it doesn't prevent the animation from being removed is to manually set the final value of the layer's position before the animation starts. This way, the model-layer is assigned the correct value:
maskLayer.path = circleMaskPathInitial.CGPath
//Create and add the animation below..