SwiftUI: Implement a simple timing slider - swift

I have a line slider that looks like this:
Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 200 * (timeRemaining), y: 0))
}
.stroke(Color.orange, lineWidth: 4).opacity(0.5)
The timeRemaining will go from 2s, 1s, 0s making the line slider to decrease from right to left. How can I change this implementation so that the slider will decrease more like this:
400,399,398...2,1,0
which will make the line slider looks smoother.
Here is the timer declaration:
#State var timeRemaining = 2
#State var timer: Timer.TimerPublisher = Timer.publish (every: 1,
on: .main, in: .common)
.onReceive(timer) { time in
if timeRemaining > 0 {
timeRemaining -= 1
}
I think I need to make some changes in the onReceive part.

To have a finer granularity and a smoother animation you simply need to scale down the update interval. Since 2 / 400 is 0.005 you should use this for your timer and your count down
#State var timeRemaining = 2.0 // Use Double
#State var timer: Timer.TimerPublisher = Timer.publish (every: 0.005, on: .main, in: .common)
...
.onReceive(timer) { time in
if timeRemaining > 0 {
timeRemaining -= 0.005
}

Related

Changing duration for Slider animation doesn't make any difference

I'm trying to animate Slider smoothly. My original problem was animating it for 1 second every second for a small value (building an audio player scrubber), but the animation was a split second instead of a full second.
To isolate a problem, I built a playground where I'm trying to animate Slider change from 0 to maxValue for maxValue number of seconds. However, whatever maxValue is, the animation happens in a fraction of a second.
Here is the code:
struct SliderTest: View {
#State private var sliderValue = 0.0
let maxValue = 30.0
var body: some View {
VStack(spacing: 10) {
Slider(value: $sliderValue, in: 0...maxValue)
Button("Animate Slider") {
withAnimation(.linear(duration: maxValue-sliderValue)) {
sliderValue = maxValue
}
}
Button("Reset to Random") {
sliderValue = Double.random(in: 0..<maxValue)
}
Button("Reset") {
sliderValue = 0
}
}
}
}
Here you can get the code with a preview for Swift Playgrounds:
https://gist.github.com/OgreSwamp/6e6423d6ef2d26425e3f993042ac208d
First of all, animation is not working here because when it comes into withAnimation it will assign sliderValue to maxValue immediately. That causes the reason above like you said.
For the problem, I will separate the slider just for change UI depends on sliderValue only. Then building sliderTimer for handle the change value of slider and everytime the timer is excuted it will automatically increase sliderValue by step.
The code will be like this
struct ContentView: View {
#State private var sliderValue = 0.0
private var maxValue = 30.0
private var sliderTimeAnimation = 2.0
private var step = 1.0
#State private var sliderTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 10) {
Slider(
value: $sliderValue,
in: 0...maxValue
).onReceive(sliderTimer) {
_ in
sliderValue += step
if (sliderValue > maxValue) {
stopTimer()
}
}
.onAppear() {
self.stopTimer()
}
Button("Animate Slider") {
startTimer()
}
Button("Reset to Random") {
stopTimer()
sliderValue = Double.random(in: 0..<maxValue)
}
Button("Reset") {
stopTimer()
sliderValue = 0
}
}
}
func startTimer() {
self.sliderTimer = Timer.publish(every: sliderTimeAnimation * step / maxValue, on: .main, in: .common).autoconnect()
}
func stopTimer() {
self.sliderTimer.upstream.connect().cancel()
}
}
In this case, I set sliderTimeAnimation is 2.0, step is 1.0 (if you need faster animation you can set step is 0.5 or lower) and the max value is 30.0 like your code.
The result is like this.

Fill the CALayer with different color when timer reaches to 5 seconds swift ios

In my app i am using a third party library which does circular animation just like in the appstore app download animation. i am using the external file which i have placed in my project. it works but when the timer reaches to 5 seconds the fill color should be red. Currently the whole layer red color applies, i want to only apply the portion it has progressed. i am attaching the video and the third party file. Please can anyone help me with this as what changes should i make to the library file or is there better solution
same video link in case google drive link doesnt work
External Library link which i am using
video link
external third file file
My code snippet that i have tried:
func startGamePlayTimer(color: UIColor)
{
if !isCardLiftedUp {
self.circleTimerView.isHidden = false
self.circleTimerView.timerFillColor = color
Constants.totalGamePlayTime = 30
self.circleTimerView.startTimer(duration: CFTimeInterval(Constants.totalGamePlayTime))
}
if self.gamePlayTimer == nil
{
self.gamePlayTimer?.invalidate()
let timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(updateGamePlayTime),
userInfo: nil,
repeats: true)
timer.tolerance = 0.05
RunLoop.current.add(timer, forMode: .common)
self.gamePlayTimer = timer
}
}
#objc func updateGamePlayTime()
{
if Constants.totalGamePlayTime > 0
{
Constants.totalGamePlayTime -= 1
if Constants.totalGamePlayTime < 5
{
SoundService.playSound(sound: .turn_timeout)
self.circleTimerView.timerFillColor = UIColor.red.withAlphaComponent(0.7)
}
}
else
{
self.stopGamePlayTimer()
}
}
where circleTimerView is the view which animates as the time progresses
You can achieve that with CAKeyframeAnimation on the StrokePath.
I've Update the code in CircleTimer View as below. You can modified fill color and time as per your need.
#objc func updateGamePlayTime()
{
if Constants.totalGamePlayTime > 0
{
Constants.totalGamePlayTime -= 1
if Constants.totalGamePlayTime < 5
{
SoundService.playSound(sound: .turn_timeout)
// self.circleTimerView.timerFillColor = UIColor.red.withAlphaComponent(0.7) // <- Comment this line
}
}
else
{
self.stopGamePlayTimer()
}
}
open func drawFilled(duration: CFTimeInterval = 5.0) {
clear()
if filledLayer == nil {
let parentLayer = self.layer
let circleLayer = CAShapeLayer()
circleLayer.bounds = parentLayer.bounds
circleLayer.position = CGPoint(x: parentLayer.bounds.midX, y: parentLayer.bounds.midY)
let circleRadius = timerFillDiameter * 0.5
let circleBounds = CGRect(x: parentLayer.bounds.midX - circleRadius, y: parentLayer.bounds.midY - circleRadius, width: timerFillDiameter, height: timerFillDiameter)
circleLayer.fillColor = timerFillColor.cgColor
circleLayer.path = UIBezierPath(ovalIn: circleBounds).cgPath
// CAKeyframeAnimation: Changing Fill Color on when timer reaches 80% of total time
let strokeAnimation = CAKeyframeAnimation(keyPath: "fillColor")
strokeAnimation.keyTimes = [0, 0.25, 0.75, 0.80]
strokeAnimation.values = [timerFillColor.cgColor, timerFillColor.cgColor, timerFillColor.cgColor, UIColor.red.withAlphaComponent(0.7).cgColor]
strokeAnimation.duration = duration;
circleLayer.add(strokeAnimation, forKey: "fillColor")
parentLayer.addSublayer(circleLayer)
filledLayer = circleLayer
}
}
open func startTimer(duration: CFTimeInterval) {
drawFilled(duration: duration) // <- Need to pass duration here
if useMask {
runMaskAnimation(duration: duration)
} else {
runDrawAnimation(duration: duration)
}
}
UPDATE:
CAKeyframeAnimation:
You specify the keyframe values using the values and keyTimes properties.
For example:
let colorKeyframeAnimation = CAKeyframeAnimation(keyPath: "backgroundColor")
colorKeyframeAnimation.values = [UIColor.red.cgColor,
UIColor.green.cgColor,
UIColor.blue.cgColor]
colorKeyframeAnimation.keyTimes = [0, 0.5, 1]
colorKeyframeAnimation.duration = 2
This animation will run for the 2.0 duration.
We have 3 values and 3 keyTimes in this example.
0 is the initial point and 1 be the last point.
It shows which associated values will reflect at particular interval [keyTimes] during the animation.
i.e:
KeyTime 0.5 -> (2 * 0.5) -> At 1 sec during animation -> UIColor.green.cgColor will be shown.
Now to answer your question from the comments, Suppose we have timer of 25 seconds and we want to show some value for last 10 seconds, we can do:
strokeAnimation.keyTimes = [0, 0.25, 0.50, 0.60]
strokeAnimation.values = [timerFillColor.cgColor,timerFillColor.cgColor, timerFillColor.cgColor, timerFillColor.cgColor, UIColor.red.withAlphaComponent(0.7).cgColor]
strokeAnimation.duration = 25.0 // Let's assume duration is 25.0

Restarting perpetual animation after stopping in SwiftUI

Background
In this learning app, I've followed and excellent tutorial from Hacking with Swift on generating a wave-like animation. I've modified this app further adding some functionalities:
Providing Start/Stop mechanism for the wave animation
Perpetually generating random numbers for the duration of the animation
Modifying animation if an "interesting" number is found. Initially, I've implemented logic that defines even numbers as interesting but that could be easily changes to flag prime numbers, etc.
Problem
After stopping the animation does not "run" again. This is demonstrated in the gif below.
After stopping the animation does not restart.
Code
//
// ContentView.swift
// WaveExample
//
// Created by Konrad on 28/07/2021.
// Original tutorial: https://www.hackingwithswift.com/plus/custom-swiftui-components/creating-a-waveview-to-draw-smooth-waveforms
//
import SwiftUI
/**
Creates wave shape object
- Parameter strength: How tall the wave should be
- Parameter frequency: How densly the wave should be packed
- returns: Shape
*/
struct Wave: Shape {
// Basic wave characteristics
var strength: Double // Height
var frequency: Double // Number of hills
var phase: Double // Offsets the wave, can be used to animate the view
// Required to define that animation relates to moving the wave from left to right
var animatableData: Double {
get { phase }
set { self.phase = newValue }
}
// Path drawing function
func path(in rect: CGRect) -> Path {
let path = UIBezierPath()
// Basic waveline characteristics
let width = Double(rect.width)
let height = Double(rect.height)
let midWidth = width / 2
let midHeight = height / 2
let wavelength = width / frequency
let oneOverMidWidth = 1 / midWidth
// Path characteristics
path.move(to: CGPoint(x: 0, y: midHeight))
// By determines the nmber of calculations, can be decreased to run faster
for xPosition in stride(from: 0, through: width, by: 1) {
let relativeX = xPosition / wavelength // How far we are from the start point
let distanceFromMidWidth = xPosition - midWidth // Distance from the middle of the space
let normalDistance = distanceFromMidWidth * oneOverMidWidth // Get values from -1 to 1, normalize
// let parabola = normalDistance // Small waves in the middle
let parabola = -(normalDistance * normalDistance) + 1 // Big wave in the middle
let sine = sin(relativeX + phase) // Offset based on phase
let yPosition = parabola * strength * sine + midHeight // Moving this halfway
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
return Path(path.cgPath)
}
}
struct Line: Shape {
func path(in rect: CGRect) -> Path {
// Positioning
let midHeight = rect.height / 2
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: midHeight))
path.addLine(to: CGPoint(x: rect.width, y: midHeight))
return Path(path.cgPath)
}
}
struct ContentView: View {
#State private var phase = 0.0 // Used to animate the wave
#State private var waveStrength: Double = 10.0 // How tall, change for interesting numbers
#State private var waveFrequency: Double = 10.0 // How frequent, change for interesting numbers
#State var isAnimating: Bool = false // Currently running animation
#State private var randNum: Int16 = 0 // Random number to keep generating while animating
#State private var isNumberInteresting: Bool = false // Will take 'true' of the random number has some interesting properties
// Timer publisher reflecting frequent animation changes
#State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
// Stop timer
func stopTimer() {
self.timer.upstream.connect().cancel()
}
// Start timer
func startTimer() {
self.timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
}
// Check if number is interesting
func checkNumber(num: Int16) -> Bool {
var isInteresting: Bool = false
if num % 2 == 0 {
isInteresting.toggle()
}
return isInteresting
}
var body: some View {
VStack {
if self.isAnimating {
VStack {
Button("Stop") {
self.isAnimating = false
stopTimer()
}
.font(.title)
.foregroundColor(Color(.blue))
Text("Random number: \(String(randNum)), interesting: \(String(isNumberInteresting))")
.onReceive(timer, perform: { _ in
randNum = Int16.random(in: 0..<Int16.max)
isNumberInteresting = checkNumber(num: randNum)
})
}
} else {
Button("Start") {
self.isAnimating = true
startTimer()
}
.font(.title)
.foregroundColor(Color(.red))
}
if self.isAnimating {
// Animation
ZStack {
ForEach(0..<10) { waveIteration in
Wave(strength: waveStrength, frequency: waveFrequency, phase: phase)
.stroke(Color.blue.opacity(Double(waveIteration) / 3), lineWidth: 1.1)
.offset(y: CGFloat(waveIteration) * 10)
}
}
.onReceive(timer) { _ in
// withAnimation requires info on how to animate
withAnimation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) {
self.phase = .pi * 2 // 180 degrees of sine being calculated
if isNumberInteresting {
waveFrequency = 50.0
waveStrength = 50.0
} else {
waveFrequency = 10.0
waveStrength = 10.0
}
}
}
.frame(height: UIScreen.main.bounds.height * 0.8)
} else {
// Static line
ZStack {
Line()
.stroke(Color.blue)
}
.frame(height: UIScreen.main.bounds.height * 0.8)
}
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Side notes
In addiction to the problem above any good practice pointers on working with Swift are always welcome.
I made your project works, you can see the changed code // <<: Here!, the issue was there that you did not show the Animation the changed value! you showed just one time! and after that you keep it the same! if you see your code in your question you are repeating self.phase = .pi * 2 it makes no meaning to Animation! I just worked on your ContentView the all project needs refactor work, but that is not the issue here.
struct ContentView: View {
#State private var phase = 0.0
#State private var waveStrength: Double = 10.0
#State private var waveFrequency: Double = 10.0
#State var isAnimating: Bool = false
#State private var randNum: Int16 = 0
#State private var isNumberInteresting: Bool = false
#State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State private var stringOfText: String = String() // <<: Here!
func stopTimer() {
self.timer.upstream.connect().cancel()
phase = 0.0 // <<: Here!
}
func startTimer() {
self.timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.milliseconds(500)) { phase = .pi * 2 } // <<: Here!
}
func checkNumber(num: Int16) -> Bool {
var isInteresting: Bool = false
if num % 2 == 0 {
isInteresting.toggle()
}
return isInteresting
}
var body: some View {
VStack {
Button(isAnimating ? "Stop" : "Start") { // <<: Here!
isAnimating.toggle() // <<: Here!
isAnimating ? startTimer() : stopTimer() // <<: Here!
}
.font(.title)
.foregroundColor(isAnimating ? Color.red : Color.blue) // <<: Here!
ZStack {
if isAnimating {
ForEach(0..<10) { waveIteration in
Wave(strength: waveStrength, frequency: waveFrequency, phase: phase)
.stroke(Color.blue.opacity(Double(waveIteration) / 3), lineWidth: 1.1)
.offset(y: CGFloat(waveIteration) * 10)
}
}
else {
Line().stroke(Color.blue)
}
}
.frame(height: UIScreen.main.bounds.height * 0.8)
.overlay(isAnimating ? Text(stringOfText) : nil, alignment: .top) // <<: Here!
.onReceive(timer) { _ in
if isAnimating { // <<: Here!
randNum = Int16.random(in: 0..<Int16.max)
isNumberInteresting = checkNumber(num: randNum)
stringOfText = "Random number: \(String(randNum)), interesting: \(String(isNumberInteresting))" // <<: Here!
if isNumberInteresting {
waveFrequency = 50.0
waveStrength = 50.0
} else {
waveFrequency = 10.0
waveStrength = 10.0
}
}
else {
stopTimer() // For safety! Killing Timer in case! // <<: Here!
}
}
.animation(nil, value: stringOfText) // <<: Here!
.animation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) // <<: Here!
.id(isAnimating) // <<: Here!
}
}
}

How to make the timer goes faster than 1 second

I have a text that gets updated every second I am using
#State var timeRemaining = 10
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
Text("Time Left: \(self.timeRemaining)").onReceive(timer) { _ in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
}
if self.timeRemaining <= 0 {
print("Time is up!")
}
}
But I want it to go faster than just every one second.
Thank you
Do:
let timer = Timer.publish(every: 1 / 2, on: .main, in: .common).autoconnect()
the timer will only take whole numbers not decimals. so you can do some math and figure how much faster you want it
The answer is:
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
I was just calling another timer without noticing the different names.

Swift: how to invalidate timer on animated SKShapenode countdown?

Alright I'm building a sprite kit game and on one of my previous questions, I got the code that lets a circle skshapenode "unwind" in accordance with a timer that runs out (so that the circle has disappeared by the time the timer is done).
Here was my question: Swift, sprite kit game: Have circle disappear in clockwise manner? On timer?
And here is the code that I've been using:
func addCircle() {
circle = SKShapeNode(circleOfRadius: 50)
let circleColor = UIColor(red: 102/255, green: 204/255, blue: 255/255, alpha: 1)
circle.fillColor = circleColor
circle.strokeColor = SKColor.clearColor()
circle.position = CGPoint (x: self.frame.size.width * 0.5, y: self.frame.size.height * 0.5+45)
circle.zPosition = 200
circle.zRotation = CGFloat(M_PI_2)
addChild(circle)
countdown(circle, steps: 120, duration: 5) { //change steps to change how much of the circle it takes away at a time
//Performed when circle timer ends:
print("circle done")
self.gameSoundTrack.stop()
self.removeAllChildren()
self.removeAllActions()
self.viewController.removeSaveMe(self)
self.GOSceneNodes() //Implements GOScene
self.viewController.addGOScene(self) //Implements GOViewController
}
}
// Creates an animated countdown timer
func countdown(circle:SKShapeNode, steps:Int, duration:NSTimeInterval, completion:()->Void) {
let radius = CGPathGetBoundingBox(circle.path).width/2
let timeInterval = duration/NSTimeInterval(steps)
let incr = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.runBlock({
percent -= incr
circle.path = self.circle(radius, percent:percent)
})
let wait = SKAction.waitForDuration(timeInterval)
let action = SKAction.sequence([wait, animate])
runAction(SKAction.repeatAction(action,count:steps-1)) {
self.runAction(SKAction.waitForDuration(timeInterval)) {
circle.path = nil
completion()
}
}
}
// Creates a CGPath in the shape of a pie with slices missing
func circle(radius:CGFloat, percent:CGFloat) -> CGPath {
let start:CGFloat = 0
let end = CGFloat(M_PI*2) * percent
let center = CGPointZero
let bezierPath = UIBezierPath()
bezierPath.moveToPoint(center)
bezierPath.addArcWithCenter(center, radius: radius, startAngle: start, endAngle: end, clockwise: true)
bezierPath.addLineToPoint(center)
return bezierPath.CGPath
}
This all works well, however I need a way to invalidate the countdown for the circle if another button within the game is pressed so that everything that is performed when the timer runs out (everything after //Performed when circle timer ends: above) is NOT performed.
I have tried
circle.removeAllActions()
circle.removeFromParent()
when the button is pressed, however this just removes the circle and everything is still executed even though the circle is gone.
I'm pretty new to Swift and I can't figure out what part of those 3 functions I need to invalidate or stop in order to stop the countdown from finishing. How can I accomplish this?
You can stop an action by adding a key to the action and then run
removeActionForKey("actionKey")
To add a key to the action, replace the countdown method with the following:
// Creates an animated countdown timer
func countdown(circle:SKShapeNode, steps:Int, duration:NSTimeInterval, completion:()->Void) {
let radius = CGPathGetBoundingBox(circle.path).width/2
let timeInterval = duration/NSTimeInterval(steps)
let incr = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.runBlock({
percent -= incr
circle.path = self.circle(radius, percent:percent)
})
let wait = SKAction.waitForDuration(timeInterval)
let action = SKAction.sequence([wait, animate])
let completed = SKAction.runBlock{
circle.path = nil
completion()
}
let countDown = SKAction.repeatAction(action,count:steps-1)
let sequence = SKAction.sequence([countDown, SKAction.waitForDuration(timeInterval),completed])
runAction(sequence, withKey: "actionKey")
}