Search code examples
iosswiftgrand-central-dispatch

DispatchQueue.main.asyncAfter with on/off switch


I whipped up the below struct as a way to alert the user when there's a slow network connection.

When a function is going to make a call to the server, it creates a ResponseTimer. This sets a delayed notification, which only fires if the responseTimer var isOn = true. When my function get's a response back from the server, set responseTimer.isOn = false.

Here's the struct:

struct ResponseTimer {

var isOn: Bool

init() {
    self.isOn = true
    self.setDelayedAlert()
}

func setDelayedAlert() {
    let timer = DispatchTime.now() + 8
    DispatchQueue.main.asyncAfter(deadline: timer) {
        if self.isOn {
            NotificationCenter.default.post(name: NSNotification.Name(rawValue: toastErrorNotificationKey), object: self, userInfo: ["toastErrorCase" : ToastErrorCase.poorConnection])
        }
    }
}

And here's how I'd use it

func getSomethingFromFirebase() {

    var responseTimer = ResponseTimer()

    ref.observeSingleEvent(of: .value, with: { snapshot in
        responseTimer.isOn = false

        //do other stuff
    })
}

Even in cases where the response comes back before the 8 second delay completes, the notification is still fired. What am I doing wrong here??? Is there a better pattern to use for something like this?

Thanks for the help!


Solution

  • There are a few approaches:

    1. invalidate a Timer in deinit

      Its implementation might look like:

      class ResponseTimer {
          private weak var timer: Timer?
      
          func schedule() {
              timer = Timer.scheduledTimer(withTimeInterval: 8, repeats: false) { _ in // if you reference `self` in this block, make sure to include `[weak self]` capture list, too
                  // do something
              }
          }
      
          func invalidate() {
              timer?.invalidate()
          }
      
          // you might want to make sure you `invalidate` this when it’s deallocated just in 
          // case you accidentally had path of execution that failed to call `invalidate`.
      
          deinit {
              invalidate()
          }
      }
      

      And then you can do:

      var responseTimer: ResponseTimer?
      
      func getSomethingFromFirebase() {
          responseTimer = ResponseTimer()
          responseTimer.schedule()
      
          ref.observeSingleEvent(of: .value) { snapshot in
              responseTimer?.invalidate()
      
              //do other stuff
          }
      }
      
    2. Use asyncAfter with DispatchWorkItem, which you can cancel:

      class ResponseTimer {
          private var item: DispatchWorkItem?
      
          func schedule() {
              item = DispatchWorkItem { // [weak self] in // if you reference `self` in this block, uncomment this `[weak self]` capture list, too
                  // do something
              }
              DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: item!)
          }
      
          func invalidate() {
              item?.cancel()
              item = nil
          }
      
          deinit {
              invalidate()
          }
      }
      
    3. Use DispatchTimerSource, which cancels automatically when it falls out of scope:

      struct ResponseTimer {
          private var timer: DispatchSourceTimer?
      
          mutating func schedule() {
              timer = DispatchSource.makeTimerSource(queue: .main)
              timer?.setEventHandler { // [weak self] in // if you reference `self` in the closure, uncomment this
                  NotificationCenter.default.post(name: notification, object: nil)
              }
              timer?.schedule(deadline: .now() + 8)
              timer?.activate()
          }
      
          mutating func invalidate() {
              timer = nil
          }
      }
      

    In all three patterns, the timer will be canceled when ResponseTimer falls out of scope.