1

Semi related question: SwiftUI ActionSheet does not dismiss when timer is running

I am currently experiencing an issue with alerts in a project that I am working on. Presented alerts will not dismiss when there is a timer running in the background. Most of the time it requires several clicks of the dismissal button to disappear. I have recreated this issue with as little overhead as possible in a sample project.

My primary project has this issue when trying to display an alert on a different view but I could not reproduce that issue in the sample project. The issue can be reliably replicated by toggling the alert on the same view that the timer is running. I have also tested by removing the binding from the text field to stop the text field view from updating. The alert still fails to dismiss on the first click. I am unsure if there is a way to work around this and am looking for any advice possible.

Xcode 13.0/iOS 15.0 and occurs in iOS 14.0 also

Timerview.swift

struct TimerView: View {
    @ObservedObject var stopwatch = Stopwatch()
    @State var isAlertPresented:Bool = false
    var body: some View {
        VStack{
            Text(String(format: "%.1f", stopwatch.secondsElapsed))
                 .font(.system(size: 70.0))
                 .minimumScaleFactor(0.1)
                 .lineLimit(1)
            Button(action:{
                stopwatch.actionStartStop()
            }){
                Text("Toggle Timer")
            }
            
            Button(action:{
                isAlertPresented.toggle()
            }){
                Text("Toggle Alert")
            }
        }
        .alert(isPresented: $isAlertPresented){
            Alert(title:Text("Error"),message:Text("I  am presented"))
        }  
    }
}

Stopwatch.swift

class Stopwatch: ObservableObject{
    @Published var secondsElapsed: TimeInterval = 0.0
        @Published var mode: stopWatchMode = .stopped
        
    
    func actionStartStop(){
        if mode == .stopped{
            start()
        }else{
            stop()
        }
    }
    
    var timer = Timer()
    func start() {
        secondsElapsed = 0.0
        mode = .running
        timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
            self.secondsElapsed += 0.1
        }
    }
    
    func stop() {
        timer.invalidate()
        mode = .stopped
    }
    
    enum stopWatchMode {
        case running
        case stopped
    }
}

Edit: Moving the button to a custom view solves the initial problem but is there a solution for when the button needs to interact with the Observable object?

         Button(action:{
             do{
                 try stopwatch.actionDoThis()
             }catch{
                 isAlertPresented = true
             }
         }){
          Text("Toggle Alert")
         }.alert(isPresented: $isAlertPresented){
          Alert(title:Text("Error"),message:Text("I  am presented"))
itsBryan
  • 13
  • 4

2 Answers2

3

Every time timer runs UI will recreate, since "secondsElapsed" is an observable object. SwiftUI will automatically monitor for changes in "secondsElapsed", and re-invoke the body property of your view. In order to avoid this we need to separate the button and Alert to another view like below.

struct TimerView: View {
   @ObservedObject var stopwatch = Stopwatch()
   @State var isAlertPresented:Bool = false
   var body: some View {
     VStack{
        Text(String(format: "%.1f", stopwatch.secondsElapsed))
            .font(.system(size: 70.0))
            .minimumScaleFactor(0.1)
            .lineLimit(1)
        Button(action:{
            stopwatch.actionStartStop()
        }){
            Text("Toggle Timer")
        }
        CustomAlertView(isAlertPresented: $isAlertPresented)
    }
  }
}

struct CustomAlertView: View {
  @Binding var isAlertPresented: Bool
    var body: some View {
       Button(action:{
        isAlertPresented.toggle()
       }){
        Text("Toggle Alert")
       }.alert(isPresented: $isAlertPresented){
        Alert(title:Text("Error"),message:Text("I  am presented"))
    }
  }
}
Vaisakh
  • 2,919
  • 4
  • 26
  • 44
  • Thank you, ultimately this does solve the original problem but made me realize I may not have asked the completely correction question. What about a scenario where a button would throw an alert after interacting with that observed object while the timer is still running. Something like `action: { do{try stopwatch.actionDoThis()}catch{isAlertPresented = true}` – itsBryan Oct 22 '21 at 21:12
  • It will working , are you facing any issue . Button(action:{ isAlertPresented = true do{try stopwatch.actionDoThis()}catch{isAlertPresented = true}}){Text("Next button")} func actionDoThis () throws { self.secondsElapsed += 0.1 } – Vaisakh Oct 22 '21 at 21:36
  • 1
    Actually it is, I was just making an uneducated mistake. I was creating another @ObservedObject stopwatch in the custom alert view when there was no reason for it to be an observed object. I think provided clarification and i'll read some more into state management in swiftui. Thank you! – itsBryan Oct 22 '21 at 21:47
  • This extra view will not help when you have custom action buttons. So do you know how to solve this when we use the primary and secondary buttons in Alert? thanks – Sajjad Sarkoobi Aug 17 '22 at 17:47
0

If you really need the ObservedObject or any attribute of it in order to perform any action in case of "OK" action of the alert, you can do a workaround.

struct TimerView: View {
   @ObservedObject var stopwatch = Stopwatch()
   @State var isResetAccepted: Bool = false

   var body: some View {
     VStack{
        Text(String(format: "%.1f", stopwatch.secondsElapsed))
            .font(.system(size: 70.0))
            .minimumScaleFactor(0.1)
            .lineLimit(1)
        Button(action:{
            stopwatch.actionStartStop()
        }){
            Text("Toggle Timer")
        }
        CustomAlertView(isResetAccepted: $isResetAccepted)
        .onChange(of: isResetAccepted) { newValue in
            if newValue {
                isResetAccepetd = false
                stopwatch.reset()               
            }
        }
    }
  }
}

struct CustomAlertView: View {
  
  @Binding var isResetAccepted: Bool
  @State var isAlertPresented: Bool = false
    
    var body: some View {
       Button(action:{
        isAlertPresented.toggle()
       }){
        Text("Toggle Alert")
       }.alert(isPresented: $isAlertPresented){
        Alert(title:Text("Error"),
        message:Text("I  am presented"),
        primaryButton: .destructive(Text("Cancel"), action: {
            self.isResetAccepted = false
            self.isAlertPresented = false                    
        }),
        secondaryButton: .default(Text("OK"), action: {
            self.isResetAccepted = true
            self.isAlertPresented = false
        }))
    }
  }
}