Stop swiftUI animation and skip to destination value - swiftui

I am animating the remaining time for some scenes in my app. I have the issue that when the user interrupts this scene by going to the next scene, the animation doesn't stop
This code is a simplified version of what is happening:
struct ContentView: View {
#State private var remaining: Int = 40
#State private var sceneIndex: Int = 0
var body: some View {
VStack(spacing: 30) {
Text("Remaining:")
EmptyView().modifier(DoubleModifier(value: remaining))
Button(action: {
self.sceneIndex += 1
if self.sceneIndex == 1 {
withAnimation(.linear(duration: 10)) {
self.remaining = 30
}
} else {
withAnimation(.linear(duration: 0)) {
self.remaining = 30 // changing this to any other value will stop the animation
}
}
}) {
Text("Go to next scene")
}
}
}
}
struct DoubleModifier: AnimatableModifier {
private var value: Double
init(value: Int) {
self.value = Double(value)
}
var animatableData: Double {
get { value }
set { value = newValue }
}
func body(content: Content) -> some View {
Text("\(Int(value)) seconds remaining")
}
}
What is happening is that when the user clicks the countdown animation is initialised with a duration of 10 seconds and will set the remaining time from 40 to 30 seconds. Now when the user clicks the skip button, the animation should stop and go to the 30 seconds remaining. However, it still shows the animation from 40 to 30 (somewhere in between).
If I change the self.remaining to 30 in the else statement, the value is not updated since it is already set to 30, but internally it is still animating.
If I change the self.remaining to any other valid value than 30, for example self.remaining = 29 the animation is indeed immediately stopped.
I could replace the content in the else statement by:
withAnimation(.linear(duration: 0)) {
self.remaining = 29
DispatchQueue.main.async {
self.remaining = 30
}
}
Which does work but feels very messy and in my case it is not always possible to use DispatchQueue.main.async
Is there another, better, way to stop the animation and skip to the destination value?
Is it maybe somehow possible to assign some sort of identifier to the self.remaining = 30 in the else statement such that swiftUI knows the value actually did change even though it factually didn't so that swiftUI stops the animation?

It is not clear what's the logic planned by step from scene-to-scene, but goal as it is postulated now in question can be achieved with the following body:
var body: some View {
VStack(spacing: 30) {
Text("Remaining:")
EmptyView().modifier(DoubleModifier(value: remaining)).id(sceneIndex)
Button(action: {
self.sceneIndex += 1
withAnimation(.linear(duration: 10)) {
self.remaining = 30
}
}) {
Text("Go to next scene")
}
}
}

Related

SwiftUI sound on timer ending is playing over and over

I created a timer.
When the timer ends it should play a complete sounds once.
It is a soundfile that is about 5 seconds long.
But what happens is that when the timer ends, the sounds is playing over and over again and it only plays the first 2 seconds of the sound.
It happens on the simulator and a real device.
I have no idea why this is happening. I've been googling voor quite some time but I can't find a solution.
I tried to connect the sound to buttons, and the play and stop functions work fine.
But even if I try to put the 'SoundManager.instance.stopSound()' into the stop button, it still doesn't stop playing the sound when I tap it when the timer ends.
Can someone tell me what I am doing wrong?
This is my code:
import SwiftUI
import AVKit
class SoundManager {
static let instance = SoundManager()
var player: AVAudioPlayer?
func playSound() {
guard let url = Bundle.main.url(forResource: "Tada", withExtension: ".mp3") else {return}
do {
player = try AVAudioPlayer(contentsOf: url)
player?.play()
} catch let error {
print("Error playing sound. \(error.localizedDescription)")
}
}
func stopSound() {
// Stop AVAudioPlayer
player?.stop()
}
}
struct TimerCountDown: View {
#State var countDownTimer = 30
#State var timerRunning = false
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Text("\(countDownTimer)")
.onReceive(timer) { _ in
if countDownTimer > 0 && timerRunning {
countDownTimer -= 1
}
else if countDownTimer < 1 {
SoundManager.instance.playSound()
}
else {
timerRunning = false
}
}
.font(.system(size: 80, weight: .bold))
.opacity(0.80)
HStack (spacing: 30) {
Button("Start") {
timerRunning = true
}
.padding()
.background(.blue)
.foregroundColor(.white)
.cornerRadius(8)
Button("Stop") {
timerRunning = false
}
.padding()
.background(.blue)
.foregroundColor(.white)
.cornerRadius(8)
Button("Reset") {
countDownTimer = 30
}
.padding()
.background(.red)
.foregroundColor(.white)
.cornerRadius(8)
}
}
}
}
struct TimerCountDown_Previews: PreviewProvider {
static var previews: some View {
TimerCountDown()
}
}
Your problem is in your .onReceive(timer) code. When the countDownTimer has reached 0, the timer is still publishing every second, so your code enters the second clause which plays the end timer sound. It does this every second. You should only do that if timerRunning and you should set that to false when your count reaches 0
.onReceive(timer) { _ in
if timerRunning {
if countDownTimer > 0 {
countDownTimer -= 1
if countDownTimer == 0 {
timerRunning = false
SoundManager.instance.playSound()
}
}
}
}
Note: Your code leaves the timer publishing at all times, and it is really only needed when the timer is running.
Check this answer for ideas on how to stop and restart the publishing of the timer.

SwiftUI #ObservedObject updating view's state from the back-stack?

I'm implementing a Timer, to show an Alert after 10 seconds, using Combine's Publisher and #ObservedObject, #StateObject or #State to manage states in a screen A. The problem is when I navigate to screen B through a NavigationLink the Alert still show up.
Is there a way to process the state changes of a view only when it's on top?
struct NavigationView: View {
let timer = Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.receive(on: DispatchQueue .main)
.scan(0) { counter, _ in
counter + 1
}
#State private var counter = "Seconds"
#State private var alert: AlertConfiguration?
var body: some View {
ZStack {
HStack(alignment: .top) {
Text(counterText)
Spacer()
}
NavigationLink(
destination: destinationView
) {
Button(Strings.globalDetails1) {
navigationAction()
}
}
}
.onReceive(timer) { count in
if count == 10 {
makeAlert()
}
setSeconds(with: count)
}
.setAlert(with: $alert) // This is just a custom ViewModifier to add an Alert to a view
}
private func makeAlert() {
alert = AlertConfiguration()
}
private func setSeconds(with count: Int) {
counter = "seconds_counter".pluralLocalization(count: count)
}
}
You could add a variable that you set true if your View is in foreground and will be set false if you switch to a different View. Then you only increase the timer if said variable is true
.onReceive(timer) {
if count == 10 {
makeAlert()
}
if (timerIsRunning) {
count += 1
}
}
So basically the solution is to have a publisher that supports the lifecycle of the screen (onAppear & onDisappear).
This Answer helped: SwiftUI Navigation Stack pops back on ObservableObject update

Delay in SwiftUI view appearing

I have a view that displays two calculated strings. At present, I calculate the strings with .onAppear. But the view does not render until the strings are calculated, leaving the user watching the previous view for 2 to 5 seconds till the calculation is done, and the progress bar never gets shown.
The code is:
struct CalculatingProgressView: View {
var body: some View {
ProgressView {
Text("Calculating")
.font(.title)
}
}
}
struct OffspringView: View {
#State private var males: String = ""
#State private var females: String = ""
#State private var busy = true
func determineOffspring() {
let temp = theOffspring(of: sire, and: dam)
males = temp.0
females = temp.1
busy = false
}
var body: some View {
Section(header: Text("Male Offspring")) {
Text(males)
.font(.callout)
}
if busy {
CalculatingProgressView()
}
Section(header: Text("Female Offspring")) {
Text(females)
.font(.callout)
}
.onAppear { determineOffspring() }
}
}
How can I get the view to render with a progress bar so the user knows that the app is actually doing something?
your code seems to work for me. You could try this approach,
to show the CalculatingProgressView while it's calculating determineOffspring.
var body: some View {
if busy {
CalculatingProgressView()
.onAppear { determineOffspring() }
} else {
Section(header: Text("Male Offspring")) {
Text(males).font(.callout)
}
Section(header: Text("Female Offspring")) {
Text(females).font(.callout)
}
}
}
}
Note, your theOffspring(...) in determineOffspring should use a completion closure something like
the following, to "wait" until the calculations are finished:
func determineOffspring() {
theOffspring(of: sire, and: dam) { result in
males = result.0
females = result.1
busy = false
}
}

How to create an instruction prompt after pressing a button?

I'm creating an instruction dialog that will start after pressing a button. I want the instruction to be displayed in the same view and just get replaced after a few seconds to display the next instruction.
The instructions are stored in a text array.
Any help will be greatly appreciated as I'm having trouble finding a solution
#State var showPrompt = false
#State var textToUpdate = ""
let instructionTo = ["hi", "bye"]
VStack {
Text(textToUpdate)
.frame(width: UIScreen.main.bounds.width - 80, height: 350)
Button(action: {
if(self.showPrompt == false)
{
self.showPrompt = true
}
else{
self.showPrompt = false
}
}) {
if(self.showPrompt == false)
{
Text("Start")
}
else
{
Text("Stop")
// some for loop that would update the text
// and have a delay of 5 seconds between each
// index in the array
}
}
}
you can add another State variable for the Text value.
here is a sample code, assuming that you want to start changing the text after tapping on the start button after 5 seconds. I just added an empty string to show an empty text at first.
struct TestView: View {
#State var showPrompt = false
#State var textIndex = 0
let instructionTo = ["", "hi", "bye"]
var body: some View {
VStack {
Text(instructionTo[textIndex])
.frame(width: UIScreen.main.bounds.width - 80, height: 350)
Button(action: {
if(self.showPrompt == false)
{
self.showPrompt = true
// update text value
self.updateText()
}
else{
self.showPrompt = false
}
}) {
if(self.showPrompt == false)
{
Text("Start")
}
else
{
Text("Stop")
}
}
}
}
func updateText() {
if self.textIndex < self.instructionTo.count - 1 {
// update text after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.textIndex += 1
self.updateText()
}
}
}
}

How to display an image for one second? SWIFTUI

I want to show an image for one second when the player achieve the goal. I thought about putting an alert but it will slow down the game. I just want the image to stay for one second at the top of the screen and disappear until the next achievement.
Example code is below:
var TapNumber = 0
func ScoreUp() {
TapNumber += 1
if TapNumber == 100 {
showImage()
}
}
func showImage() {
// this is the function I want to create but. I do not know how
show image("YouEarnedAPointImage") for one second
}
Here is a demo of possible approach
struct DemoShowImage1Sec: View {
#State private var showingImage = false
var body: some View {
ZStack {
VStack {
Text("Main Content")
Button("Simulate") { self.showImage() }
}
if showingImage {
Image(systemName: "gift.fill")
.resizable()
.frame(width: 100, height: 100)
.background(Color.yellow)
}
}
}
private func showImage() {
self.showingImage = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.showingImage = false
}
}
}