iPhone: Detectando inatividade do usuário / tempo ocioso desde o último toque na tela

152

Alguém implementou um recurso em que, se o usuário não toca na tela por um determinado período, você executa uma determinada ação? Estou tentando descobrir a melhor maneira de fazer isso.

Existe este método um pouco relacionado no UIApplication:

[UIApplication sharedApplication].idleTimerDisabled;

Seria bom se você tivesse algo parecido com isto:

NSTimeInterval timeElapsed = [UIApplication sharedApplication].idleTimeElapsed;

Então eu poderia configurar um cronômetro e verificar periodicamente esse valor e executar alguma ação quando ele exceder um limite.

Espero que isso explique o que estou procurando. Alguém já resolveu esse problema ou já pensou em como o faria? Obrigado.

Mike McMaster
fonte
Esta é uma grande pergunta. O Windows tem o conceito de um evento OnIdle, mas acho que é mais sobre o aplicativo que atualmente não manipula nada em sua bomba de mensagens versus a propriedade idleTimerDisabled do iOS, que parece apenas preocupada em bloquear o dispositivo. Alguém sabe se existe algo remotamente próximo do conceito Windows no iOS / MacOSX?
31516 stonedauwg

Respostas:

153

Aqui está a resposta que eu estava procurando:

Faça com que seu aplicativo delegue a subclasse UIApplication. No arquivo de implementação, substitua o método sendEvent: da seguinte maneira:

- (void)sendEvent:(UIEvent *)event {
    [super sendEvent:event];

    // Only want to reset the timer on a Began touch or an Ended touch, to reduce the number of timer resets.
    NSSet *allTouches = [event allTouches];
    if ([allTouches count] > 0) {
        // allTouches count only ever seems to be 1, so anyObject works here.
        UITouchPhase phase = ((UITouch *)[allTouches anyObject]).phase;
        if (phase == UITouchPhaseBegan || phase == UITouchPhaseEnded)
            [self resetIdleTimer];
    }
}

- (void)resetIdleTimer {
    if (idleTimer) {
        [idleTimer invalidate];
        [idleTimer release];
    }

    idleTimer = [[NSTimer scheduledTimerWithTimeInterval:maxIdleTime target:self selector:@selector(idleTimerExceeded) userInfo:nil repeats:NO] retain];
}

- (void)idleTimerExceeded {
    NSLog(@"idle time exceeded");
}

onde maxIdleTime e idleTimer são variáveis ​​de instância.

Para que isso funcione, você também precisa modificar seu main.m para dizer ao UIApplicationMain para usar sua classe delegada (neste exemplo, AppDelegate) como a classe principal:

int retVal = UIApplicationMain(argc, argv, @"AppDelegate", @"AppDelegate");
Mike McMaster
fonte
3
Olá Mike, My AppDelegate está herdando do NSObject. Então, alterou os métodos UIApplication e Implement acima para detectar o usuário ficando ocioso, mas estou recebendo o erro "Finalizando o aplicativo devido à exceção não capturada 'NSInternalInconsistencyException', razão: 'Só pode haver uma instância do UIApplication.' ".. mais alguma coisa que eu preciso fazer ...?
Mihir Mehta
7
Gostaria de acrescentar que a subclasse UIApplication deve ser separada da subclasse
UIApplicationDelegate
Não tenho certeza de como isso funcionará com o dispositivo que entra no estado inativo quando os cronômetros param de disparar?
Anonmys
não funcionará corretamente se eu atribuir o uso da função popToRootViewController para um evento de tempo limite. Isso acontece quando eu mostro UIAlertView, popToRootViewController e pressiono qualquer botão no UIAlertView com um seletor de uiviewController que já foi
acionado
4
Muito agradável! No entanto, essa abordagem cria muitas NSTimerinstâncias se houver muitos toques.
Andreas Ley
86

Eu tenho uma variação da solução de timer ocioso que não requer a subclasse de UIApplication. Ele funciona em uma subclasse UIViewController específica, portanto, é útil se você tiver apenas um controlador de visualização (como um aplicativo ou jogo interativo) ou desejar apenas manipular o tempo limite inativo em um controlador de visualização específico.

Também não recria o objeto NSTimer toda vez que o timer ocioso é redefinido. Ele cria apenas um novo se o timer disparar.

Seu código pode solicitar resetIdleTimeroutros eventos que possam precisar invalidar o timer inativo (como entrada significativa do acelerômetro).

@interface MainViewController : UIViewController
{
    NSTimer *idleTimer;
}
@end

#define kMaxIdleTimeSeconds 60.0

@implementation MainViewController

#pragma mark -
#pragma mark Handling idle timeout

- (void)resetIdleTimer {
    if (!idleTimer) {
        idleTimer = [[NSTimer scheduledTimerWithTimeInterval:kMaxIdleTimeSeconds
                                                      target:self
                                                    selector:@selector(idleTimerExceeded)
                                                    userInfo:nil
                                                     repeats:NO] retain];
    }
    else {
        if (fabs([idleTimer.fireDate timeIntervalSinceNow]) < kMaxIdleTimeSeconds-1.0) {
            [idleTimer setFireDate:[NSDate dateWithTimeIntervalSinceNow:kMaxIdleTimeSeconds]];
        }
    }
}

- (void)idleTimerExceeded {
    [idleTimer release]; idleTimer = nil;
    [self startScreenSaverOrSomethingInteresting];
    [self resetIdleTimer];
}

- (UIResponder *)nextResponder {
    [self resetIdleTimer];
    return [super nextResponder];
}

- (void)viewDidLoad {
    [super viewDidLoad];
    [self resetIdleTimer];
}

@end

(código de limpeza de memória excluído por brevidade.)

Chris Miles
fonte
1
Muito bom. Essa resposta é demais! Bate a resposta marcada como correta, embora eu saiba que foi muito antes, mas esta é uma solução melhor agora.
Chintan Patel
Isso é ótimo, mas encontrei um problema: a rolagem no UITableViews não faz com que o nextResponder seja chamado. Também tentei acompanhar por touchesBegan: e touchesMoved :, mas sem melhorias. Alguma ideia?
Greg Maletic
3
@ GregMaletic: eu tive o mesmo problema, mas finalmente adicionei - (vazio) scrollViewWillBeginDragging: (UIScrollView *) scrollView {NSLog (@ "Começará a arrastar"); } - (vazio) scrollViewDidScroll: (UIScrollView *) scrollView {NSLog (@ "Did Scroll"); [auto resetIdleTimer]; Você tentou isso?
Akshay Aher
Obrigado. Isso ainda é útil. Eu o carreguei para a Swift e funcionou muito bem.
27414 Mark_ewd
Você é uma estrela do rock. kudos
Pras
21

Para swift v 3.1

não se esqueça de comentar esta linha no AppDelegate // @ UIApplicationMain

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 * 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 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 {
        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() {
    NotificationCenter.default.post(name:Notification.Name.TimeOutUserInteraction, object: nil)
   }
} 

crie o arquivo main.swif e adicione-o (o nome é importante)

CommandLine.unsafeArgv.withMemoryRebound(to: UnsafeMutablePointer<Int8>.self, capacity: Int(CommandLine.argc)) {argv in
_ = UIApplicationMain(CommandLine.argc, argv, NSStringFromClass(InterractionUIApplication.self), NSStringFromClass(AppDelegate.self))
}

Observando a notificação em qualquer outra classe

NotificationCenter.default.addObserver(self, selector: #selector(someFuncitonName), name: Notification.Name.TimeOutUserInteraction, object: nil)
Sergey Stadnik
fonte
2
Não entendo por que precisamos de um método de check- if idleTimer != nilin sendEvent()?
Guangyu Wang
Como podemos definir o valor timeoutInSecondsda resposta do serviço da web?
precisa saber é o seguinte
12

Esse tópico foi uma grande ajuda e eu o envolvi em uma subclasse UIWindow que envia notificações. Escolhi as notificações para torná-lo um acoplamento flexível, mas você pode adicionar um delegado com bastante facilidade.

Aqui está a essência:

http://gist.github.com/365998

Além disso, o motivo do problema da subclasse UIApplication é que o NIB está configurado para criar 2 objetos UIApplication, pois contém o aplicativo e o delegado. A subclasse UIWindow funciona muito bem.

Brian King
fonte
1
você pode me dizer como usar seu código? Eu não entendo como chamá-lo
R. Dewi 15/06
2
Funciona muito bem para toques, mas não parece lidar com as entradas do teclado. Isso significa que o tempo limite será excedido se o usuário estiver digitando coisas no teclado da GUI.
Martin Wickman
2
Eu também não consigo entender como usá-lo ... Eu adiciono observadores no meu controlador de exibição e espero que as notificações sejam disparadas quando o aplicativo estiver intocado / ocioso .. mas nada aconteceu ... além de onde podemos controlar o tempo ocioso? como eu quero um tempo ocioso de 120 segundos, para que após 120 segundos o IdleNotification seja acionado, não antes disso.
Ans
5

Na verdade, a ideia de subclasse funciona muito bem. Apenas não faça do seu delegado a UIApplicationsubclasse. Crie outro arquivo que herda de UIApplication(por exemplo, myApp). No IB, defina a classe do fileOwnerobjeto como myAppe em myApp.m, implemente o sendEventmétodo como acima. Em main.m do:

int retVal = UIApplicationMain(argc,argv,@"myApp.m",@"myApp.m")

et voilà!

Roby
fonte
1
Sim, a criação de uma subclasse UIApplication independente parece funcionar bem. Eu deixei o segundo par nil na principal.
Hot Licks
@ Roby, dê uma olhada na minha query stackoverflow.com/questions/20088521/… .
Tirth
4

Acabei de encontrar este problema com um jogo controlado por movimentos, ou seja, com o bloqueio de tela desativado, mas deve ativá-lo novamente no modo de menu. Em vez de um timer, encapsulei todas as chamadas para setIdleTimerDisableduma classe pequena, fornecendo os seguintes métodos:

- (void) enableIdleTimerDelayed {
    [self performSelector:@selector (enableIdleTimer) withObject:nil afterDelay:60];
}

- (void) enableIdleTimer {
    [NSObject cancelPreviousPerformRequestsWithTarget:self];
    [[UIApplication sharedApplication] setIdleTimerDisabled:NO];
}

- (void) disableIdleTimer {
    [NSObject cancelPreviousPerformRequestsWithTarget:self];
    [[UIApplication sharedApplication] setIdleTimerDisabled:YES];
}

disableIdleTimer desativa o timer ocioso, enableIdleTimerDelayed ao entrar no menu ou o que quer que seja executado com o timer ocioso ativo e enableIdleTimeré chamado pelo applicationWillResignActivemétodo do AppDelegate para garantir que todas as suas alterações sejam redefinidas corretamente para o comportamento padrão do sistema.
Escrevi um artigo e forneci o código para a classe Singleton IdleTimerManager Idle Timer Handling in iPhone Games

Kay
fonte
4

Aqui está outra maneira de detectar atividade:

O temporizador é adicionado UITrackingRunLoopModepara poder disparar apenas se houver UITrackingatividade. Ele também tem a vantagem de não enviar spam para todos os eventos de toque, informando se houve atividade nos últimos ACTIVITY_DETECT_TIMER_RESOLUTIONsegundos. Eu nomeei o seletor keepAlive, pois parece um caso de uso apropriado para isso. Obviamente, você pode fazer o que quiser com a informação de que houve atividade recentemente.

_touchesTimer = [NSTimer timerWithTimeInterval:ACTIVITY_DETECT_TIMER_RESOLUTION
                                        target:self
                                      selector:@selector(keepAlive)
                                      userInfo:nil
                                       repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:_touchesTimer forMode:UITrackingRunLoopMode];
Mihai Timar
fonte
como assim? Eu acredito que está claro que você deve fazer o seu seletor "keepAlive" para qualquer necessidade. Talvez eu esteja perdendo o seu ponto de vista?
Mihai Timar
Você diz que essa é outra maneira de detectar atividade, no entanto, isso apenas instancia um iVar que é um NSTimer. Não vejo como isso responde à pergunta do OP.
Jasper
1
O cronômetro é adicionado em UITrackingRunLoopMode, portanto, ele pode ser acionado apenas se houver atividade no UITracking. Ele também tem a vantagem de não enviar spam para todos os eventos de toque, informando se houve atividade nos últimos ACTIVITY_DETECT_TIMER_RESOLUTION segundos. Chamei o seletor de keepAlive, pois parece um caso de uso apropriado para isso. Obviamente, você pode fazer o que quiser com a informação de que houve atividade recentemente.
Mihai Timar
1
Eu gostaria de melhorar esta resposta. Se você puder ajudar com dicas sobre como torná-lo mais claro, seria uma grande ajuda.
Mihai Timar
Eu adicionei sua explicação à sua resposta. Faz muito mais sentido agora.
Jasper
3

Por fim, você precisa definir o que considera inativo - é inativo o resultado do usuário não tocar na tela ou é o estado do sistema se nenhum recurso de computação estiver sendo usado? É possível, em muitos aplicativos, que o usuário esteja fazendo algo, mesmo que não esteja interagindo ativamente com o dispositivo através da tela de toque. Embora o usuário provavelmente esteja familiarizado com o conceito de dispositivo em suspensão e com o aviso de que isso ocorrerá através do escurecimento da tela, não é necessariamente o caso em que eles esperam que algo aconteça se estiverem ociosos - você precisa ter cuidado sobre o que você faria. Mas voltando à declaração original - se você considera o 1º caso a sua definição, não há realmente uma maneira fácil de fazer isso. Você precisaria receber cada evento de toque, transmitindo-o na cadeia de resposta conforme necessário, observando a hora em que foi recebido. Isso fornecerá algumas bases para fazer o cálculo inativo. Se você considerar o segundo caso como sua definição, poderá jogar com uma notificação NSPostWhenIdle para tentar executar sua lógica naquele momento.

wisequark
fonte
1
Só para esclarecer, estou falando sobre interação com a tela. Vou atualizar a pergunta para refletir isso.
Mike McMaster
1
Em seguida, você pode implementar algo em que, sempre que um toque acontece, você atualiza um valor que você verifica, ou até define (e redefine) um timer ocioso para disparar, mas é necessário implementá-lo você mesmo, porque, como o wisequark disse, o que constitui o ocioso varia entre aplicativos diferentes.
Louis Gerbarg
1
Estou definindo "ocioso" estritamente como o tempo desde a última vez que tocamos na tela. Entendo que precisarei implementá-lo eu mesmo; estava apenas imaginando qual seria a melhor maneira de, digamos, interceptar os toques na tela ou se alguém conhece um método alternativo para determinar isso.
Mike McMaster
3

Existe uma maneira de fazer esse aplicativo amplamente, sem que os controladores individuais precisem fazer nada. Basta adicionar um reconhecedor de gestos que não cancela toques. Dessa forma, todos os toques serão rastreados no cronômetro, e outros toques e gestos não serão afetados, de modo que ninguém mais precisará saber sobre isso.

fileprivate var timer ... //timer logic here

@objc public class CatchAllGesture : UIGestureRecognizer {
    override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event)
    }
    override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        //reset your timer here
        state = .failed
        super.touchesEnded(touches, with: event)
    }
    override public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesMoved(touches, with: event)
    }
}

@objc extension YOURAPPAppDelegate {

    func addGesture () {
        let aGesture = CatchAllGesture(target: nil, action: nil)
        aGesture.cancelsTouchesInView = false
        self.window.addGestureRecognizer(aGesture)
    }
}

No método de inicialização do delegado do aplicativo, basta ligar para addGesture e está tudo pronto. Todos os toques passarão pelos métodos do CatchAllGesture sem impedir a funcionalidade de outros.

Jlam
fonte
1
Eu gosto desta abordagem, usou-o para um problema semelhante com Xamarin: stackoverflow.com/a/51727021/250164
Wolfgang Schreurs
1
Funciona muito bem, também parece que essa técnica é usada para controlar a visibilidade dos controles da interface do usuário no AVPlayerViewController ( ref da API privada ). A substituição do aplicativo -sendEvent:é um exagero e UITrackingRunLoopModenão lida com muitos casos.
Roman B.
@RomanB. sim exatamente. quando você trabalha com iOS há tempo suficiente, sabe sempre usar o "caminho certo" e esta é a maneira direta de implementar um gesto personalizado como desenvolvedor
Jlam
O que definir o estado como .failed realiza em touchesEnded?
Stonedauwg
Eu gosto dessa abordagem, mas quando tentada, parece apenas capturar toques, e nenhum outro gesto, como deslocar, deslizar, etc, em touchesEnded para onde a lógica de redefinição iria. Isso foi planejado?
Stonedauwg 8/05/19