0

I am trying to add a timeout feature to my SwiftUI app. The view should be updated when timeout is reached. I have found code on a different thread, which works for the timeout part, but I cannot get the view to update.

I am using a static property in the UIApplication extension to toggle the timeout flag. Looks like the view is not notified when this static property changes. What is the correct way to do this?

Clarification added:

@workingdog has proposed an answer below. This does not quite work, because in the actual app, there is not just one view, but multiple views that the user can navigate between. So, I am looking for a global timer that gets reset by any touch action whatever the current view is.

In the sample code, the global timer works, but the view does not take notice when the static var UIApplication.timeout is changed to true.

How can I get the view to update? Is there something more appropriate for this purpose than a static var? Or maybe the timer should not be in the UIApplication extension to begin with?

Here is my code:

import SwiftUI

@main
struct TimeoutApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
              .onAppear(perform: UIApplication.shared.addTapGestureRecognizer)
        }
    }
}

extension UIApplication {
  
  private static var timerToDetectInactivity: Timer?
  static var timeout = false
    
  func addTapGestureRecognizer() {
      guard let window = windows.first else { return }
      let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapped))
      tapGesture.requiresExclusiveTouchType = false
      tapGesture.cancelsTouchesInView = false
      tapGesture.delegate = self
      window.addGestureRecognizer(tapGesture)
  }

  private func resetTimer() {
    let showScreenSaverInSeconds: TimeInterval = 5
    if let timerToDetectInactivity = UIApplication.timerToDetectInactivity {
        timerToDetectInactivity.invalidate()
    }
    UIApplication.timerToDetectInactivity = Timer.scheduledTimer(
      timeInterval: showScreenSaverInSeconds,
      target: self,
      selector: #selector(timeout),
      userInfo: nil,
      repeats: false
    )
  }
  
  @objc func timeout() {
    print("Timeout")
    Self.timeout = true
  }
    
  @objc func tapped(_ sender: UITapGestureRecognizer) {
    if !Self.timeout {
      print("Tapped")
      self.resetTimer()
    }
  }
}

extension UIApplication: UIGestureRecognizerDelegate {
    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

import SwiftUI

struct ContentView: View {
  var body: some View {
    VStack {
      Text(UIApplication.timeout ? "TimeOut" : "Hello World!")
          .padding()
      Button("Print to Console") {
        print(UIApplication.timeout ? "Timeout reached, why is view not updated?" : "Hello World!")
      }
    }
  }
}

Nimantha
  • 6,405
  • 6
  • 28
  • 69
spadolini
  • 1
  • 2

3 Answers3

0

You can do this like this - >

  1. Create a subclass of "UIApplication" (use separate files like MYApplication.swift).

  2. Use the below code in the file.

  3. Now method "idle_timer_exceeded" will get called once the user stops touching the screen.

  4. Use notification to update the UI

import UIKit
import Foundation

private let g_secs = 5.0 // Set desired time

class MYApplication: UIApplication
{
    var idle_timer : dispatch_cancelable_closure?
    
    override init()
    {
        super.init()
        reset_idle_timer()
    }
    
    override func sendEvent( event: UIEvent )
    {
        super.sendEvent( event )
        
        if let all_touches = event.allTouches() {
            if ( all_touches.count > 0 ) {
                let phase = (all_touches.anyObject() as UITouch).phase
                if phase == UITouchPhase.Began {
                    reset_idle_timer()
                }
            }
        }
    }
    
    private func reset_idle_timer()
    {
        cancel_delay( idle_timer )
        idle_timer = delay( g_secs ) { self.idle_timer_exceeded() }
    }
    
    func idle_timer_exceeded()
    {
        
        // You can broadcast notification here and use an observer to trigger the UI update function
        reset_idle_timer()
    }
}

You can view the source for this post here.

aheze
  • 24,434
  • 8
  • 68
  • 125
Vineet Rai
  • 80
  • 7
  • Thank you. I saw similar implementation before, and tried it. But overriding sendEvent does not work in SwiftUI, or does it? – spadolini Jun 13 '21 at 12:45
0

to update the view when timeout is reached, you could do something like this:

import SwiftUI

@main
struct TimeoutApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
    struct ContentView: View {
    @State var thingToUpdate = "tap the screen to rest the timer"
    @State var timeRemaining = 5
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack (spacing: 30) {
            Text("\(thingToUpdate)")
            Text("\(timeRemaining)")
                .onReceive(timer) { _ in
                    if timeRemaining > 0 {
                        timeRemaining -= 1
                    } else {
                        thingToUpdate = "refreshed, tap the screen to rest the timer"
                    }
                }
        }
        .frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity,
               minHeight: 0, idealHeight: .infinity, maxHeight: .infinity,
               alignment: .center)
        .contentShape(Rectangle())
        .onTapGesture {
            thingToUpdate = "tap the screen to rest the timer"
            timeRemaining = 5
        }
    }
}
  • Thank you. I tried it out, but it only does half the job. The timer needs to reset at any screen touch, not just when I hit a dedicated reset button. If you run my sample code and look at the console output, you will see what it does. – spadolini Jun 13 '21 at 12:59
  • ok, I've updated the code so you can tap on the screen to update the view. – workingdog support Ukraine Jun 13 '21 at 13:01
  • Thank you, @workingdog. This works, ... almost. The problem that I still have is with your proposed solution, the timer is a modifier that is attached to the view. When I add a second view, I need a second timer. And when I navigate between the views, I end up with multiple timers. What I really want is one global timer, that gets reset whatever the current view. I will change the question to make that clear. Thank you, anyway. – spadolini Jun 13 '21 at 19:53
  • you may try to use ObservableObject to implement what you want to achieve. For example: "class MyGlobalTimer: ObservableObject { @Published var ... }" . You can pass this ObservableObject easily to all views using, "@ObservedObject var myGlobalTimer = MyGlobalTimer()" or use "@EnvironmentObject" – workingdog support Ukraine Jun 13 '21 at 22:47
  • Yes, if the global timer was in a class, then I could put the timeout flag in a published var. I have tried to subclass UIApplication, but then I was not able to get the app to use the subclass instead of UIApplication. Can you provide a code example how to implement this? – spadolini Jun 14 '21 at 14:25
  • added a new answer for a global timer. – workingdog support Ukraine Jun 14 '21 at 23:16
0

Here is my code for a "global" timer using the "Environment". Although it works for me, it seems to be a lot of work just to do something simple. This leads me to believe there must be a better way to do this. Anyhow, there maybe some ideas you can recycle here.

import SwiftUI

@main
struct TestApp: App {
    @StateObject var globalTimer = MyGlobalTimer()
    var body: some Scene {
        WindowGroup {
            ContentView().environment(\.globalTimerKey, globalTimer)
        }
    }
}

struct MyGlobalTimerKey: EnvironmentKey {
    static let defaultValue = MyGlobalTimer()
}

extension EnvironmentValues {
    var globalTimerKey: MyGlobalTimer {
        get { return self[MyGlobalTimerKey] }
        set { self[MyGlobalTimerKey] = newValue }
    }
}

class MyGlobalTimer: ObservableObject {
    @Published var timeRemaining: Int = 5
    var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    func reset(_ newValue: Int = 5) {
        timeRemaining = newValue
    }
}

struct ContentView: View {
    @Environment(\.globalTimerKey) var globalTimer
    @State var refresh = true
    
    var body: some View {
        GlobalTimerView {  // <-- this puts the content into a tapable view
            // the content
            VStack {
                Text(String(globalTimer.timeRemaining))
                    .accentColor(refresh ? .black :.black) // <-- trick to refresh the view
                    .onReceive(globalTimer.timer) { _ in
                        if globalTimer.timeRemaining > 0 {
                            globalTimer.timeRemaining -= 1
                            refresh.toggle() // <-- trick to refresh the view
                            print("----> time remaining: \(globalTimer.timeRemaining)")
                        } else {
                            // do something at every time step after the countdown
                            print("----> do something ")
                        }
                    }
            }
        }
    }
}

struct GlobalTimerView<Content: View>: View {
    @Environment(\.globalTimerKey) var globalTimer
    let content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        ZStack {
            content
            Color(white: 1.0, opacity: 0.001)
                .frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity,
                       minHeight: 0, idealHeight: .infinity, maxHeight: .infinity,
                       alignment: .center)
                .contentShape(Rectangle())
                .onTapGesture {
                    globalTimer.reset()
                }
        }.onAppear {
            globalTimer.reset()
        }
    }
}