Home > OS >  SwiftUI Update the mainMenu [SOLVED] kludgey
SwiftUI Update the mainMenu [SOLVED] kludgey

Time:11-15

The Real Question

How do you update the mainMenu in SwiftUI so that it actually works?

I have built a MacOS Document Based application in SwiftUI which includes all of the in-built File menu commands (i.e. Close, Save, Duplicate. Rename... etc.)

Before saving the document, I validate the structure and would like to present a modal dialog to the user if there are any validation errors.

The modal dialog is just a simple OK/Cancel dialog - 'OK' meaning that the user is happy to save the file with validation errors, 'Cancel' would need to stop the save operation.

So the question is: "How do I intercept the in-built 'Save' menu command to present this dialog?

I have tried to overwrite the .saveItem CommandGroup - but this replaces all of the menu items and I only want to override a couple of the commands ('Save' and 'Save As') and don't want to re-implement them all (and I am not sure that I have the skills to do so)

        .commands {
            CommandGroup(replacing: .saveItem) {
                // code goes here - but removes all of the in-built menus
            }
        }

I have tried this solution (In a SwiftUI Document App, how to save a document from within a function)

and have put it into my AppDelegate

    public func applicationDidBecomeActive(_ notification: Notification) {
        let menu = NSApplication.shared.mainMenu!.items.first(where: { $0.title == "File" })!
        let submenu = menu.submenu!.items.first(where: { $0.title == "Save" })!
        submenu.action = #selector(showDialog)
    }

    @objc func showDialog() {
        var retVal: Int = 0

        let thisWindow: NSWindow? = NSApplication.shared.mainWindow

        let nsAlert: NSAlert = NSAlert()
        let cancelButton: NSButton = nsAlert.addButton(withTitle: "Cancel")
        cancelButton.tag = 1

        let okButton: NSButton = nsAlert.addButton(withTitle: "OK")
        okButton.tag = 0

        // The below code is replaced
        nsAlert.beginSheetModal(for: thisWindow!) { modalResponse in
            print(modalResponse)
            retVal = modalResponse.rawValue


            if retVal == 0 {
                print("save")
            } else {
                print("cancel")
            }
        }
    }

However it doesn't actually call the showDialog function.

Edit/Update

I am still having difficulties updating the menus, but in the above example the call to beginModalSheet is incorrect as the process will run in the background. Updated the call to runModal() which will stop any background process writing the file.

    @objc func showDialog() {
        let nsAlert: NSAlert = NSAlert()
        let cancelButton: NSButton = nsAlert.addButton(withTitle: "Cancel")
        cancelButton.tag = 1

        let okButton: NSButton = nsAlert.addButton(withTitle: "OK")
        okButton.tag = 0

        let response: Int = nsAlert.runModal().rawValue
        if response == 0 {
            print("save")
            NSApp.sendAction(#selector(NSDocument.save(_:)), to: nil, from: nil)
        } else {
            print("cancel")
        }
    }

I have read somewhere that you need to set the menu before the window appears, and I have also read that you need to set the menus before the AppDelegate is set.

Yet another edit

See this post Hiding Edit Menu of a SwiftUI / MacOS app

and this comment

Thoughts: SwiftUI either has a bug or they really don't want you to remove the top level menus in NSApp.mainMenu. SwiftUI seems to reset the whole menu with no way to override or customize most details currently (Xcode 13.4.1). The CommandGroup(replacing: .textEditing) { }-esque commands don't let you remove or clear a whole menu. Assigning a new NSApp.mainMenu just gets clobbered when SwiftUI wants even if you specify no commands.

CodePudding user response:

XCode 14.1 Swift 5

After a lot of super frustrating searching an attempts and lots of code - I reduced the problem to being just trying to change the name of the save menu item - If I could do this - then I can change the action for it as well.

Here is how I did it

My Tetsing App is called YikesRedux

Steps:

  1. Register the AppDelegate
  2. Override the applicationWillUpdate method
  3. Put the menu updating in a DispatchQueue.main.async closure
  4. Cry tears of joy that you have solved this problem after days of searching

YikesAppRedux.swift

import SwiftUI

@main
struct YikesReduxApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate // <- Don't forget the AppDelegate

    var body: some Scene {
        DocumentGroup(newDocument: YikesReduxDocument()) { file in
            ContentView(document: file.$document)
        }
    }
}

AppDelegate.swift

import Foundation
import AppKit

public class AppDelegate: NSObject, NSApplicationDelegate {
    public func applicationWillUpdate(_ notification: Notification) {
        DispatchQueue.main.async {
            let currentMainMenu = NSApplication.shared.mainMenu

            let fileMenu: NSMenuItem? = currentMainMenu?.item(withTitle: "File")
            if nil != fileMenu {
                let saveMenu = fileMenu?.submenu!.item(withTitle: "Save")
                if nil != saveMenu {
                    print("updated menu")
                    saveMenu?.title = "Save Updated"
                }
            }
        }
    }
}

I put this down as a bit kludgey - as it runs on every application update (which is not a lot, but you can see the print out in the console "updated menu" when it does occur)

I did try to keep a state variable as to whether the menu was updated, to try and not do it again - but in a multi document window environment you would need to keep track of every window... (Also swift just clobbers the menu whenever it wants - so it didn't work as well as expected.)

I put the menu updating code in almost everywhere I could think of

  • Every single AppDelegate function override
  • init methods for the App, the ContentView
  • on the document read function/write function
  • You name it - I put it in there (I even had a hosting controller, a NSViewRepresentable)

I then removed them one by one until I found the solution.

I would be happy if there was a less kludgey way to do this.

  • Related