60

How can I add a timer to my iOS app that is based on user interaction (or lack thereof)? In other words, if there is no user interaction for 2 minutes, I want to have the app do something, in this case navigate to the initial view controller. If at 1:55 someone touches the screen, the timer resets. I would think this would need to be a global timer so no matter which view you are on, the lack of interaction starts the timer. Although, I could create a unique timer on each view. Does anyone have any suggestions, links or sample code where this has been done before?

BobbyScon
  • 2,435
  • 2
  • 21
  • 30
  • 8
    You're looking for the IDLE time, check: [iPhone: Detecting user inactivity/idle time since last screen touch](http://stackoverflow.com/questions/273450/iphone-detecting-user-inactivity-idle-time-since-last-screen-touch) – Anne Nov 10 '11 at 19:32
  • From @Vanessa Forney 's answer, I came with a complete solution to show a screensaver kind of view or overlay throughout the app. If needed please go through the link and suggest me if any changes needed https://stackoverflow.com/questions/53389572/detecting-inactivity-no-user-interaction-in-ios-for-showing-separate-screen-li?noredirect=1#comment93657820_53389572 – SARATH SASI Nov 21 '18 at 07:29

6 Answers6

119

The link that Anne provided was a great starting point, but, being the n00b that I am, it was difficult to translate into my existing project. I found a blog [original blog no longer exists] that gave a better step-by-step, but it wasn't written for XCode 4.2 and using storyboards. Here is a write up of how I got the inactivity timer to work for my app:

  1. Create a new file -> Objective-C class -> type in a name (in my case TIMERUIApplication) and change the subclass to UIApplication. You may have to manually type this in the subclass field. You should now have the appropriate .h and .m files.

  2. Change the .h file to read as follows:

    #import <Foundation/Foundation.h>
    
    //the length of time before your application "times out". This number actually represents seconds, so we'll have to multiple it by 60 in the .m file
    #define kApplicationTimeoutInMinutes 5
    
    //the notification your AppDelegate needs to watch for in order to know that it has indeed "timed out"
    #define kApplicationDidTimeoutNotification @"AppTimeOut"
    
    @interface TIMERUIApplication : UIApplication
    {
        NSTimer     *myidleTimer;
    }
    
    -(void)resetIdleTimer;
    
    @end
    
  3. Change the .m file to read as follows:

    #import "TIMERUIApplication.h"
    
    @implementation TIMERUIApplication
    
    //here we are listening for any touch. If the screen receives touch, the timer is reset
    -(void)sendEvent:(UIEvent *)event
    {
        [super sendEvent:event];
    
        if (!myidleTimer)
        {
            [self resetIdleTimer];
        }
    
        NSSet *allTouches = [event allTouches];
        if ([allTouches count] > 0)
        {
            UITouchPhase phase = ((UITouch *)[allTouches anyObject]).phase;
            if (phase == UITouchPhaseBegan || phase == UITouchPhaseMoved)
            {
                [self resetIdleTimer];
            }
    
        }
    }
    //as labeled...reset the timer
    -(void)resetIdleTimer
    {
        if (myidleTimer)
        {
            [myidleTimer invalidate];
        }
        //convert the wait period into minutes rather than seconds
        int timeout = kApplicationTimeoutInMinutes * 60;
        myidleTimer = [NSTimer scheduledTimerWithTimeInterval:timeout target:self selector:@selector(idleTimerExceeded) userInfo:nil repeats:NO];
    
    }
    //if the timer reaches the limit as defined in kApplicationTimeoutInMinutes, post this notification
    -(void)idleTimerExceeded
    {
        [[NSNotificationCenter defaultCenter] postNotificationName:kApplicationDidTimeoutNotification object:nil];
    }
    
    
    @end
    
  4. Go into your Supporting Files folder and alter main.m to this (different from prior versions of XCode):

    #import <UIKit/UIKit.h>
    
    #import "AppDelegate.h"
    #import "TIMERUIApplication.h"
    
    int main(int argc, char *argv[])
    {
        @autoreleasepool {
            return UIApplicationMain(argc, argv, NSStringFromClass([TIMERUIApplication class]), NSStringFromClass([AppDelegate class]));
        }
    }
    
  5. Write the remaining code in your AppDelegate.m file. I've left out code not pertaining to this process. There is no change to make in the .h file.

    #import "AppDelegate.h"
    #import "TIMERUIApplication.h"
    
    @implementation AppDelegate
    
    @synthesize window = _window;
    
    -(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 
    {      
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidTimeout:) name:kApplicationDidTimeoutNotification object:nil];
    
        return YES;
    }
    
    -(void)applicationDidTimeout:(NSNotification *) notif
    {
        NSLog (@"time exceeded!!");
    
    //This is where storyboarding vs xib files comes in. Whichever view controller you want to revert back to, on your storyboard, make sure it is given the identifier that matches the following code. In my case, "mainView". My storyboard file is called MainStoryboard.storyboard, so make sure your file name matches the storyboardWithName property.
        UIViewController *controller = [[UIStoryboard storyboardWithName:@"MainStoryboard" bundle:NULL] instantiateViewControllerWithIdentifier:@"mainView"];
    
        [(UINavigationController *)self.window.rootViewController pushViewController:controller animated:YES];
    }
    

Notes: The timer will start anytime a touch is detected. This means that if the user touches the main screen (in my case "mainView") even without navigating away from that view, the same view will push over itself after the allotted time. Not a big deal for my app, but for yours it might be. The timer will only reset once a touch is recognized. If you want to reset the timer as soon as you get back to the page you want to be at, include this code after the ...pushViewController:controller animated:YES];

[(TIMERUIApplication *)[UIApplication sharedApplication] resetIdleTimer];

This will cause the view to push every x minutes if it's just sitting there with no interaction. The timer will still reset every time it recognizes a touch, so that will still work.

Please comment if you have suggested improvements, especially someway to disable the timer if the "mainView" is currently being displayed. I can't seem to figure out my if statement to get it to register the current view. But I'm happy with where I'm at. Below is my initial attempt at the if statement so you can see where I was going with it.

-(void)applicationDidTimeout:(NSNotification *) notif
{
    NSLog (@"time exceeded!!");
    UIViewController *controller = [[UIStoryboard storyboardWithName:@"MainStoryboard" bundle:NULL] instantiateViewControllerWithIdentifier:@"mainView"];

    //I've tried a few varieties of the if statement to no avail. Always goes to else.
    if ([controller isViewLoaded]) {
        NSLog(@"Already there!");
    }
    else {
        NSLog(@"go home");
        [(UINavigationController *)self.window.rootViewController pushViewController:controller animated:YES];
        //[(TIMERUIApplication *)[UIApplication sharedApplication] resetIdleTimer];
    }
}

I am still a n00b and may have not done everything the best way. Suggestions are always welcome.

ULazdins
  • 1,804
  • 4
  • 22
  • 30
BobbyScon
  • 2,435
  • 2
  • 21
  • 30
  • 2
    This is an excellent walk through and surely deserves more up-votes. I'm a n00b too and have been trying to figure out how to implement something like this for weeks, before I stumbled upon this. Thank you very much. Only thing I would clarify is setting the ID of the view controller that you mention in your comments, which you set in the identity inspector under `Identity` then `Storyboard ID`. But I'm really just nitpicking. Thanks again. – Robert Nov 28 '12 at 11:58
  • Thanks! Glad I could be of help to someone. Good call on the identity issue, didn't even dawn on me to elaborate on that. – BobbyScon Nov 29 '12 at 02:23
  • Thanks I've implemented it in my app :-), very useful – Aluminum Dec 21 '12 at 20:47
  • Thanks for sharing this bobbyScon.. You're the best.. I'm also noob too.. just started a month ago.. – Link Feb 14 '13 at 03:21
  • 1
    For a "n00b" (at that time at least), this is pretty advanced stuff. This was very helpful. Thanks! :) – Kenn Cal May 08 '13 at 09:30
  • 1
    Hi, Thanks for the below method, [(TIMERUIApplication *)[UIApplication sharedApplication] resetIdleTimer]; – Meenakshi May 26 '14 at 11:57
  • If the VC you want to display is the root, then instead of pushing, you can just pop to the root. [(UINavigationController *)self.window.rootViewController popToRootViewControllerAnimated:YES]; This performs no action is you are already on the root. – David Lari Sep 04 '14 at 14:25
  • @BobbyScon CAN YOU PLEASE translate this in Swift. I want this in swift – Qadir Hussain Oct 15 '14 at 06:56
  • Application going to crash when i dissmiss view controller. Crash at [super sendEvent:event]; ----- ECX_BAD_ACCESS. – Rajesh Maurya Nov 06 '14 at 10:33
  • @QadirHussain I've added a Swift version in an answer below – kfmfe04 Dec 19 '14 at 23:04
  • 1
    You currently use AppDelegate to listen to your notifications, which makes it difficult to figure out if the "mainView" is already pushed onto the rootViewController. Instead, shift the listening logic to your rootViewController. If it's a UINavigationController, check its viewControllers array to see if a "mainView" exists. If so, do nothing; otherwise push a "mainView" instance onto the navigation stack. If rootViewController is simply a UIViewController instance, you can modally present a "mainView" instance. – Thomas Verbeek Mar 24 '15 at 22:05
  • I want to enable Timer only when user is logged in. How to go about this? I tried but getting session time out message even when user is on login screen. – Jayprakash Dubey Jun 19 '16 at 12:26
  • I don't see where do you call *removeObserver:* I would better register notification in - (void)applicationDidBecomeActive: and unregister in - (void)applicationWillResignActive:(UIApplication *)application { [[NSNotificationCenter defaultCenter] removeObserver:self]; } – David Jul 28 '16 at 15:18
  • 1
    Since I just found this code, verbatim, in a project I inherited, I thought it worth pointing out that the comments about the time units are incorrect. The value in the header file represents minutes (not seconds), and in the .m file, we convert this to seconds (not minutes) for use as an NSTimeInterval. – Joe Strout Jan 24 '17 at 18:06
  • @JoeStrout - That's interesting. I wonder if it's different with newer versions? (This was written in 2012...) However, I very much appreciate hearing that my code is out in the wild and being inherited by people. Thanks for letting me know (and adding helpful extra info). – BobbyScon Jan 24 '17 at 19:51
  • I don't understand why you have the check `if (myidleTimer)` in `sendEvent()` method? – Guangyu Wang Jul 05 '18 at 19:48
26

I have implemented what Bobby has suggested, but in Swift. The code is outlined below.

  1. Create a new file -> Swift File -> type in a name (in my case TimerUIApplication) and change the subclass to UIApplication. Change the TimerUIApplication.swift file to read as follows:

    class TimerUIApplication: UIApplication {
    
        static let ApplicationDidTimoutNotification = "AppTimout"
    
        // The timeout in seconds for when to fire the idle timer.
        let timeoutInSeconds: TimeInterval = 5 * 60
    
        var idleTimer: Timer?
    
        // Listen for any touch. If the screen receives a touch, the timer is reset.
        override func sendEvent(event: UIEvent) {
            super.sendEvent(event)
            if event.allTouches?.contains(where: { $0.phase == .began || $0.phase == .moved }) == true {
                resetIdleTimer()
            }
        }
    
        // Resent the timer because there was user interaction.
        func resetIdleTimer() {
            idleTimer?.invalidate()
            idleTimer = Timer.scheduledTimer(timeInterval: timeoutInSeconds, target: self, selector: #selector(AppDelegate.idleTimerExceeded), userInfo: nil, repeats: false)
        }
    
        // If the timer reaches the limit as defined in timeoutInSeconds, post this notification.
        func idleTimerExceeded() {
            Foundation.NotificationCenter.default.post(name: NSNotification.Name(rawValue: TimerUIApplication.ApplicationDidTimoutNotification), object: nil)
        }
    }
    
  2. Create a new file -> Swift File -> main.swift (the name is important).

    import UIKit
    
    UIApplicationMain(Process.argc, Process.unsafeArgv, NSStringFromClass(TimerUIApplication), NSStringFromClass(AppDelegate))
    
  3. In your AppDelegate: Remove @UIApplicationMain above the AppDelegate.

    class AppDelegate: UIResponder, UIApplicationDelegate {
    
        func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
            NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(AppDelegate.applicationDidTimout(_:)), name: TimerUIApplication.ApplicationDidTimoutNotification, object: nil)
            return true
        }
    
        ...
    
        // The callback for when the timeout was fired.
        func applicationDidTimout(notification: NSNotification) {
            if let vc = self.window?.rootViewController as? UINavigationController {
                if let myTableViewController = vc.visibleViewController as? MyMainViewController {
                    // Call a function defined in your view controller.
                    myMainViewController.userIdle()
                } else {
                  // We are not on the main view controller. Here, you could segue to the desired class.
                  let storyboard = UIStoryboard(name: "MyStoryboard", bundle: nil)
                  let vc = storyboard.instantiateViewControllerWithIdentifier("myStoryboardIdentifier")
                }
            }
        }
    }
    

Keep in mind you may have to do different things in applicationDidTimout depending on your root view controller. See this post for more details on how you should cast your view controller. If you have modal views over the navigation controller, you may want to use visibleViewController instead of topViewController.

ULazdins
  • 1,804
  • 4
  • 22
  • 30
Vanessa Forney
  • 416
  • 5
  • 7
  • Hey, @Vanessa Forney ............... how do i override the following variable......... timeoutInSeconds of the TimerUIApplication class from my viewcontrollers – Cloy Apr 12 '16 at 06:16
  • @Vanessa Forney : Where does Process.argc came from? – EK Chhuon Jun 11 '18 at 04:57
  • @EKChhuon I think it has been renamed to CommandLine.argc so maybe give that a shot, but I'm not sure the validity of this answer anymore. – Vanessa Forney Jun 26 '18 at 19:01
  • https://stackoverflow.com/questions/39088928/xcode-8-beta-6-main-swift-wont-compile Process.argc has been updated – carrotzoe Aug 15 '18 at 14:25
  • 1
    Your solution works. Just need to do some changes in main.swift file. `UIApplicationMain( CommandLine.argc, UnsafeMutableRawPointer(CommandLine.unsafeArgv) .bindMemory( to: UnsafeMutablePointer.self, capacity: Int(CommandLine.argc)), NSStringFromClass(TimerUIApplication.self), NSStringFromClass(AppDelegate.self) )` – Vijay Kharage Jul 30 '19 at 05:16
  • additional question, how if I want to increase the timeout programmatically? like It's default was 5 mins and I want to change it to 10 mins when i click a button? – Dylan Jun 17 '20 at 05:09
  • @MarkDylanBMercado You can do something like a didSet on the timer variable which invalidates and starts a new timer. Wherever your button code is can technically access this unless you changed the visibility (whether this is good programming style is another question!). – Vanessa Forney Jul 28 '20 at 05:36
16

Background [Swift Solution]

There was a request to update this answer with Swift so I've added a snippet below.

Do note that I have modified the specs somewhat for my own uses: I essentially want to do work if there are no UIEvents for 5 seconds. Any incoming touch UIEvent will cancel previous timers and restart with a new timer.

Differences from Answer Above

  • Some changes from the accepted answer above: instead of setting up the first timer upon the first event, I set up my timer in init() immediately. Also my reset_idle_timer() will cancel the previous timer so only one timer will be running at any time.

IMPORTANT: 2 Steps Before Building

Thanks to a couple great answers on SO, I was able to adapt the code above as Swift code.

  • Follow this answer for a rundown on how to subclass UIApplication in Swift. Make sure you follow those steps for Swift or the snippet below won't compile. Since the linked answer described the steps so well, I will not repeat here. It should take you less than a minute to read and set it up properly.

  • I could not get NSTimer's cancelPreviousPerformRequestsWithTarget: to work, so I found this updated GCD solution which works great. Just drop that code into a separate .swift file and you are gtg (so you can call delay() and cancel_delay(), and use dispatch_cancelable_closure).

IMHO, the code below is simple enough for anyone to understand. I apologise in advance for not answering any questions on this answer (a bit flooded with work atm).

I just posted this answer to contribute back to SO what great information I've gotten out.

Snippet

import UIKit
import Foundation

private let g_secs = 5.0

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()
    {
        println( "Ring ----------------------- Do some Idle Work!" )
        reset_idle_timer()
    }
}
Community
  • 1
  • 1
kfmfe04
  • 14,193
  • 11
  • 67
  • 132
  • super.sendEvent( event ) - because of this when i press hard ( 3d touch ) it is crash ... Is there any way to handle this ? – Mili Shah Nov 16 '17 at 10:55
4

Notes: The timer will start anytime a touch is detected. This means that if the user touches the main screen (in my case "mainView") even without navigating away from that view, the same view will push over itself after the allotted time. Not a big deal for my app, but for yours it might be. The timer will only reset once a touch is recognized. If you want to reset the timer as soon as you get back to the page you want to be at, include this code after the ...pushViewController:controller animated:YES];

One solution to this problem of the same view beginning displayed again is to have a BOOL in the appdelegate and set this to true when you want to check for the user being idle and setting this to false when you have moved to the idle view. Then in the TIMERUIApplication in the idleTimerExceeded method have an if statement as below. In the viewDidload view of all the views where you want to check for the user beginning idle you set the appdelegate.idle to true, if there are other views where you do not need to check for the user being idle you can set this to false.

-(void)idleTimerExceeded{
          AppDelegate *appdelegate = [[UIApplication sharedApplication] delegate];

          if(appdelegate.idle){
            [[NSNotificationCenter defaultCenter] postNotificationName: kApplicationDidTimeOutNotification object:nil]; 
          }
}
David
  • 111
  • 1
  • 5
  • Good call, David. Thanks for following up with that idea! I'm not able to implement this fix into the app that was originally designed with the code above, but I will definitely reference it on any future projects where I use the timer technique. – BobbyScon Jan 27 '13 at 20:39
  • No Problem BobbyScon. I am currently working on an app that also requires this feature, idle activity timer, and I found the way you had implemented up the idle timer much more effect that the way I had originally set up my own. Glad to be able to contribute in some small way. – David Jan 28 '13 at 15:43
4

Swift 3 example here

  1. create a class like .

     import Foundation
     import UIKit
    
     extension NSNotification.Name {
         public static let TimeOutUserInteraction: NSNotification.Name = NSNotification.Name(rawValue: "TimeOutUserInteraction")
       }
    
    
      class InterractionUIApplication: UIApplication {
    
      static let ApplicationDidTimoutNotification = "AppTimout"
    
      // The timeout in seconds for when to fire the idle timer.
       let timeoutInSeconds: TimeInterval = 15//15 * 60
    
          var idleTimer: Timer?
    
      // Listen for any touch. If the screen receives a touch, the timer is reset.
      override func sendEvent(_ event: UIEvent) {
         super.sendEvent(event)
       // print("3")
      if idleTimer != nil {
         self.resetIdleTimer()
     }
    
        if let touches = event.allTouches {
           for touch in touches {
              if touch.phase == UITouchPhase.began {
                self.resetIdleTimer()
             }
         }
      }
    }
     // Resent the timer because there was user interaction.
    func resetIdleTimer() {
      if let idleTimer = idleTimer {
        // print("1")
         idleTimer.invalidate()
     }
    
          idleTimer = Timer.scheduledTimer(timeInterval: timeoutInSeconds, target: self, selector: #selector(self.idleTimerExceeded), userInfo: nil, repeats: false)
      }
    
        // If the timer reaches the limit as defined in timeoutInSeconds, post this notification.
       func idleTimerExceeded() {
          print("Time Out")
    
       NotificationCenter.default.post(name:Notification.Name.TimeOutUserInteraction, object: nil)
    
         //Go Main page after 15 second
    
        let appDelegate = UIApplication.shared.delegate as! AppDelegate
       appDelegate.window = UIWindow(frame: UIScreen.main.bounds)
        let mainStoryboard: UIStoryboard = UIStoryboard(name: "Main", bundle: nil)
       let yourVC = mainStoryboard.instantiateViewController(withIdentifier: "ViewController") as! ViewController
      appDelegate.window?.rootViewController = yourVC
      appDelegate.window?.makeKeyAndVisible()
    
    
       }
    }
    
  2. create another class named main.swift paste bellow code

    import Foundation
       import UIKit
    
       CommandLine.unsafeArgv.withMemoryRebound(to: UnsafeMutablePointer<Int8>.self, capacity: Int(CommandLine.argc))
        {    argv in
                _ = UIApplicationMain(CommandLine.argc, argv, NSStringFromClass(InterractionUIApplication.self), NSStringFromClass(AppDelegate.self))
            }
    
  3. don't forget to Remove @UIApplicationMain from AppDelegate

  4. Swift 3 complete source code is given to GitHub. GitHub link:https://github.com/enamul95/UserInactivity

Enamul Haque
  • 3,249
  • 1
  • 20
  • 32
  • It will crash when i am uninstalling application ... So applicationDidFinishLaunching is not called ... Is there any way to solve this ? – Mili Shah Nov 15 '17 at 04:01
2

Swift 3.0 Conversion of the subclassed UIApplication in Vanessa's Answer

class TimerUIApplication: UIApplication {
static let ApplicationDidTimoutNotification = "AppTimout"

    // The timeout in seconds for when to fire the idle timer.
    let timeoutInSeconds: TimeInterval = 5 * 60

    var idleTimer: Timer?

    // Resent the timer because there was user interaction.
    func resetIdleTimer() {
        if let idleTimer = idleTimer {
            idleTimer.invalidate()
        }

        idleTimer = Timer.scheduledTimer(timeInterval: timeoutInSeconds, target: self, selector: #selector(TimerUIApplication.idleTimerExceeded), userInfo: nil, repeats: false)
    }

    // If the timer reaches the limit as defined in timeoutInSeconds, post this notification.
    func idleTimerExceeded() {
        NotificationCenter.default.post(name: NSNotification.Name(rawValue: TimerUIApplication.ApplicationDidTimoutNotification), object: nil)
    }


    override func sendEvent(_ event: UIEvent) {

        super.sendEvent(event)

        if idleTimer != nil {
            self.resetIdleTimer()
        }

        if let touches = event.allTouches {
            for touch in touches {
                if touch.phase == UITouchPhase.began {
                    self.resetIdleTimer()
                }
            }
        }

    }
}
Alan
  • 8,681
  • 12
  • 48
  • 90