NEW: My new book Pro SwiftUI is out now – level up your SwiftUI skills today! >>

SOLVED: Correct method to insure save is executed on close or quit

Forums > SwiftUI

In the SwiftUI Lifecycle of a Document based app, what is the correct method to insure that a save is performed before the app quits or a scene/window closes?

I have a variable for changeCount in myDocument which is updated anytime a change is made to the data. The data is a NSAttrributedString saved in a JSON tree struct coming from an NSTextView using representable.

If I select save from the file menu, it saves correctly. If I then quit and relaunch it loads the changed data correctly. But, if I change the data and quit then relaunch, the changes were not saved.

Any links, suggestions?

There ia a real derth of tutorials and exmaples for MacOS and SwiftUI - I have spent several days and can not find a best practice for this.

ANY help would be appreciated.

   

The SwiftUI app life cycle has a scenePhase environment variable that stores the app's current phase: active, inactive, or background. Add the .onChange modifier to the document group in the App struct. Check if the current phase is .background If so, save the document.

@main
struct MySwiftUIApp: App {
    @Environment(.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) { phase in
            if phase == .background {
                // Save here
            } 
        }
    }
} 

Replace the WindowGroup with DocumentGroup for a document-based app. The following article has more details:

Saving Data When Your Swift App Quits

I'm not sure how to trigger a save when closing the document window. I tested a SwiftUI document-based app I'm making to see if I could reproduce the behavior you're seeing. I couldn't reproduce the behavior. The changes were saved when I quit the app and closed the window. I looked at the code to see if I was doing anything special in the document struct, but I didn't find any special code.

   

Thank you for the reply. I do have onChange and it does not trigger when quiting or closing the window in my app.

Does your test app have a flat data model or a linked tree model? I think part of my problems is my data models is a tree structure with a link between the structs.

I have not changed "much" of the basic Document App code. I added coding the NSAttributedString and that is pretty much it.

I "think" the issue is my data model. I have 2 structures which define a tree structure. I am making an sort of word processor for speicific applications and I am making the UI similar to the XCode. The first structure containst the definition of a project and a link to a second structure which contains a tree organization of sections, each section can contain pages/AttributedStrings.

The editor text view is a NSTextView using NSViewRepresentable. I think this is part of the problem. I think the edits which change the text property of the Sidebar struct (below) do not get recognized as a change to the document. Meaning the document does not observe the sidebar data which is linked to in the Project. So, no saves are being triggered automatically.

The part that confuses me, is that a regular open or save from the menu does work, so it would seem the document should "know" about the text property.

I added a "change count" to the project structure but, that did not help.

I agree if I just make an app using the document template it works properly. If I knew what was triggering the Document code to detect a change and cause a save, I could do that but I can't find any suggestions.

public class Project: Codable, ObservableObject, Identifiable {

    public let objectWillChange = ObservableObjectPublisher()

    enum CodingKeys: CodingKey {
        case id
        case name
  ...
        case sidebar
    }
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(id, forKey: .id)
        try container.encode(name, forKey: .name)
...
        try container.encode(sidebar, forKey: .sidebar)
    }

    public required init(from decoder: Decoder) throws {

        let container = try decoder.container(keyedBy: CodingKeys.self)

        id = UUID()
        name = try container.decode(String.self, forKey: .name)
        authorName = try container.decode(String.self, forKey: .authorName)
...
        sidebar = try container.decode(Sidebar.self, forKey: .sidebar)
    }

    @Published public var id: UUID?
    @Published public var name: String
...
    @Published public var selectedSidebar: Sidebar?
    @Published public var changeCount = 0

    init() {
        id = UUID()
        name = "Project Name"
...
        selectedSidebar = nil
    }
}

public class Sidebar: Codable, ObservableObject, Identifiable {

    public let objectWillChange = ObservableObjectPublisher()

    enum CodingKeys: CodingKey {
        case id
        case name
        case type
        case custom
        case iconName
        case text
        case children
    }

    public func encode(to encoder: Encoder) throws {

        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(id, forKey: .id)
        try container.encode(name, forKey: .name)
        try container.encode(type, forKey: .type)
        try container.encode(custom, forKey: .custom)
        try container.encode(iconName, forKey: .iconName)
        try container.encode(text, forKey: .text)
        try container.encode(children, forKey: .children)
    }

    public required init(from decoder: Decoder) throws {

        let container = try decoder.container(keyedBy: CodingKeys.self)

        id = UUID()
        name = try container.decode(String.self, forKey: .name)
        type = try container.decode(String.self, forKey: .type)
        custom = try container.decode(Bool.self, forKey: .custom)
        iconName = try container.decode(String.self, forKey: .iconName)
        text = try container.decode(MyAttributedString?.self, forKey: .text)
        children = try container.decode([Sidebar]?.self, forKey: .children)
    }

    @Published  public var id: UUID?
    @Published  public var name: String
    @Published  public var type: String
    @Published  public var custom: Bool
    @Published  public var iconName: String
    @Published  public var parent: Sidebar?
    @Published  public var text: MyAttributedString?

    @Published  public var children: [Sidebar]?

    public init() {
        id = UUID()
        name = "None Selected"
        type = "type"
        custom = false
        iconName = "Document"
        text = MyAttributedString(string: "init string")
    }
}

public class MyAttributedString : Codable {

    var aString = NSAttributedString(string: "a test init")

    init(nsAttributedString : NSAttributedString) {
        self.aString = nsAttributedString
    }

    init(string: String) {
        self.aString = NSAttributedString(string: string)
    }

    var string: String {
        get {
            return aString.string
        }
        set {
            aString = NSAttributedString(string: newValue)
        }
    }
    public required init(from decoder: Decoder) throws {

        let singleContainer = try decoder.singleValueContainer()
        guard let attributedString = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(singleContainer.decode(Data.self)) as? NSAttributedString else {
            throw DecodingError.dataCorruptedError(in: singleContainer, debugDescription: "Data is corrupted")
        }
        self.aString = attributedString
    }

    public func encode(to encoder: Encoder) throws {
        var singleContainer = encoder.singleValueContainer()
        try singleContainer.encode(NSKeyedArchiver.archivedData(withRootObject: aString, requiringSecureCoding: true))
    }
}

   

I have not used a tree structure with a document-based SwiftUI app so I can't say if the tree structure is the cause of your problem.

In the SwiftUI app I was developing, I had a menu to insert Markdown tags in a text editor, and the document wouldn't be marked as changed until I added a dummy edited property to the document struct and set the property to true when inserting the tags from the menu. You might have to do something similar to trigger a change in the document.

You showed a bunch of code for several classes, but you didn't show the code for your document data structure. What is the relationship between the document data structure and the classes you posted?

You're going to have a tough time finding a solution to this. Apple does not provide much documentation on SwiftUI document-based apps. Most iOS developers do not develop document-based apps so there's not many articles and tutorials about them.

1      

I also added a "changed" var to the Document structure. It didn't help. I was going to change it to a non-Static. I tried static just to make it easy to test. Next I will take out the stataic keyword and try accessing it otherwise.

The Project var is the main struct in my model, which contains a link (shown above) to the sidebar structure which is a nested tree structure. So, it all links back to the Project, here.

As I was writing this and looking at this struct, I got to thinking. When you have linked data, the link (read here) is never changed (well, it can, but that is not my problem yet.) It is the data that the link points at. I expect that is the root of the problem. That FileDocument is happy with the link having not changed.

I am going to try to create a simple test to see if linked to data can trigger a save in a simple case with no nesting.

//
//  HermesDocument.swift
//  Hermes
//
//  Created by Frank Nichols on 1/29/21.
//

import SwiftUI
import UniformTypeIdentifiers

extension UTType {
    static var hermesDocument: UTType {
        UTType(exportedAs: "net.thenichols.Hermes.hermes")
    }
} 

struct HermesDocument: FileDocument {

    var project: Project
    static var changed = 0

    init() {
        project = Project()
    }

    static var readableContentTypes: [UTType] { [.hermesDocument] }

    init(configuration: ReadConfiguration) throws {

        guard let data = configuration.file.regularFileContents  else {
            throw CocoaError(.fileReadCorruptFile)
        }

        let decoder = JSONDecoder()

        do {
            project = try decoder.decode(Project.self, from: data)
            if let sidebar = project.sidebar {
                sidebar.updateParents(sidebar: sidebar)
            } else {
                print("HermesDocument: FileDocument - Error loading document")
            }
        } catch DecodingError.keyNotFound(let key, let context) {
            fatalError("Failed to decode from file due to missing key '\(key.stringValue)' not found – \(context.debugDescription)")
        } catch DecodingError.typeMismatch(_, let context) {
            fatalError("Failed to decode from file  due to type mismatch – \(context.debugDescription)")
        } catch DecodingError.valueNotFound(let type, let context) {
            fatalError("Failed to decode from file  due to missing \(type) value – \(context.debugDescription)")
        } catch DecodingError.dataCorrupted(_) {
            fatalError("Failed to decode from file  because it appears to be invalid JSON")
        } catch {
            fatalError("Failed to decode from file : \(error.localizedDescription)")
        }
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {

        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted

        let data = try encoder.encode(project)
        return .init(regularFileWithContents: data)
    }
}

   

I was able to duplicate this error using the SwiftUI Document Template. I simply replaced the "text" var in the document with a structure containing a text var. And it no longer detected changes in the text. I debugged that and it turned out I was not init'ing the structure correctly.

   

Hacking with Swift is sponsored by Play

SPONSORED Play is the first native iOS design tool created for designers and engineers. You can install Play for iOS and iPad today and sign up to check out the Beta of our macOS app with SwiftUI code export. We're also hiring engineers!

Click to learn more about Play!

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.