Animation doesn't repeat correctly in Swift - swift

#objc private func updateCountdownLabel(_ notification: NSNotification) {
self.progressWidthAnchor.constant = 0
if let timeRemaining = notification.userInfo?["timeRemaining"] as? Int {
self.secondsRemaining = timeRemaining
self.animateProgress(width: 100, duration: 10)
}
}
private func animateProgress(width: CGFloat, duration: Int) {
self.layoutIfNeeded()
UIView.animate(withDuration: TimeInterval(duration),
delay: TimeInterval(LocalConstants.animationDelayDuration),
options: .curveLinear) {
self.progressWidthAnchor.constant = width
self.layoutIfNeeded()
}
}
I'm having a weird issue with my animation. At the moment I am trying to animate a bar decreasing/increasing but I need it to repeat every second.
For some reason it doesn't repeat the animation, the constraint jumps outside the view.
It works the first time, but the second time it doesn't work as you'd expect.
No constraint warnings are thrown.
Moving code around,
updating the layoutIfNeeded to various locations

The cause of my animation not working correctly was due to
removeAllAnimations() not being called prior to the next UIView.animate code blo
Figured it out
Calling removeAllAnimations before executing the code again fixed my issue

Related

When I scroll my UIScrollView, animations that are connected to a timer and animate views inside of the scrollview, suddenly stop ticking

I have a UIScrollView containing views, which are connected to timers that call a function every x seconds. Everything works perfectly, until I start scrolling the scroll view, upon which the timers stop ticking, meaning that the animations stop happening. I do not know if this is clear enough, but I will show you some code below to try to clarify.
#objc func lowBeatingAnimation(){
for i in lowWindow{
let List = i as? [Any] ?? []
let View = List[0] as! UIView
let width = List[1] as! NSLayoutConstraint
let height = List[2] as! NSLayoutConstraint
let label = List[3] as! UILabel
self.view.layoutIfNeeded()
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseIn, animations: {
View.layer.shadowRadius = 50
width.constant += -20
height.constant += -20
label.alpha = 0.65
View.layer.cornerRadius += -10
self.view.layoutIfNeeded()
}, completion: { finished in
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: {
View.layer.shadowRadius = 10
width.constant += 20
View.layer.cornerRadius += 10
label.alpha = 0.85
height.constant += 20
self.view.layoutIfNeeded()
}, completion: { finished in
})
})
}
}
That is the function I call every second. lowWindow is an array, consisting of arrays with the following format: [UIView, NSLayoutConstraint (belonging to the first element in the list), NSLayoutConstraint (also belonging to the first element in the list), UILabel]
The first element in lowWindow is a UIView, which is a subview of the scroll view, which causes the animation to stop whenever it gets scrolled.
I think the issue can be boiled down to the following question, although I am not entirely sure: Why does an external timer stop working whenever the position of the scrollview gets edited?
I have also tried different things in terms of whether the views that get animated are direct subviews of the scrollview, or if they are subviews of a subview of the scrollview. Nothing works so far. If you have any ideas on how to solve this and would like to share it, that would be highly appreciated. Thanks.
I solved this by adding the timers to Runloop.
RunLoop.main.add(lowTimer, forMode: .common)
RunLoop.main.add(mediumTimer, forMode: .common)
RunLoop.main.add(highTimer, forMode: .common)

UIViewPropertyAnimator AutoLayout Completion Issue

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

Swift4 Animation using Timer.scheduledTimer

I'm animating a clock hand that takes a CGFloat value from 0 to 1. While I have the animation, I would like it to be a lot smoother. The total animation takes 5 seconds, as part of an input variable. How can I make this a lot smoother?
Ideally, I'd like to get all the values from 0 to 1 in 5 seconds...
The clock hand does a complete 360 but is a little choppy
#IBAction func start(_ sender: Any) {
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(launchTimer), userInfo: nil, repeats: true)
launchTimer()
}
func launchTimer() {
guard seconds < 4.9 else {
timer.invalidate()
seconds = 0
return
}
seconds += 0.1
clockView.currentPressure = CGFloat(seconds / 5)
clockView.setNeedsDisplay()
}
EDIT
import UIKit
class GaugeView: UIView {
var currentPressure : CGFloat = 0.0
override func draw(_ rect: CGRect) {
StyleKitName.drawGauge(pressure: currentPressure)
}
}
Timer is not appropriate for animations on this scale. 100ms isn't a good step in any case, since it's not a multiple of the frame rate (16.67ms). Generally speaking, you shouldn't try to hand-animate unless you have a specialized problem. See UIView.animate(withDuration:...), which is generally how you should animate UI elements, allowing the system to take care of the progress for you.
For a slightly more manual animation, see CABasicAnimation, which will update a property over time. If you need very manual control, see CADisplayLink, but you almost never need this.
In any case, you must never assume that any timer is called precisely when you ask it to be. You cannot add 0.1s to a value just because you asked to be called in 0.1s. You have to look at what time it really is. Even hard-real-time systems can't promise something will be called at a precise moment. The best you can possibly get is a promise it will be within some tolerance (and iOS doesn't even give you that).
To animate this with UIView (which I recommend), it'll probably be something like:
#IBAction func start(_ sender: Any) {
self.clockView.currentPressure = 0
UIView.animate(withDuration: 5, animations: {
self.clockView.currentPressure = 1
})
}
With a CABasicAnimation (which is more complicated) it would be something like:
currentPressure = 1 // You have to set it to where it's going or it'll snap back.
let anim = CABasicAnimation(keyPath: "currentPressure")
anim.fromValue = 0
anim.toValue = 1
anim.duration = 5
clockView.addAnimation(anim)
Make the time interval smaller to make the animation smoother. That way it will seem like it's gliding around instead of jumping between values.
You can also use spritekit:
import SpriteKit
let wait = SKAction.wait(forDuration: 0.01)
let runAnim = SKAction.run {
launchTimer()
}
let n = SKNode()
n.run(SKAction.repeat(SKAction.sequence([wait, runAnim]), count: 500))

Swift animation of constraint not working

The below function is just moving the view to a new place. It isn't showing the animation:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 0.9, animations: {
//self.leadingConst.constant -= 200
self.view.setNeedsLayout()
})
}
self.leadingConst.constant -= 200 should be outside UIView.animate, like this:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.leadingConst.constant -= 200
UIView.animate(withDuration: 0.9, animations: {
self.view.setNeedsLayout()
})
}
reference: trying to animate a constraint in swift
A few weeks ago I ran into the same problem. The problems came down to a few things. I was mixing adding views programmatically and adding them to the storyboard, I wasn't calling layoutSubviews(), and I was adding my constraints as IBOutlets as weak vars. So I would suggest to only use storyboard (otherwise animations start to get complicated quickly) and make your leadingConst constraint a var. You should also move the object that you want to animate in the animation block and then use a completion block to reset the constraint.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
view.layoutSubviews()
UIView.animate(withDuration: 0.9, animations: {
//something like this
self.animatableObject.origin.x -= 200
}, completion: {(finished: Bool) in
self.leadingConst.constant -= 200
})
}
I'm not sure what your trying to animate so the origin.x line is just an example.
2023 answer
The correct solution was given in a comment, you need to call layoutIfNeeded()
A typical example
UIView.animate(withDuration: t) { [weak self] in
guard let self = self else { return }
self.bottom.constant = -H
self.superview!.layoutIfNeeded()
}
IMPORTANT: "IN OR OUT" IS THE SAME
somewhat bizarrely, it makes NO difference if you do:
this
UIView.animate(withDuration: t) {
self.bottom.constant = -66
self.superview!.layoutIfNeeded()
}
or this
self.bottom.constant = -66
UIView.animate(withDuration: t) {
self.superview!.layoutIfNeeded()
}
There are 1000 totally incorrect comments on SO that you have to do it one way or the other to "mak eit work". It has nothing to do with the issue.
WHY THE SOLUTION IS layoutIfNeeded
It was perfectly explained in the #GetSwifty comment.
layoutIfNeeded is when the actual animation takes place. setNeedsLayout just tells the view it needs to layout, but doesn't actually do it.
WHY THE ISSUE IS CONFUSING: IT "SOMETIMES" WORKS ANYWAY
Often you actually do not need to explicitly have layoutIfNeeded. This causes lots of confusion. ie, this will work "sometimes" ...
UIView.animate(withDuration: t) {
self.bottom.constant = -66
}
There are particular things to consider:
If you are animating "the view itself" (ie likely in a custom UIView subclass), the cycle can be different from when you are animating some view below you "from above"
If you have other animations going on, that can affect the view cycle.
Note that if it "magically works" without layoutIfNeeded, it may NOT work other times in your app, depending on what's going on in the app.
In short you must always add layoutIfNeeded, that's all there is to it.
setNeedsLayout wont work, you need to layoutSubviews().
UIView.animate(withDuration: 0.2,
delay: 0.0,
animations: {
self.myConstraint.constant = value
self.view.layoutSubviews()
}

Simple Action Loop

I am trying to repeat a set of actions by running them through a loop. My loop is fine when running something else, but it seems to have trouble running the actions. My code is as follows:
let pulse1 = SKAction.scaleTo(2.0, duration: 1.0)
let pulse2 = SKAction.scaleTo(0.5, duration: 1.0)
var i = 0
override func didMoveToView(view: SKView) {
for var i = 0; i <= 100; i++ {
self.sun.runAction(pulse1)
self.sun.runAction(pulse2)
}
This will cause the node to pulse1 and pulse2 each once but never again. If I add
println("")
to the loop, it runs whatever text properly, but for some reason doesn't run the actions like it runs the text. Or maybe it does and I don't understand how SKAction works? Either way, the loop is executing properly, I believe. I am not quite sure what's wrong with the SKAction call in the loop.
scaleTo simply changes the node's scale. Once pulse1 goes to 2.0 and pulse2 gets to 0.5, runAction runs repeatedly but you never change the scale for either pulse ever again.
That's why you're only seeing it do work the first time.
Instead of using a for loop, try something like this:
override func didMoveToView(view: SKView) {
if (i % 2 == 0) {
let pulse = SKAction.scaleTo(2.0, duration: 1.0)
} else {
let pulse = SKAction.scaleTo(0.5, duration: 1.0)
}
[self.sun runAction:pulse completion:^{
if( i < 100 )
{
didMoveToView(view);
}
}];
}
Maybe you can use
class func repeatAction(_ action: SKAction,
count count: Int) -> SKAction
Put as many single actions in a sequence an run repeatAction for x times.