1

I'm creating a document-based application where data is represented by TextFields in a TableView (it could also be a List, the same issue occurs). When the app SwiftUI app on an Intel MacBook Air, I get a lot of keyboard lag whenever there are more than a dozen rows in my table. It's present on the Apple Studio too, but less noticeable. I've tried changing the table into a List and LazyVStack, but it doesn't seem to make much difference. Using the Swift UI instrument, it looks to me like every TextField on the page is being redrawn on every keystroke, even though their values haven't changed.

I also tried using a custom TextField with a debounce added in (with this as a starting point). This works well for reducing the lag, but I don't think this is how debouncing is intended to be used and I ended up with some strange behaviour.

I suspect that it is rather the case that I've misunderstood how to using @Binding variables in a Document Based application, or possibly I have misconfigured the Struct where I store the data. So here are the essential parts of my code, which will hopefully make it clear where I have gone wrong without having to run anything.

struct ClaraApp: App {

    @StateObject var globalViewModel = GlobalViewModel()
            
    var body: some Scene {
        DocumentGroup(newDocument: ClaraDocument(claraDoc:GroupVocab())) { file in
            MainContentView(data: file.$document)
                .environmentObject(self.globalViewModel)
        }
}

struct MainContentView: View {

    @Binding var data: ClaraDocument // Binding to the document
    @EnvironmentObject var globalViewModel : GlobalViewModel
    @StateObject var viewModel: ViewModel  = ViewModel()

    var body: some View {
        HostingWindowFinder { window in
          if let window = window {
            self.globalViewModel.addWindow(window: window)
            print("New Window", window.windowNumber)
            self.globalViewModel.addViewModel(self.viewModel, forWindowNumber: window.windowNumber)
            window.becomeKey()
          }
        }
        .frame(width:0, height: 0)
        VStack{
            TabView(selection: $viewModel.activeTab){
                VocabView(vocabs: $data.claraDoc.vocabs, selectedVocab: $viewModel.selectedVocab)
                    .tabItem{
                        Label("Vocabulary", systemImage: "tablecells")
                    }
                    .tag(TabType.vocab)
                //more tabs here
                }
        }
    }
}

struct VocabView: View{
    @Binding var vocabs: [Vocab] // Binding to just the part of the document concerned by this view
    @Binding var selectedVocab: Vocab.ID?

    var body: some View{
        VStack(){
            VocabTable(vocabs: $vocabs, selection: $selectedVocab)
                .padding([.top, .leading, .trailing])
            HStack{
                Button("-"){
                    if selectedVocab != nil{
                        let oldSelectionIndex = vocabs.firstIndex(where: {$0.id == selectedVocab!})
                        if oldSelectionIndex != nil{
                            if oldSelectionIndex! > 0{
                                selectedVocab = vocabs[oldSelectionIndex! - 1].id
                            } else {
                                selectedVocab = nil
                            }
                            vocabs.remove(at: oldSelectionIndex!)
                        }
                    }
                }
                .disabled(selectedVocab == nil)
                Text("\(String(vocabs.count)) entries")
                Button("+"){
                    let newVocab = Vocab(id: UUID(), word: "", def: "", trans: "", visNote: "", hidNote: "", link: Link(linked: false), date: Date())
                    vocabs.append(newVocab)
                    selectedVocab = newVocab.id
                }
            }
        }
    }
}

struct VocabTable: View{
    
    @Binding var vocabs: [Vocab] 
    @Binding var selection: Vocab.ID?
            
    var body: some View{
        Table($vocabs, selection: $selection){
            TableColumn("Word") {vocab in
                TextField("Word", text: vocab.word)
            }
            TableColumn("Definition") {vocab in
                TextField("Definition", text: vocab.def)
            }
            TableColumn("Translation") {vocab in
                TextField("Translation", text: vocab.trans)
            }
            TableColumn("Visible note") {vocab in
                TextField("Visible note", text: vocab.visNote)
            }
            TableColumn("Hidden note") {vocab in
                TextField("Hidden note", text: vocab.hidNote)
            }
            TableColumn("Created") {vocab in
                HStack{
                    Text(vocab.date.wrappedValue, style: .date)
                    Text(vocab.date.wrappedValue, style: .time)
                }
            }
        }
        .onDeleteCommand{
            if selection != nil{
                let oldSelectionIndex = vocabs.firstIndex(where: {$0.id == selection!})
                if oldSelectionIndex != nil{
                    if oldSelectionIndex! > 0{
                        selection = vocabs[oldSelectionIndex! - 1].id
                    } else {
                        selection = nil
                    }
                    vocabs.remove(at: oldSelectionIndex!)
                }
            }
            }
    }
}

// vocab struct which is contained as an array [Vocab] inside the GroupVocab struct

struct Vocab: Identifiable, Codable, Equatable, Hashable {
    let id: UUID
    var word: String
    var def: String
    var trans: String
    var visNote: String
    var hidNote: String
    var date: Date
    
    init(id: UUID = UUID(), word: String? = "", def: String? = "", trans: String? = "", visNote: String? = "", hidNote: String? = "", date: Date? = Date()){
        self.id = id
        self.word = word ?? ""
        self.def = def ?? ""
        self.trans = trans ?? ""
        self.visNote = visNote ?? ""
        self.hidNote = hidNote ?? ""
        self.date = date ?? Date()
    }
    
    static func == (lhs: Vocab, rhs: Vocab) -> Bool {
        return lhs.id == rhs.id && lhs.word == rhs.word && lhs.def == rhs.def && lhs.trans == rhs.trans && lhs.visNote == rhs.visNote && lhs.date == rhs.date
    }
    
}

struct GroupVocab: Codable{
    var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
    var groupName: String = ""
    var vocabs = [Vocab]()
    var learners = [Learner]()
    var lessons = [Lesson]()
    var startDate = Date()
    var endDate = Date()
}

If that doesn't shed any light, here's my attempt at making a minimal example. It isn't nearly as laggy as the actual app, but from what I can tell it exhibits the same problems. Of course, it could be something in my actual app, which is not present in this minimal example, that I have overlooked. For example, I know that the menu bar is redrawn when editing the document, but removing the menu bar doesn't improve performance. So, I'm assuming that the reduced (albeit still present) lag is due to the general baggage of the program and not one specific element that I haven't taken into account. Obviously if there are no obvious problems in the above, I will need to go back and check everything again, but to my knowledge I have already tried removing and readding each part of the application individually to no avail.

Finally, this is what the actual app looks like in use:

Screenshot of the application in use (not just the working example or cut-down code).

  • I'm on an Intel MBP with Ventura Beta and Xcode 14 and with the minimal example afraid I don't see an obvious slow down. So may be something outside of minimal example, possibly down to equipment or version of SwiftUI being used (it has got more performant with newer versions). Besides that, might be worth trying `Vocab` as an `ObservableObject` class. The current struct will be being destroyed and rebuilt every time any of its fields are changed which may be tickling the redraws. (if you go down that route worth adding a Row View and pass it the `Vocab` class instance as a `@ObservedObject` – shufflingb Sep 23 '22 at 14:54
  • I think ObservableObjects can't be codable and therefore can't be saved in a document. But are you suggesting having an ObservableObject *as well as* the original struct, then updating the struct from the ObservableObject? – jaimepapier Sep 23 '22 at 15:22
  • Just a single ObservableObject instead of the struct. Changing to `class Vocab: ObservableObject, Identifiable, Codable, Equatable` (dropped Hashable) compiles with no errors and warnings and seems to run and persist okay for me. – shufflingb Sep 23 '22 at 16:19
  • OK, I'm trying to hold off celebrating too hard because this issue has plagued me since my first day back in classes and I always think I've found the answer when I haven't… but I *think* you may have led to me to the solution. It wasn't letting me make an observableobject codable with published variables (I tried without but it wouldn't always update correctly), but then I found this: https://www.hackingwithswift.com/books/ios-swiftui/adding-codable-conformance-for-published-properties And it seems to work! Absolutely no lag whatsoever. If you write an answer for the question, I'll accept it – jaimepapier Sep 23 '22 at 17:52
  • Been in similiar situations with the celebrating thing - only to discover it's more like wack-a-bug. Hope not case here and still good. Have written up original explanation and also made another suggestion that might want to try if have the time. Good luck with the rest of your classes. – shufflingb Sep 24 '22 at 13:54

1 Answers1

1

As mentioned in the comments on original post.

TL;DR; For those encountering a similar lag issue, the solution here was to replace the declaration of Vocab as a struct with the use of an ObservableObject class, i.e. Vocab's definition becomes class Vocab: ObservableObject, Identifiable, Codable, Equatable.

Might also want to have a look at https://www.hackingwithswift.com/books/ios-swiftui/adding-codable-conformance-for-published-properties if @Published properties in the class have to be Codable

In a bit more detail

When struct Vocab is used each keystroke (because it is a value type) creates a new (original data + change) instance of the Vocab struct.

The problem [0] with this struct new instance is that SwiftUI cannot detect the singular property change and trigger just updating its corresponding TextField [1].

Instead SwiftUI handles each new keystroke driven struct instance as if it is a completely unrelated Vocab object; for which it has to update every TextField in the Table's entry row.

It's the updating of all of the TextFields in the entry row that causes the perceived lag.

By contrast the solution - using an ObservableObject class - enables binding the TextFields to a property on an object where the instance does not change on each keystroke. Consequently SwiftUI is able to detect and update just the individual entry changes.

The final piece in the puzzle is that when using an ObservableObject. The @Published properties that update Views nicely take some extra effort to enable them to conform with the Codeable protocol. For how to add that conformance there is a nice explanation over [here[( https://www.hackingwithswift.com/books/ios-swiftui/adding-codable-conformance-for-published-properties)

Other bits
  1. If running on higher spec machines - or with fewer properties - issues like these can be difficult to spot.
  2. Other approaches might be possible. For instance, if it's practicable within the context of the rest of the app, relaxing Vocab's Equatable compliance [1] might be enough to enable SwiftUI to do something more clever when determining what TextFields need recomputing.

[0] In this context; generally though, preferring value types such as structs is seen as good practice because it reduces the risk of unexpected side-effects.

[1] Possibly this might also be addressable by relaxing the implementation of Equatable conformance on the struct to just being based on id equivalence.

shufflingb
  • 1,847
  • 16
  • 21
  • Thanks for the answer! I will need to update other structs apart from the Vocab one, so I’ll experiment with other possibilities at the same time. – jaimepapier Sep 24 '22 at 19:55