Home > Net >  Using state variables with Document app while mulitple documents are open
Using state variables with Document app while mulitple documents are open

Time:08-24

I'm making a Document App and I have a bunch of @State variables for passing around information about what things are selected in different parts of the app. For example:

@main
struct ClaraApp: App {
 @State var activeTab: TabType = .vocab
 @State var selectedVocab: Vocab.ID?
 @State var selectedLearner: Learner.ID?
 @State var selectedLesson: Lesson.ID?
 @State var selectedAttendance: Attendance.ID?
…

Which are then used as Binding variables in the relevant views.

struct VocabView: View{

@Binding var selectedVocab: Vocab.ID?
…

With this, I can refer to the currently selected vocabulary item in a TableView from a function running in the Menu Commands.

This was working fine, until I started playing around with having multiple documents open. I've realised that using State variables in this results in some rather undesirable behaviour where controlling one document ends up controlling any others open.

GIF: Changing the tab on one document changes the tab on the other, changing the selected lesson on one document causes the lesson on the other to be unselected etcetera.

I also have some auxiliary windows to show data (the idea is that these will be on a projected screen). Ideally, changing from one document to another should change the data displayed on them, but it works very inconsistently with my current method. One of the windows wouldn't update at all, it got so confused.

GIF: The vocabulary display window should change as data is edited in the window, but it gets stuck on the data from one document when two are open. The name picker window isn't working at all here.

None of this behaviour is especially surprising. The variables are declared at the App level, so of course they will apply across all open documents. I also need them at that level because I need to pass them to menu commands and across different views.

It occurred to me that one way around this (or at least the first part of this issue) would be to store all these State variables (active tab, selected learner etc.) in the document itself. This would have the added advantage of bringing the user straight back to what they were last doing when they open a document. However, something about making a change to a document every single time you click on an item in a list seems a bit off to me.

Would that be the best practice here? Is there a better way of having a different set of State variables for each open document, which are still accessible at the App level? Is there a way to detect which document currently has focus?

EDIT: Here's a bit more of the code from the app struct, for a little more context.

@main
struct ClaraApp: App {
    @State var activeTab: TabType = .vocab
    @State var selectedVocab: Vocab.ID?
    @State var selectedLearner: Learner.ID?
    @State var selectedLesson: Lesson.ID?
    @State var selectedAttendance: Attendance.ID?

    @State var attendeeList: [String]?    
    @State var vocabReader: [Vocab]?

    var body: some Scene {
        DocumentGroup(newDocument: ClaraDocument(claraDoc:GroupVocab())) { file in
                MainContentView(data: file.$document, activeTab: $activeTab, selectedVocab: $selectedVocab, selectedLearner: $selectedLearner, selectedAttendance: $selectedAttendance, selectedLesson: $selectedLesson, attendeeList: $attendeeList, vocabReader: $vocabReader)
        .focusedSceneValue(\.document, file.$document)                    
        .commands{
            MenuCommands(activeTab: $activeTab, selectedVocab: $selectedVocab, selectedLearner: $selectedLearner, selectedLesson: $selectedLesson, selectedAttendance: $selectedAttendance, attendeeList: $attendeeList)
        }
        WindowGroup("Name Picker"){
                NamePickerView(names: attendeeList ?? [""], selectedLesson: selectedLesson)
        }.handlesExternalEvents(matching: Set(arrayLiteral: "NamePickerWindow"))
        WindowGroup("Vocabulary Display"){
                VocabDisplayView(vocabs: vocabReader ?? [Vocab()])
        }.handlesExternalEvents(matching: Set(arrayLiteral: "VocabDisplayWindow"))
    }
} 

EDIT: Following this solution, I'm making some changes https://stackoverflow.com/a/68334001/2682035 However, currently stuck on some menu command buttons not recognising changes immediately. Will post full solution when I've worked it out.

CodePudding user response:

Move all those states into scene content view, so they will work per-scene (ie. per window), like

@main
struct ClaraApp: App {
  var body: Scene {
    ContentView()    // << root view of window
  }
}
struct ContentView: View {   
     @State var activeTab: TabType = .vocab
     @State var selectedVocab: Vocab.ID?     // << view states here !!
     @State var selectedLearner: Learner.ID?
     @State var selectedLesson: Lesson.ID?
     @State var selectedAttendance: Attendance.ID?
...
}

CodePudding user response:

In the end, I used this answer to fix the issue (so enormous thanks to Ondrej Kvasnovsky!). In the rest of my answer, I'll only refer to things I added to this.

The ViewModel class looks like this:

public class ViewModel: ObservableObject {
    
    @Published var activeTab: TabType = .vocab
    @Published var vocabList = [Vocab()]
    @Published var attendeeList: [String]?
    @Published var selectedVocab: Vocab.ID?
    @Published var selectedLearner: Learner.ID?
    @Published var selectedLesson: Lesson.ID?
    @Published var selectedAttendance: Attendance.ID?
}

This actually makes the App view simpler:

@main
struct ClaraApp: App {
    
    @StateObject var globalViewModel = GlobalViewModel()
            
    var body: some Scene {
        DocumentGroup(newDocument: ClaraDocument(claraDoc:GroupVocab())) { file in
            MainContentView(data: file.$documentt)
                .focusedSceneValue(\.document, file.$document)
                .environmentObject(self.globalViewModel)
        }
        .commands{
            MenuCommands(activeViewModel: globalViewModel.activeViewModel ?? ViewModel())
            }
        WindowGroup("Name Picker"){
            NamePickerView(activeView: globalViewModel.activeViewModel ?? ViewModel())
        }.handlesExternalEvents(matching: Set(arrayLiteral: "NamePickerWindow"))
        WindowGroup("Vocabulary Display"){
            VocabDisplayView(activeView: globalViewModel.activeViewModel ?? ViewModel())
        }.handlesExternalEvents(matching: Set(arrayLiteral: "VocabDisplayWindow"))

    }
}

Now the menu commands and the auxiliary windows just take the Active View Model (in other words, the document with focus) and access the values from there.

For example, here is the Vocabulary Display View (which is shown in an auxiliary window and not for editing the document - only showing information from it)

import SwiftUI

struct VocabDisplayView: View{
    
    @ObservedObject var activeView: ViewModel
    
    var body: some View{

        ZStack{
            Color.accentColor
                .opacity(0.5)
                .ignoresSafeArea()
            ScrollView{
                ForEach(activeView.vocabList.sorted(by: {$0.date > $1.date}), id: \.id){ vocab in
                    VStack{
                        HStack{
                            Text(vocab.word)
                                .font(.system(size: 30, weight: .bold, design: .default))
                                .padding(.leading)
                                .textSelection(.enabled)
                            if vocab.trans != ""{
                                Text("= "   vocab.trans)
                                    .font(.system(size: 25, design: .default))
                                    .textSelection(.enabled)
                            }
                            Spacer()
                        }.frame(alignment: .trailing)
                        if vocab.def != ""{
                            HStack{
                                Text(vocab.def)
                                    .font(.system(size: 25, design: .default))
                                    .padding(.leading)
                                    .textSelection(.enabled)
                                Spacer()
                            }
                        }
                        if vocab.visNote != ""{
                            HStack{
                                Text(vocab.visNote)
                                    .font(.system(size: 20, design: .default).italic())
                                    .padding(.leading)
                                    .textSelection(.enabled)
                                Spacer()
                            }
                        }
                    }
                    .padding(.bottom)
                }
            }
            
            }
        
    }
}

Meanwhile in the views for actually editing the document, I thankfully only had to change the main content view. This passes the other variables (SelectedVocab, SelectedLesson etc.) down from its individual ViewModel, so very little needed to be changed in the sub-views. Here's what that looks like now:

struct MainContentView: View {
    
    @Binding var data: ClaraDocument
    
    @EnvironmentObject var globalViewModel : GlobalViewModel // This only gets modified to add the new ViewModel to its array of ViewModels. 
    
    @StateObject var viewModel: ViewModel  = ViewModel() // This is what we will actually use to set the SelectedVocab etc.

    var body: some View {
    //This HostingWindowFinder view is from the solution mentioned above. The only thing I changed is that I changed the frame size so that it wouldn't take up any space.
        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)
          }
        }
        .frame(width:0, height: 0)

        VStack{
            TabView(selection: $viewModel.activeTab){
                VocabView(vocabs: $data.claraDoc.vocabs, selectedVocab: $viewModel.selectedVocab)
                    .tabItem{
                        Label("Vocabulary", systemImage: "tablecells")
                    }
                    // I'm not particularly keen on this way of updating these values (which are passed to the auxiliary views), but it seems to work well enough in the testing I did. Worse can scenario, you have to close and reopen the window. There's no risk of losing data at least.
                    .onChange(of: data.claraDoc.vocabs, perform: { _ in // when we change something
                        viewModel.vocabList = data.claraDoc.vocabs
                    })
                    .onReceive(globalViewModel.$activeWindow, perform: {_ in // when the window changes
                        viewModel.vocabList = data.claraDoc.vocabs
                    }
                    )
                    .tag(TabType.vocab)
                GroupView(data: $data, selectedLearner: $viewModel.selectedLearner)
                    .tabItem{
                        Label("Group", systemImage: "person.3")
                    }
                    .tag(TabType.group)
                AttendanceView(data: $data, events: events, selectedLesson: $viewModel.selectedLesson, selectedAttendance: $viewModel.selectedAttendance, attendeeList: $viewModel.attendeeList)
                    .tabItem{
                        Label("Attendance", systemImage: "list.dash")
                    }
                    .onChange(of: viewModel.selectedLesson, perform: {selection in
                        if selection != nil{
                            viewModel.attendeeList = data.listAttendees(selection!)
                        }
                    })
                    .onReceive(globalViewModel.$activeWindow, perform: {_ in // when the window changes
                        if viewModel.selectedLesson != nil{
                            viewModel.attendeeList = data.listAttendees(viewModel.selectedLesson!)
                        }
                    })
                    .tag(TabType.attendance)
            }
            .onChange(of: viewModel.activeTab, perform: { value in
                print("Switched to \(viewModel.activeTab) tab.")
            })
            .padding()
        }
    }
}

Still to do: This is slightly out of the remit of the original question (though it is still part of it), but it would be better if the auxiliary windows had direct access to the open document, instead of having to be passed data through the ViewModel. I haven't worked out how to do that yet (FocusedBinding doesn't work because the data disappears as soon as you click on the auxiliary window). I might see how the new Window scene works before I try something else.

  • Related