1

I'm saving the text of a TextEditor to a text file. I start by creating the file with the first line of the TextEditor as the filename, and then subsequent updates are saved on that file. The code lives on a .onChange action. This presents a challenge since I'm creating a file for each character the use type on the first line of the TextEditor.

Is there a way to detect when the user stopped typing, or the component is idle, and then create the file and save the text? This is on macOS Big Sur.

I haven't been able to find any action I can use. Code follows for the view:

    @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)
                    })                    
            }
        }
Aleph
  • 465
  • 2
  • 12
  • Just use the date instead of the first line – Leo Dabus Aug 31 '21 at 19:31
  • One possible way would be saving after some time maybe a min, and for being safe putting the save function onDisappear as well. – ios coder Aug 31 '21 at 19:51
  • @swiftPunk Thank you, however, how does this solve the issue? If I need to type the file name as the first line of the text editor? File will always !exist since "hello world" will get h.txt he.txt hel.txt hell.txt hello.txt.... – Aleph Aug 31 '21 at 19:54
  • @Aleph: Well I assumed you have some standards for doing this! Maybe using date or a UUID as well – ios coder Aug 31 '21 at 19:57
  • @swiftPunk I was replying to your original comment about checking if file exists. Now, for the current one, on a timer, I could... I don't know how, but also running the wrist of not saving data. Maybe that would be caught by the onDissapear, if that is avail on macOS. – Aleph Aug 31 '21 at 20:04
  • @swiftPunk also, UUID or date are not good in this case for file names. – Aleph Aug 31 '21 at 20:06
  • @Aleph: The issue you are facing is not complicated, just do not created file onChange! That is the worth thing you can do, use a timer and other mechanism like onDisappear or when sencePhase change, something like this also naming the file with date or mix of string and date would be not a bad idea. – ios coder Aug 31 '21 at 20:09
  • You are welcome, just think about the issue maybe you find even better ways. – ios coder Aug 31 '21 at 20:12
  • Alternatively, I can use date or UUID to create an initial file and rename it once I have text on the TextEdit... Tho, with macOS permissions, I don't know how I will rename that file.... – Aleph Aug 31 '21 at 20:39

1 Answers1

3

Combine has debounce for such cases. It applies to Publisher, and passes the value down the chain only if no new value is received within the debounceFor specified time.

This is exactly what you need: if the user has not typed anything within say one second, you should save the text.

Just consider the fact that, if the user closes the view or minimizes the application, you may not save the text state. For these cases you have to duplicate the saving logic in onDisappear and listen for a willEnterForegroundNotification notification.

I created a modifier based on debounce which is easy to use in 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)
    }
}

Usage:

@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)
                }
        }
    }
}
Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • Wow, all that to detect idle? Let me give it a try... Thank you Philip. – Aleph Sep 01 '21 at 10:16
  • @Aleph I made this modifier to be placed in a separate file, so your view file will not be cluttered. Originally you can follow [this article](https://rhonabwy.com/2021/02/07/integrating-swiftui-bindings-and-combine/) to add debounce directly to your view, or [this answer](https://stackoverflow.com/a/66165075/3585796) to add it to your view model – Phil Dukhov Sep 01 '21 at 11:02
  • I added a new swift file to my project, copied the code and called `.onDebouncedChange()` from the view, but Xcode keeps on telling me it can't find it. I'm missing something for sure, but I'm going to try to add all into the view... – Aleph Sep 02 '21 at 10:25
  • @Aleph does you code highlining works in that new file? I haven't mentionied it, but you also need `import SwiftUI` there. Try building, it should show you an error if there's some – Phil Dukhov Sep 02 '21 at 10:28
  • Got it. Now... I can't get anything to work on any kind of delay or after any time... it just won't call anything... – Aleph Sep 02 '21 at 10:45
  • Sorry for my ignorance here, but is `debounceFor: 1,` a second or a minute? Or what? – Aleph Sep 02 '21 at 10:48
  • I added a breakpoint on Xcode, but it never gets there... – Aleph Sep 02 '21 at 10:55
  • @Aleph I was able to reproduce the case when it doesn't work.. Seems it's not possible to create a stable modifier, so it have to be done inside the view. Check out updated answer – Phil Dukhov Sep 02 '21 at 11:56
  • Hmmm and now `Argument passed to call that takes no arguments`, so I can't pass note to `NoteView`, and when I do that, I can't update the text, nor it detects idle... NoteView is where the code I posted on my question lives. – Aleph Sep 02 '21 at 12:05
  • OK. I give up on SwiftUI. As an old C programmer, this makes no sense to me. Thank you for the help. Massive amounts of hacks to do simple stuff, from detecting idle, to saving files, to focusing on things.... – Aleph Sep 02 '21 at 12:09
  • @Aleph sorry about that, I'm testing in a sample project that's why I used to make stuff private and provide default values. I've updated that – Phil Dukhov Sep 02 '21 at 12:48
  • `Argument passed to call that takes no arguments`. No, making it non private doesn't solve it. Adding this code gets a ton of errors across all the view. I appreciate the help, but I'll try to hire a freelance developers to create this app, a simple, stupid app that has taken me so far 3 weeks since I have to deal with things that used to be super simple in Obj-C, and take impossible hacks in SwiftUI. – Aleph Sep 02 '21 at 13:13
  • It doesn't matter how I try this, it messes up the call to the view. – Aleph Sep 02 '21 at 13:15
  • @Aleph sure it's up to you, it's not just removing private but also replacing default value `var text = "" ` with type: `text: String` – Phil Dukhov Sep 02 '21 at 13:57
  • Thank you Philip. I am confused. My code above is already `text: String`. `@State var text: String`. – Aleph Sep 02 '21 at 14:19
  • I am now even more confused. – Aleph Sep 02 '21 at 14:23
  • @Aleph can you send me pic/text of git giff between current code and the original one? That one which run well but saved on each key press – Phil Dukhov Sep 02 '21 at 14:24
  • The code is above. Just add `struct NoteView: View {` and close it with a `}` – Aleph Sep 02 '21 at 14:29
  • @Aleph oh my bad, I forget that I need to override `init`, so a property should be added there. Updated, should work now – Phil Dukhov Sep 02 '21 at 14:59
  • `Extra argument 'note' in call`. I tried to init like this: `init(note: NoteItem, text: String)` but I keep on getting that error. – Aleph Sep 02 '21 at 15:06
  • I'll drop the whole thing. Apparently this is a bad way to get a filename created and there is no way to safely rename a file at a later stage because more issues with how swift treats files. Overall, bad. – Aleph Sep 02 '21 at 15:09
  • @Aleph I don't have your `NoteItem` code that's why I removed it to build and test. added it now. – Phil Dukhov Sep 02 '21 at 15:10
  • It is on the code I posted on the question: `var note: NoteItem` – Aleph Sep 02 '21 at 15:12
  • @Aleph I finally figured out how to move debounce to a separate modifier, check the updated answer if you're still interested. – Phil Dukhov Sep 06 '21 at 07:07
  • Now you at that! That triggers it. Now to put a lot of try-catch around the save... Thank you! – Aleph Sep 07 '21 at 11:02