SwiftUI inactivity timer
Asked Answered
A

1

2

I want to show a hint after the user hasn't touched a screen for 30 seconds. For this restartInactivityTimer() function needs to be called whenever the screen is touched.

Is there a way to discover any screen touch to restart the timer, but without "consuming" it's tap gesture? Do I really need to call restartInactivityTimer() from every onTapGesture (sub)view closure, like in the code snippet below?

struct MyView: View {
    @State private var inactivityTimer: Timer?

    var body: some View {
        VStack { 
            subView1
            subView2
            subView3
            subView4
        }
        .onAppear {
            restartInactivityTimer()
        }
        .onTapGesture {
            // not reaching here when any subView is touched
            restartInactivityTimer() 
        }
    }

    func restartInactivityTimer() {
        inactivityTimer?.invalidate()
        inactivityTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: false) { _ in
            showHint()
        }
    }

    var subView1: some View {
        // ...
        .onTapGesture {
            // ... other actions for tapping that subview
            restartInactivityTimer()
        }
    }

    // ... other subviews
}
Astounding answered 30/9, 2023 at 9:42 Comment(0)
G
3

Most solutions I see detect user activity by overriding UIApplication.sendEvent (Example). We can't do that easily in SwiftUI, but it is possible if you do method swizzling. That is quite hacky though.

You can swizzle the methods just before your app launches by writing your own entry point.

@main
struct EntryPoint {
    // swizzle here
    static func main() {
        let original = class_getInstanceMethod(UIApplication.self, #selector(UIApplication.sendEvent))!
        let new = class_getInstanceMethod(ActivityDetector.self, #selector(ActivityDetector.mySendEvent))!
        method_exchangeImplementations(original, new)

        // launch your app
        YourApp.main()
    }
    
}

struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

The swizzled method is:

class ActivityDetector: NSObject {
    
    private typealias SendEventFunc = @convention(c) (AnyObject, Selector, UIEvent) -> Void
    
    @objc func mySendEvent(_ event: UIEvent) {
        // call the original sendEvent
        // from: https://mcmap.net/q/975487/-calling-original-function-from-swizzled-function
        unsafeBitCast(
            class_getMethodImplementation(ActivityDetector.self, #selector(mySendEvent)),
            to: SendEventFunc.self
        )(self, #selector(UIApplication.sendEvent), event)

        // send a notification, just like in the non-SwiftUI solutions
        NotificationCenter.default.post(name: .init("UserActivity"), object: nil)
    }
}

Now in your view, you can listen for the notification:

@State var presentAlert = false
@State var timer = Timer.publish(every: 60, on: .current, in: .common).autoconnect()

var body: some View {
    Text("Foo")
        // time is up!
        .onReceive(timer) { _ in
            presentAlert = true
            timer.upstream.connect().cancel()
        }
        // user did something!
        .onReceive(NotificationCenter.default.publisher(for: .init("UserActivity")), perform: { _ in
            timer.upstream.connect().cancel()
            timer = Timer.publish (every: 5, on: .current, in: .common).autoconnect()
        })
        .alert("Foo", isPresented: $presentAlert) {
            Button("OK") {}
        }
}

Using Timer.publish here is kind of wasteful, since you only need one output from the publisher. Consider using a Task for example:

@State var presentAlert = false
@State var task: Task<Void, Error>?

var body: some View {
    Text("Foo")
        .onAppear {
            resetTask()
        }
        .onReceive(NotificationCenter.default.publisher(for: .init("UserActivity")), perform: { _ in
            task?.cancel()
            resetTask()
        })
        .alert("Foo", isPresented: $presentAlert) {
            Button("OK") {}
        }
}

@MainActor
func resetTask() {
    task = Task {
        try await Task.sleep(for: .seconds(60))
        try Task.checkCancellation()
        presentAlert = true
    }
}
Gavrilla answered 30/9, 2023 at 12:28 Comment(3)
Thank you @Gavrilla for your comprehensive answer. The solution presented is more hacky and swizzling than the original solution is tiresome. I was hoping for a clever something like .onScreenActivation() modifier. Maybe it is still ahead of Apple to develop.Astounding
@Astounding Not even the UIKit solutions have “clever something like .onScreenActiviation”. Also, “swizzling” is not an adjective here. It just means exchanging the implementations of two methods.Gavrilla
This should be the accepted answer. Using .onTapGesture will not catch all user input.Manuelmanuela

© 2022 - 2024 — McMap. All rights reserved.