How to get a view to update automatically after period of inactivity?

1k Views Asked by At

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!")
      }
    }
  }
}

3

There are 3 best solutions below

1
Vineet Rai On

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.

6
workingdog support Ukraine On

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
        }
    }
}
0
workingdog support Ukraine On

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()
        }
    }
}