Smooth animation with timer and loop in iOS app - swift

I have ViewController with stars rating that looks like this (except that there are 10 stars)
When user opens ViewController for some object that have no rating I want to point user's attention to this stars with very simple way: animate stars highlighting (you could see such behaviour on some ads in real world when each letter is highlighted one after another).
One star highlighted
Two stars highlighted
Three stars highlighted
......
Turn off all of them
So this is the way how I am doing it
func delayWithSeconds(_ seconds: Double, completion: #escaping () -> ()) {
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
completion()
}
}
func ratingStarsAnimation() {
for i in 1...11 {
var timer : Double = 0.6 + Double(i)*0.12
delayWithSeconds(timer) {
ratingStars.rating = (i < 10) ? Double(i) : 0
}
}
}
What is going on here? I have function called delayWithSeconds that delays action and I use this function to delay each star highlighting. And 0.6 is initial delay before animation begins. After all stars are highlighted - last step is to turn off highlighting of all stars.
This code works but I can't say that it is smooth.
My questions are:
How can I change 0.6 + Double(i)*0.12 to get smooth animation feel?
I think that my solution with delays is not good - how can I solve smooth stars highlighting task better?

Have a look at the CADisplaylink class. Its a specialized timer that is linked to the refresh rate of the screen, on iOS this is 60fps.
It's the backbone of many 3rd party animation libraries.
Usage example:
var displayLink: CADisplayLink?
let start: Double = 0
let end: Double = 10
let duration: CFTimeInterval = 5 // seconds
var startTime: CFTimeInterval = 0
let ratingStars = RatingView()
func create() {
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .defaultRunLoopMode)
}
func tick() {
guard let link = displayLink else {
cleanup()
return
}
if startTime == 0 { // first tick
startTime = link.timestamp
return
}
let maxTime = startTime + duration
let currentTime = link.timestamp
guard currentTime < maxTime else {
finish()
return
}
// Add math here to ease the animation
let progress = (currentTime - startTime) / duration
let progressInterval = (end - start) * Double(progress)
// get value =~ 0...10
let normalizedProgress = start + progressInterval
ratingStars.rating = normalizedProgress
}
func finish() {
ratingStars.rating = 0
cleanup()
}
func cleanup() {
displayLink?.remove(from: .main, forMode: .defaultRunLoopMode)
displayLink = nil
startTime = 0
}
As a start this will allow your animation to be smoother. You will still need to add some trigonometry if you want to add easing but that shouldn't be too difficult.
CADisplaylink:
https://developer.apple.com/reference/quartzcore/cadisplaylink
Easing curves: http://gizma.com/easing/

Related

Swift - SCNAnimationPlayer setting duration cancels out timeOffset

I have an animation that I'm trying to start & end at specific places. I can set the start by setting the animationPlayer.animation.timeOffset, I'm also trying to set the animation to end about 20s after the timeOffset & I can do that by setting animationPlayer.animation.duration.
The problem that I'm facing is that setting the duration cancels out the timeOffset. If I use just .timeOffset I can get the animation to start from any position but as soon as duration is set the animation will play from the beginning.
The intended result would be this: The animation starts at 25s (timeOffset) runs for 20s (duration) & then loops back to the timeOffset.
let rootNode = sceneView.rootNode
rootNode.enumerateChildNodes { child, _ in
guard let animationPlayer = child.animationPlayer(forKey: key) else { return }
animationPlayer.animation.timeOffset = 25
animationPlayer.animation.duration = 20
animationPlayer.animation.autoreverses = true
animationPlayer.animation.isRemovedOnCompletion = false
}
The best solution I have found is something like this:
let player = model.animationPlayer(forKey: "all")
let animation = player?.animation
func restartAnimation(atTimeOffset timeOffset: TimeInterval, duration: TimeInterval) {
animation?.timeOffset = timeOffset
if isWalking {
player?.play()
let uuid = isWalkingUUID
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
guard uuid == self.isWalkingUUID else { return }
player?.stop(withBlendOutDuration: 0.2)
restartAnimation(atTimeOffset: timeOffset, duration: duration)
}
} else {
player?.stop(withBlendOutDuration: 0.2)
}
}
restartAnimation(atTimeOffset: 33, duration: 0.6)

Dispatch queue to animate SCNNode in ARKit

I'm facing an issue when trying to periodically animate my nodes on an ARSession. I'm fetching data from Internet every 5 seconds and then with that data I update this nodes (shrink or enlarge).
My code looks something like this:
Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { timer in
fetchDataFromServer() {
let fetchedData = $0
DispatchQueue.main.async {
node1.update(fetchedData)
node2.update(fetchedData)
node3.update(fetchedData)
}
if stopCondition { timer.invalidate() }
}
}
Problem is that when calling the updates I'm seeing a glitch in which the camera seems to freeze for a fraction of second and I see the following message in the console: [Technique] World tracking performance is being affected by resource constraints [1]
Update happens correctly, but the UX is really clumpsy if every 5 seconds I get these "short freezes"
I've tried creating a concurrent queue too:
let animationQueue = DispatchQueue(label: "animationQueue", attributes: DispatchQueue.Attributes.concurrent)
and call animationQueue.async instead of main queue but problem persists.
I'd appreciate any suggestions.
EDIT: Each of the subnodes on it's update method looks like this
private func growingGeometryAnimation(newHeight height: Float) -> CAAnimation{
// Change height
let grow = CABasicAnimation(keyPath: "geometry.height")
grow.toValue = height
grow.fromValue = prevValue
// .... and the position
let move = CABasicAnimation(keyPath: "position.y")
let newPosition = getNewPosition(height: height)
move.toValue = newPosition.y + (yOffset ?? 0)
let growGroup = CAAnimationGroup()
growGroup.animations = [grow, move]
growGroup.duration = 0.5
growGroup.beginTime = CACurrentMediaTime()
growGroup.timingFunction = CAMediaTimingFunction(
name: kCAMediaTimingFunctionEaseInEaseOut)
growGroup.fillMode = kCAFillModeForwards
growGroup.isRemovedOnCompletion = false
growGroup.delegate = self
return growGroup
}
self.addAnimation(growingGeometryAnimation(newHeight: self.value), forKey: "bar_grow_animation")
To make any updates to the scene use SCNTransaction, it makes sure all of the changes are made on the appropriate thread.
Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { timer in
fetchDataFromServer() {
let fetchedData = $0
SCNTransaction.begin()
node1.update(fetchedData)
node2.update(fetchedData)
node3.update(fetchedData)
SCNTransaction.commit()
if stopCondition { timer.invalidate() }
}
}

Changing the time interval of a Timer in swift spritekit

So, I have this timer that is setup to run a specific function (which are both shown below) on a time interval variable called 'frequency' when I try and change the timeinterval variable frequency to a lower number based on the score number it doesn't seem to change the rate at which it fires it just seems to fire at the same time even if the frequency is changed to a lower number
override func didMove(to view: SKView) {
Timer.scheduledTimer(timeInterval: frequency, target: self, selector: #selector(GameScene.spawnFallingOjects), userInfo: nil, repeats: true)
}
func spawnFallingOjects() {
if (GameState.current == .playing || GameState.current == .blackstone) {
guard usingThirdEye == false else { return }
let scoreLabel = childNode(withName: "scoreLabel") as! Score
let lane = [-100, -50 , 0, 50, 100]
let duration = 3.0
switch scoreLabel.number {
case 0...50:
frequency = 6.0
print("frequency has changed: \(frequency)")
case 51...100:
frequency = 4.5
print("frequency has changed: \(frequency)")
case 101...200000:
frequency = 1.1
print("frequency has changed: \(frequency)")
default:
return
}
let randomX = lane[Int(arc4random_uniform(UInt32(lane.count)))]
let object:Object = Object()
object.createFallingObject()
object.position = CGPoint(x: CGFloat(randomX), y: self.size.height)
object.zPosition = 20000
addChild(object)
let action = SKAction.moveTo(y: -450, duration: duration)
object.run(SKAction.repeatForever(action))
}
}
How do I make the timer fire faster when the frequency number changes to a lower number? should I recreate the timer at the end of the function?
You should actually avoid using Timer, Sprite kit has its own time functionality, and Timer does not work well with it and is a real pain to manage.
Instead, use SKAction's to wait and fire:
let spawnNode = SKNode()
override func didMove(to view: SKView) {
let wait = SKAction.wait(forDuration:frequency)
let spawn = SKAction.run(spawnFallingObjects)
spawnNode.run(SKAction.repeatForever(SKAction.sequence([wait,spawn])))
addChild(spawnNode)
}
Then to make it faster, just do:
switch scoreLabel.number {
case 0...50:
spawnNode.speed = 1
print("speed has changed: \(spawnNode.speed)")
case 51...100:
spawnNode.speed = 1.5
print("speed has changed: \(spawnNode.speed)")
case 101...200000:
spawnNode.speed = 2
print("speed has changed: \(spawnNode.speed)")
default:
return
}
The timeInterval property of Timer is a readonly property. (And your code is not trying to write a new frequency into the property...)
should I recreate the timer at the end of the function?
Nearly yes. Just you have no need to do it at the end.
With changing your method header like this:
func spawnFallingOjects(_ timer: Timer) {
You can access the fired Timer through the parameter timer, so you may need to write something like this just after switch scoreLabel.number {...}:
if frequency != timer.timeInterval {
//Invalidate old Timer...
timer.invalidate()
//And then allocate new one
Timer.scheduledTimer(timeInterval: frequency, target: self, selector: #selector(GameScene.spawnFallingOjects), userInfo: nil, repeats: true)
}
You can modify the fireDate property of an existing Timer (in case which still isValid), but recreating a Timer instance is not a heavy operation (comparing to creating an SKSpriteNode instance), so recreating a new Timer seems to be a little bit easier.

Change the value of a variable after a set amount of time in Swift

I am making a game similar to pitfall in Swift and I am trying to make a boolean that shows whether the player is jumping or not. I want the boolean to become false after 3 seconds so that the player moves down again. I have tried using a delay function but it didn't work.
Thanks in advance.
Try this:
let delay = 3 // seconds to wait before firing
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(delay)) {
// set your var here
}
Replace DispatchQueue.main with whichever queue you're using.
The follow code snippet describes Player objects that have a isJumping property. When it's set to true (using didSet), it automatically starts a timer that after 3 seconds resets isJumping to false.
Please not that the snippet makes use of a NSTimer extensions for comfortably starting and handling the timer. Credits to https://gist.github.com/natecook1000/b0285b518576b22c4dc8
class Player {
private var resetJumpingTimer: NSTimer?
var isJumping: Bool = false {
didSet {
resetJumpingTimer?.invalidate() // Stops the timer in case it was already started
if isJumping {
self.resetJumpingTimer = NSTimer.schedule(3.0) { [weak self] _ in
self?.isJumping = false
}
}
}
}
}
extension NSTimer {
class func schedule(delay delay: NSTimeInterval, handler: NSTimer! -> Void) -> NSTimer {
let fireDate = delay + CFAbsoluteTimeGetCurrent()
let timer = CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, fireDate, 0, 0, 0, handler)
CFRunLoopAddTimer(CFRunLoopGetCurrent(), timer, kCFRunLoopCommonModes)
return timer
}
}
After the player jumps, you create an NSTimer.
Declare global variables let timer = NSTimer() and var seconds = 3
Then after the player jumps you set the timer:
timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: #selector(YOUR_CLASS_NAME.updateTimer()), userInfo: nil, repeats: true)
Then the method:
func updateTimer() {
seconds -= 1
if seconds == 0 {
// Do stuff
timer.invalidate() // Stop the timer
seconds == 3 // Reset # of seconds
}
Give it a try.
func update() {
seconds -= 1
if seconds <= 0 {
score = 5
}
}
You may want to make it <= if you don't invalidate the timer. That way it stays the variable, etc.

How to make a smooth transition between drum patterns using AVAudioPlayer?

I am writing a music app which will let the user switch between different drum patterns "smoothly" by pressing desired buttons on screen. (By "smoothly" I mean the pattern will switch in the measure immediately following the time when the user presses a button).
My problem is that the time delay I am calculating for a delayed start of the next pattern is slightly larger than desired. I can fix by reducing the time delay by 0.1 sec, but this may not work if one uses a different tempo than the one I am currently using for testing.
The code which calculates the delay is:
func startClock() {
let aSelector : Selector = "updateClock"
clock = NSTimer.scheduledTimerWithTimeInterval(0.001, target: self, selector: aSelector, userInfo: nil, repeats: true)
startTime = CFAbsoluteTimeGetCurrent()
}
func stopClock() {
clock.invalidate()
}
func updateClock() {
currentTime = CFAbsoluteTimeGetCurrent()
elapsedTime = currentTime - startTime
elapsedBeats = UInt(elapsedTime / audioMeterUpdateInterval)
elapsedMeasures = UInt( Double(elapsedBeats) / Double(beatUnit[patternSelectIdx]) )
requiredMeasures = elapsedMeasures + 1
requiredBeats = requiredMeasures * UInt(beatUnit[patternSelectIdx])
requiredTime = Double(requiredBeats) * audioMeterUpdateInterval
delayTime = requiredTime - elapsedTime - 0.1 // 0.1 sec is chosen ad hoc
}
The code for one of the buttons for ending the drum patterns is:
#IBAction func endShort(sender: UIButton) {
fileName = patternSelect + "End1"
if startPlay == true {
play(fileName, numberOfLoops: 0, delaySec: delayTime)
} else {
play(fileName, numberOfLoops: 0, delaySec: 0)
}
playPauseButton.setImage(playImage, forState: UIControlState.Normal)
startPlay = false
}
Finally, the function play called by the above code is
func play(fileName: String, numberOfLoops: Int, delaySec: Double){
if delaySec > 0 {
let delayNSec = Double(NSEC_PER_SEC)*delaySec
let dispatchTime = dispatch_time(DISPATCH_TIME_NOW, Int64(delayNSec))
self.ButtonAudioPlayer.numberOfLoops = numberOfLoops
self.ButtonAudioPlayer.volume = 1.0
self.ButtonAudioURL = NSURL(fileURLWithPath: NSBundle.mainBundle().pathForResource(fileName, ofType: "wav")!)
dispatch_after(dispatchTime, dispatch_get_main_queue(),{
self.ButtonAudioPlayer = try! AVAudioPlayer(contentsOfURL: self.ButtonAudioURL)
self.ButtonAudioPlayer.play()
})
} else {
ButtonAudioPlayer.numberOfLoops = numberOfLoops
ButtonAudioPlayer.volume = 1.0
ButtonAudioURL = NSURL(fileURLWithPath: NSBundle.mainBundle().pathForResource(fileName, ofType: "wav")!)
ButtonAudioPlayer = try! AVAudioPlayer(contentsOfURL: ButtonAudioURL)
ButtonAudioPlayer.play()
}
}
Is there an alternate approach to this? Also, can the 0.1 sec be a fixed delay related to the button animation time and hence always remain fixed and can be modified? Thanks!