SwiftUI 不活动计时器

SwiftUI inactivity timer

提问人:Voyteck 提问时间:9/30/2023 最后编辑:Voyteck 更新时间:9/30/2023 访问量:76

问:

我想在用户 30 秒未触摸屏幕后显示提示。为此,每当触摸屏幕时都需要调用此函数。restartInactivityTimer()

有没有办法发现任何屏幕触摸来重新启动计时器,但没有“消耗”它的点击手势?我真的需要从每个 onTapGesture(子)视图闭包进行调用,就像下面的代码片段一样吗?restartInactivityTimer()

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
}
SwiftUI 计时器 触摸事件 用户不活动

评论


答:

1赞 Sweeper 9/30/2023 #1

我看到的大多数解决方案都通过覆盖来检测用户活动(示例)。在 SwiftUI 中,我们不能轻易做到这一点,但如果你做方法 swizzling,这是可能的。不过,这很棘手。UIApplication.sendEvent

您可以在应用启动之前通过编写自己的入口点来切换这些方法。

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

swizzled 方法是:

class ActivityDetector: NSObject {
    
    private typealias SendEventFunc = @convention(c) (AnyObject, Selector, UIEvent) -> Void
    
    @objc func mySendEvent(_ event: UIEvent) {
        // call the original sendEvent
        // from: https://stackoverflow.com/a/61523711/5133585
        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)
    }
}

现在,在您的视图中,您可以收听通知:

@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") {}
        }
}

使用 here 有点浪费,因为你只需要发布者的一个输出。例如,请考虑使用 a:Timer.publishTask

@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
    }
}

评论

0赞 Voyteck 10/2/2023
谢谢@Sweeper的全面回答。所提出的解决方案比原始解决方案更棘手和令人厌烦。我希望有一个聪明的东西,比如修饰符。也许它仍然领先于苹果的发展。.onScreenActivation()
0赞 Sweeper 10/2/2023
@Voyteck 甚至 UIKit 解决方案也没有“聪明的东西”。此外,“swizzling”在这里不是形容词。它只是意味着交换两个方法的实现。.onScreenActiviation
0赞 Martin Claesson 12/22/2023
这应该是公认的答案。使用 .onTapGesture 不会捕获所有用户输入。