检测用户何时停止在 SwiftUI (macOS) 中的 TextEditor 上打字

Detect when a user stopped typing on a TextEditor in SwiftUI (macOS)

我正在将 TextEditor 的文本保存到文本文件中。我首先使用 TextEditor 的第一行作为文件名创建文件,然后将后续更新保存在该文件中。该代码依赖于 .onChange 操作。这是一个挑战,因为我正在为 TextEditor.

第一行中使用类型的每个字符创建一个文件

有没有办法检测用户何时停止输入或组件空闲,然后创建文件并保存文本?这是在 macOS Big Sur 上。

我找不到任何可以使用的操作。视图代码如下:

    @EnvironmentObject private var data: DataModel
    var note: NoteItem        
    @State var text: String
    
    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                TextEditor(text: $text)
                    //.onTapGesture {
                    // tried with this....
                    //    print("stopped typing")
                    //}
                    .onChange(of: text, perform: { value in
                        guard let index = data.notes.firstIndex(of: note) else { return }
                        data.notes[index].text = value
                        data.notes[index].date = Date()
                        data.notes[index].filename = value.components(separatedBy: NSCharacterSet.newlines).first!
                        saveFile(filename: data.notes[index].filename, contents: data.notes[index].text)
                    })                    
            }
        }

对于这种情况,Combine 有 debounce。它适用于 Publisher,只有在 debounceFor 指定时间内没有收到新值时才将值传递到链中。

这正是您所需要的:如果用户在一秒钟内没有输入任何内容,您应该保存文本。

请考虑以下事实:如果用户关闭视图或最小化应用程序,您可能不会保存文本状态。对于这些情况,您必须复制 onDisappear 中的保存逻辑并监听 willEnterForegroundNotification 通知。

我基于debounce创建了一个在SwiftUI中易于使用的修改器:

import Combine

extension View {
    func onDebouncedChange<V>(
        of binding: Binding<V>,
        debounceFor dueTime: TimeInterval,
        perform action: @escaping (V) -> Void
    ) -> some View where V: Equatable {
        modifier(ListenDebounce(binding: binding, dueTime: dueTime, action: action))
    }
}

private struct ListenDebounce<Value: Equatable>: ViewModifier {
    @Binding
    var binding: Value
    @StateObject
    var debounceSubject: ObservableDebounceSubject<Value, Never>
    let action: (Value) -> Void

    init(binding: Binding<Value>, dueTime: TimeInterval, action: @escaping (Value) -> Void) {
        _binding = binding
        _debounceSubject = .init(wrappedValue: .init(dueTime: dueTime))
        self.action = action
    }

    func body(content: Content) -> some View {
        content
            .onChange(of: binding) { value in
                debounceSubject.send(value)
            }
            .onReceive(debounceSubject) { value in
                action(value)
            }
    }
}

private final class ObservableDebounceSubject<Output: Equatable, Failure>: Subject, ObservableObject where Failure: Error {
    private let passthroughSubject = PassthroughSubject<Output, Failure>()

    let dueTime: TimeInterval

    init(dueTime: TimeInterval) {
        self.dueTime = dueTime
    }

    func send(_ value: Output) {
        passthroughSubject.send(value)
    }

    func send(completion: Subscribers.Completion<Failure>) {
        passthroughSubject.send(completion: completion)
    }

    func send(subscription: Subscription) {
        passthroughSubject.send(subscription: subscription)
    }

    func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
        passthroughSubject
            .removeDuplicates()
            .debounce(for: .init(dueTime), scheduler: RunLoop.main)
            .receive(subscriber: subscriber)
    }
}

用法:

@EnvironmentObject private var data: DataModel
var note: NoteItem
@State var text: String

var body: some View {
    HStack {
        VStack(alignment: .leading) {
            TextEditor(text: $text)
                //.onTapGesture {
                // tried with this....
                //    print("stopped typing")
                //}
                .onDebouncedChange(
                    of: $text,
                    debounceFor: 1 // TimeInterval, i.e. sec
                ) { value in
                    guard let index = data.notes.firstIndex(of: note) else {
                        return
                    }
                    data.notes[index].text = value
                    data.notes[index].date = Date()
                    data.notes[index].filename = value.components(separatedBy: NSCharacterSet.newlines).first!
                    saveFile(filename: data.notes[index].filename, contents: data.notes[index].text)
                }
        }
    }
}