CALayer animation beginTime - ios

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()
}
}

Related

Swift: Longpress Button Animation

I have a Button that acts as an SOS Button. I would like to only accept longpresses on that button (something like two seconds long press) and animate the button while pressing to let the user know he has to longpress.
The Button is just a round Button:
let SOSButton = UIButton()
SOSButton.backgroundColor = Colors.errorRed
SOSButton.setImage(UIImage(systemName: "exclamationmark.triangle.fill"), for: .normal)
SOSButton.translatesAutoresizingMaskIntoConstraints = false
SOSButton.tintColor = Colors.justWhite
SOSButton.clipsToBounds = true
SOSButton.layer.cornerRadius = 25
SOSButton.addTarget(self, action: #selector(tappedSOSButton(sender:)), for: .touchUpInside)
SOSButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(SOSButton)
which looks something like this:
Now, when the button is getting long-pressed, I'd like to animate a stroke like a circular progress view. It would start from 0* and fill the whole circle to finally look like this:
I know it looks the same because the background is white, but there is a white stroke around it.
If the user lets go of the button before the circle fills up, it should animate back to zero in the same speed. If the user holds on long enough, only then should the action get executed.
How would I go about designing such a button? I have not found anything I can work off right now. I know I can animate stuff but animating while long-pressing seems like I'd need to implement something very custom.
Interested in hearing ideas.
You can create a custom class of UIView and add layer to it.
class CircularProgressBar: UIView {
private var circularPath: UIBezierPath = UIBezierPath()
var progressLayer: CAShapeLayer!
var progress: Double = 0 {
willSet(newValue) {
progressLayer?.strokeEnd = CGFloat(newValue)
}
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func layoutSubviews() {
super.layoutSubviews()
removeAllSubviewAndSublayers()
setupCircle()
}
private func removeAllSubviewAndSublayers() {
layer.sublayers?.forEach { $0.removeFromSuperlayer() }
subviews.forEach { $0.removeFromSuperview() }
}
func setupCircle() {
let x = self.frame.width / 2
let y = self.frame.height / 2
let center = CGPoint(x: x, y: y)
circularPath = UIBezierPath(arcCenter: center, radius: x, startAngle: -0.5 * CGFloat.pi, endAngle: 1.5 * CGFloat.pi, clockwise: true)
progressLayer = CAShapeLayer()
progressLayer.path = circularPath.cgPath
progressLayer.strokeColor = UIColor.white.cgColor
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineWidth = x/10
progressLayer.lineCap = .round
progressLayer.strokeEnd = 0
layer.addSublayer(progressLayer)
}
func addStroke(duration: Double = 2.0) {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = duration
animation.beginTime = CACurrentMediaTime()
progressLayer.add(animation, forKey: "strokeEnd")
}
func removeStroke(duration: Double = 0.0) {
let revAnimation = CABasicAnimation(keyPath: "strokeEnd")
revAnimation.duration = duration
revAnimation.fromValue = progressLayer.presentation()?.strokeEnd
revAnimation.toValue = 0.0
progressLayer.removeAllAnimations()
progressLayer.add(revAnimation, forKey: "strokeEnd")
}
}
In UIViewController create a UIImageView and CircularProgressBar. Set isUserInteractionEnabled to true of imageView and add progressView to it.
In viewDidLayoutSubviews() method set the frame of progressView equal to bounds of imageView. You also need to set Timer to execute action. Here is the full code.
class ViewController: UIViewController {
let imageView = UIImageView(image: UIImage(named: "icon_sos")!)
let progressView = CircularProgressBar()
var startTime: Date?
var endTime: Date?
var longPress: UILongPressGestureRecognizer?
var timer: Timer?
let longPressDuration: Double = 2.0
override func viewDidLoad() {
super.viewDidLoad()
longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressAction(_:)))
longPress?.minimumPressDuration = 0.01
imageView.frame = CGRect(x: 100, y: 100, width: 30, height: 30)
imageView.isUserInteractionEnabled = true
imageView.addGestureRecognizer(longPress!)
imageView.addSubview(progressView)
self.view.addSubview(imageView)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
progressView.frame = imageView.bounds
}
private func setupTimer() {
timer = Timer.scheduledTimer(timeInterval: self.longPressDuration, target: self, selector: #selector(fireTimer), userInfo: nil, repeats: false)
}
#objc private func fireTimer() {
longPress?.isEnabled = false
longPress?.isEnabled = true
progressView.removeStroke()
if let timer = timer {
timer.invalidate()
self.timer = nil
}
// execute button action here
print("Do something")
}
#objc private func longPressAction(_ sender: UILongPressGestureRecognizer) {
if sender.state == .began {
print("Long Press Began: ", Date())
startTime = Date()
self.progressView.addStroke(duration: self.longPressDuration)
setupTimer()
}
if sender.state == .changed {
print("Long Press Changed: ", Date())
}
if sender.state == .cancelled {
print("Long Press Cancelled: ", Date())
endTime = Date()
if let startTime = startTime, let endTime = endTime {
let interval = DateInterval(start: startTime, end: endTime)
progressView.removeStroke(duration: interval.duration.magnitude)
if interval.duration.magnitude < self.longPressDuration {
timer?.invalidate()
timer = nil
}
}
}
if sender.state == .ended {
print("Long Press Ended: ", Date())
endTime = Date()
if let startTime = startTime, let endTime = endTime {
let interval = DateInterval(start: startTime, end: endTime)
progressView.removeStroke(duration: interval.duration.magnitude)
if interval.duration.magnitude < self.longPressDuration {
timer?.invalidate()
timer = nil
}
}
}
}
}

CAEmitterLayer not timing correctly with CACurrentMediaTime() and sometimes not showing at all

I am currently making a particle emitter using CAEmitterLayer and ran into the issue of the layer preloading the animation when I start it and hence the particles all over the place when I show it.
Many answers have said the culprit is CAEmitterLayer being preloaded and we simply have to set its beginTime to CACurrentMediaTime() on the emitter.
See:
CAEmitterLayer emits random unwanted particles on touch events
Initial particles from CAEmitterLayer don't start at emitterPosition
iOS 7 CAEmitterLayer spawning particles inappropriately
For me this solution has not worked, when running it on device, iPad Air running iOS 12.1, the emitter often does not show and sometimes it is showed with a great delay.
To illustrate this issue I made a project on github:
https://github.com/roodoodey/CAEmitterLayer/tree/master/CAEmitterLayerApp
Here is the main code, I have 7 different images for particles chosen at random and a button to show the emitter when pressed.
import UIKit
class ViewController: UIViewController {
var particleImages = [UIImage]()
var emitter: CAEmitterLayer?
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
view.backgroundColor = UIColor.black
// Populate the random images array for the particles
for index in 1..<8 {
if let image = UIImage(named: "StarParticle00\(index)") {
particleImages.append(image)
}
}
// Button pressed to make the emitter emit the particles
let button = UIButton(frame: CGRect(x: view.frame.width * 0.5 - 60, y: view.frame.height * 0.5 - 40, width: 120, height: 80))
button.setTitle("Emit!", for: .normal)
button.setTitleColor(UIColor.white, for: .normal)
button.backgroundColor = UIColor.blue
button.addTarget(self, action: #selector(changeButton(sender:)), for: .touchDown)
button.addTarget(self, action: #selector(addEmitter(sender:)), for: .touchUpInside)
view.addSubview(button)
}
#objc func changeButton(sender: UIButton) {
sender.alpha = 0.5
}
#objc func addEmitter(sender: UIButton) {
sender.alpha = 1.0
// IF an emitter already exists remove it.
if emitter?.superlayer != nil {
emitter?.removeFromSuperlayer()
}
emitter = CAEmitterLayer()
emitter?.emitterShape = CAEmitterLayerEmitterShape.point
emitter?.position = CGPoint(x: self.view.frame.width * 0.5, y: self.view.frame.height * 0.5)
// So that the emitter starts now, and is not preloaded.
emitter?.beginTime = CACurrentMediaTime()
var cells = [CAEmitterCell]()
for _ in 0..<40 {
let cell = CAEmitterCell()
cell.birthRate = 1
cell.lifetime = 3
cell.lifetimeRange = 0.5
cell.velocity = 500
cell.velocityRange = 100
cell.emissionRange = 2 * CGFloat(Double.pi)
cell.contents = getRandomImage().cgImage
cell.scale = 1
cell.scaleRange = 0.5
cells.append(cell)
}
emitter?.emitterCells = cells
view.layer.addSublayer( emitter! )
}
func getRandomImage() -> UIImage {
let upperBound = UInt32(particleImages.count)
let randomIndex = Int(arc4random_uniform( upperBound ))
return particleImages[randomIndex]
}
}
Here is a short 20 second video of the app running on device, iPad Air running iOS 12.1, not run through xcode. https://www.dropbox.com/s/f9uol3yot67drm8/ScreenRecording_11-25-2018%2013-19-29.MP4?dl=0
If somebody could see if they can reproduce this issue or shed some light on this strange behavior it would be greatly appreciated.
I have a ton of experience with Core Animation although I have to admit not a lot with CAEmitterLayer. Everything looks right and for a person that knows CALayer pretty well, setting the beginTime with CACurrentMediaTime() makes sense. However, I ran your project and saw it was not working. For me setting the beginTime on the cell had the effect I would expect.
Meaning
//delay for 5.0 seconds
cell.beginTime = CACurrentMediaTime() + 5.0
cell.beginTime = CACurrentMediaTime() //immediate
cell.beginTime = CACurrentMediaTime() - 5.0 //5 seconds ago
Entire File
import UIKit
class ViewController: UIViewController {
var particleImages = [UIImage]()
var emitter: CAEmitterLayer?
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
view.backgroundColor = UIColor.black
// Populate the random images array for the particles
for index in 1..<8 {
if let image = UIImage(named: "StarParticle00\(index)") {
particleImages.append(image)
}
}
// Button pressed to make the emitter emit the particles
let button = UIButton(frame: CGRect(x: view.frame.width * 0.5 - 60, y: view.frame.height * 0.5 - 40, width: 120, height: 80))
button.setTitle("Emit!", for: .normal)
button.setTitleColor(UIColor.white, for: .normal)
button.backgroundColor = UIColor.blue
button.addTarget(self, action: #selector(changeButton(sender:)), for: .touchDown)
button.addTarget(self, action: #selector(addEmitter(sender:)), for: .touchUpInside)
view.addSubview(button)
}
#objc func changeButton(sender: UIButton) {
sender.alpha = 0.5
}
#objc func addEmitter(sender: UIButton) {
sender.alpha = 1.0
// IF an emitter already exists remove it.
if emitter?.superlayer != nil {
emitter?.removeFromSuperlayer()
}
emitter = CAEmitterLayer()
emitter?.emitterShape = CAEmitterLayerEmitterShape.point
emitter?.position = CGPoint(x: self.view.frame.width * 0.5, y: self.view.frame.height * 0.5)
// So that the emitter starts now, and is not preloaded.
var cells = [CAEmitterCell]()
for _ in 0..<40 {
let cell = CAEmitterCell()
cell.birthRate = 1
cell.lifetime = 3
cell.lifetimeRange = 0.5
cell.velocity = 500
cell.velocityRange = 100
cell.emissionRange = 2 * CGFloat(Double.pi)
cell.contents = getRandomImage().cgImage
cell.scale = 1
cell.scaleRange = 0.5
cell.beginTime = CACurrentMediaTime()
cells.append(cell)
}
emitter?.emitterCells = cells
view.layer.addSublayer( emitter! )
}
func getRandomImage() -> UIImage {
let upperBound = UInt32(particleImages.count)
let randomIndex = Int(arc4random_uniform( upperBound ))
return particleImages[randomIndex]
}
}

Fade image of button from one image to another via a float value

In my use case, I have a slider that moves from 0.0 to 1.0. When moving that slider I want a button's image to fade from one image to another smoothly. The button will only transition between two images. As one fades in, the other fades out and vice-versa.
There are answers on SO on how to change the image of a button over a fixed duration but I can't find something based on a changing float.
So is there a way to change the image of a button over a duration from one image to another based on a float that changes from 0.0 to 1.0?
EDIT:
For example, imagine a button that transitions smoothly from an image of a red star to a green star as you move the slider back and forth. (I know you could accomplish this other ways but I want to use two images)
Give this a shot. It is doing what you want I think. The trick is to set the layer speed to 0 and just update the timeOffset.
import UIKit
class ViewController: UIViewController {
lazy var slider : UISlider = {
let sld = UISlider(frame: CGRect(x: 30, y: self.view.frame.height - 60, width: self.view.frame.width - 60, height: 20))
sld.addTarget(self, action: #selector(sliderChanged), for: .valueChanged)
sld.value = 0
sld.maximumValue = 1
sld.minimumValue = 0
sld.tintColor = UIColor.blue
return sld
}()
lazy var button : UIButton = {
let bt = UIButton(frame: CGRect(x: 0, y: 60, width: 150, height: 150))
bt.center.x = self.view.center.x
bt.addTarget(self, action: #selector(buttonPress), for: .touchUpInside)
bt.setImage(UIImage(named: "cat1"), for: .normal)
return bt
}()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(button)
self.view.addSubview(slider)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
addAnimation()
}
func addAnimation(){
let anim = CATransition()
anim.type = kCATransitionFade
anim.duration = 1.0
button.layer.add(anim, forKey: nil)
button.layer.speed = 0
button.setImage(UIImage(named: "cat2"), for: .normal)
}
#objc func sliderChanged(){
print("value \(slider.value)")
button.layer.timeOffset = CFTimeInterval(slider.value)
}
#objc func buttonPress(){
print("pressed")
}
}
Here is the result:
For Swift 4
//This is slider
#IBOutlet weak var mSlider: UISlider!
//Add Target for valueChanged in ViewDidLoad
self.mSlider.addTarget(self, action: #selector(ViewController.onLuminosityChange), for: UIControlEvents.valueChanged)
//called when value change
#objc func onLuminosityChange(){
let value = self.mSlider.value
print(value)
if value > 0.5 {
btnImage.imageView?.alpha = CGFloat(value)
UIView.transition(with: self.btnImage.imageView!,
duration:0.3,
options: .showHideTransitionViews,
animations: { self.btnImage.setImage(newImage, for: .normal) },
completion: nil)
}else{
btnImage.imageView?.alpha = 1 - CGFloat(value)
UIView.transition(with: self.btnImage.imageView!,
duration:0.3,
options: .showHideTransitionViews,
animations: { self.btnImage.setImage(oldImage, for: .normal) },
completion: nil)
}
}
Or Inset an Action directly via Storyboard/XIB
This is another workaround, since i tired with single button but couldn't achieve the result, so what i did is i took two buttons overlaying each other, with same size, constraints and each with separate image.
var lastSlideValue : Float = 0.0
#IBOutlet weak var slideImage: UISlider!
#IBOutlet weak var btnImageOverlay: UIButton!
#IBOutlet weak var btnSecondOverlayImage: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
btnImageOverlay.imageView?.alpha = 1.0
btnSecondOverlayImage.imageView?.alpha = 0.0
}
#IBAction func sliderValueChanged(_ sender: Any) {//UIControlEventTouchUpInside connected method
let sliderr = sender as? UISlider
let value = sliderr?.value
if lastSlideValue < (sliderr?.value ?? 0.0) {
btnSecondOverlayImage.imageView?.alpha = CGFloat(value!)
btnImageOverlay.imageView?.alpha = 1 - CGFloat(value!)
} else if lastSlideValue == sliderr?.value {
print("equal")
} else {
btnImageOverlay.imageView?.alpha = CGFloat(value!)
btnSecondOverlayImage.imageView?.alpha = 1 - CGFloat(value!)
}
}
#IBAction func userTouchedSlider(_ sender: Any) { //UIControlEventValueChanged connected method
let sliderr = sender as? UISlider
lastSlideValue = (sliderr?.value)!
}
You could use cross fade animations with single button, but binding it with slider is difficult task. Let me know if you need any assistance with the same.

Running CAEmitterLayer only once

I want to run CAEmitterLayer only once, I was thinking of stopping birthRate but I can't do it. What I want is for it to run only once when tapping on the screen. I've been trying with delegates but I can't get it to work. And could you please tell me if my code is efficient.
import UIKit
import PlaygroundSupport
class Emitter {
static func get(with image: UIImage) -> CAEmitterLayer {
let emitter = CAEmitterLayer()
emitter.emitterShape = kCAEmitterLayerLine
emitter.emitterCells = generateEmitterCells(image: image)
print("emit")
emitter.setValue(0.0, forKey: "em")
return emitter
}
static func generateEmitterCells(image: UIImage) -> [CAEmitterCell] {
var cells = [CAEmitterCell]()
let cell = CAEmitterCell()
cell.contents = image.cgImage
cell.birthRate = 0.1
cell.lifetime = 20
cell.velocity = 250
cell.emissionRange = (10 * (.pi/180))
cell.scale = 0.9
cell.scaleRange = 0.3
cell.velocityRange = 100
cells.append(cell)
print("cells")
return cells
}
}
class ViewController : UIViewController{
override func viewDidLoad() {
super.viewDidLoad()
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
let view2 = UIView(frame: self.view.frame)
self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 580)
super.view.backgroundColor = UIColor.white
self.view.addSubview(view2)
}
#objc func handleTap() {
rain()
}
func rain() {
let emitter = Emitter.get(with: UIImage(named: "Group 1493")!)
emitter.emitterPosition = CGPoint(x: view.frame.width / 2, y: view.frame.height + 25)
emitter.emitterSize = CGSize(width: view.frame.width, height: 2)
self.view.layer.addSublayer(emitter)
}
}
let controller = ViewController()
PlaygroundPage.current.liveView = controller.view
You could set lifetime propertie to 0 after first splash
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
emitter.lifetime = 0
}
You need to do a few things.
You need to CAEmitterLayer's beginTime: emitter.beginTime = CACurrentMediaTime()
To stop new particles birth you need to set emitter.birthRate = 0. You can delay it using GCD to create this splash effect.

Core Animation - modify animation property

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

Resources