Tamas
Tamas

Reputation: 3452

Hiding Edit Menu of a SwiftUI / MacOS app

My MacOS app doesn't have any text editing possibilities. How can I hide the Edit menu which is added to my app automatically? I'd prefer to do this in SwiftUI.

I would expect the code below should work, but it doesn't.

@main
struct MyApp: App {

var body: some Scene {
    WindowGroup {
        ContentView()
    }.commands {
        CommandGroup(replacing: .textEditing) {}
    }
}

}

Upvotes: 4

Views: 3555

Answers (6)

DudeOnRock
DudeOnRock

Reputation: 3831

Neither of the answers suggesting KVO nor answers suggesting responding to application lifecycle methods (other than applicationDidFinishLaunching) worked for me (the result was either glitchy, delayed or sometimes just plain wouldn't work.)

What did end up working for me (targeting macOS 14.0) is just HIDING the offending main menu items:

@objc
public class AppDelegate: NSObject, NSApplicationDelegate {
    public func applicationDidFinishLaunching(_ notification: Notification) {
        hideUnusedMenuItems()
    }
}

private extension AppDelegate {
    func hideUnusedMenuItems() {
        ["File", "Edit", "Window", "View"]
            .compactMap { NSApp.mainMenu?.item(withTitle: $0) }
            .forEach { $0.isHidden = true }
    }
}

and then in your SwiftUI App struct:

@main
struct MyMenuItemHidingApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    // ...
}

Fun fact: The same works with any of the main-menu's submenu-items.

Still obviously not great that menu items have to be found by name, which will obviously break this implementation when the user has a none-english version of macOS...

I'll die on that hill another day.

Upvotes: 0

Chintan Ghate
Chintan Ghate

Reputation: 189

You should be able to hide whole menus by modifying @waggles's answer a bit.

class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {

    // SwiftUI updates menu on occlusion state change
    func applicationDidChangeOcclusionState(_ notification: Notification) {
        ["Edit", "View"].forEach { name in
            NSApp.mainMenu?.item(withTitle: name).map { NSApp.mainMenu?.removeItem($0) }
        }
    }
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        if let app = notification.object as? NSApplication,  app.windows.count > 0 {
            // For some reason if window delegate is not set here, 
            // occulsionState notification fires with a delay on 
            // window unhide messing up the menu-update timing.
            app.windows.first?.delegate = self
        }
    }
}

Upvotes: 0

synapticloop
synapticloop

Reputation: 124

For those of you that are looking for any updates on this - have a look at this question that I asked (and answered myself):

SwiftUI Update the mainMenu [SOLVED] kludgey

The way I got around it was to put it in a DispatchQueue.main.async closure in the AppDelegate applicationWillUpdate function

import Foundation
import AppKit

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

            let editMenu: NSMenuItem? = currentMainMenu?.item(withTitle: "Edit")
            if nil != editMenu {
                NSApp.mainMenu?.removeItem(editMenu!)
            }
        }
    }
}

It took me a good 4 days of searching and attempting things :) - typical that it came down to a 2 line code change

Upvotes: 2

waggles
waggles

Reputation: 2854

The current suggestions failed for me when SwiftUI updated the body of a window.

Solution:

Use KVO and watch the NSApp for changes on \.mainMenu. You can remove whatever you want after SwiftUI has its turn.

@objc
class AppDelegate: NSObject, NSApplicationDelegate {
    
    var token: NSKeyValueObservation?
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        
        // Remove a single menu
        if let m = NSApp.mainMenu?.item(withTitle: "Edit") {
            NSApp.mainMenu?.removeItem(m)
        }

        // Remove Multiple Menus
        ["Edit", "View", "Help", "Window"].forEach { name in
            NSApp.mainMenu?.item(withTitle: name).map { NSApp.mainMenu?.removeItem($0) }
        }
        
        

        // Must remove after every time SwiftUI re adds
        token = NSApp.observe(\.mainMenu, options: .new) { (app, change) in
            ["Edit", "View", "Help", "Window"].forEach { name in
                NSApp.mainMenu?.item(withTitle: name).map { NSApp.mainMenu?.removeItem($0) }
            }

            // Remove a single menu
            guard let menu = app.mainMenu?.item(withTitle: "Edit") else { return }
            app.mainMenu?.removeItem(menu)
        }
    }
}

struct MarblesApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some View { 
        //... 
    }
}

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.

This seems like a very fragile solution. There should be a way to tell SwiftUI to not touch the NSApp.mainMenu or enable more customization. Or it seems SwiftUI should check that it owned the previous menu (the menu items are SwiftUI.AppKitMainMenuItem). Or I'm missing some tool they've provided. Hopefully this is fixed in the WWDC beta?

(In Xcode 13.4.1 with Swift 5 targeting macOS 12.3 without Catalyst.)

Upvotes: 6

Daniel Neades
Daniel Neades

Reputation: 121

For native (Cocoa) apps

It is possible to remove application menus using an NSApplicationDelegate. This approach may break in future macOS versions (e.g. if the position of the Edit menu were to change), but does currently work:

class MyAppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
  let indexOfEditMenu = 2
   
  func applicationDidFinishLaunching(_ : Notification) {
    NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
  }
}


@main
struct MyApp: App {
  @NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate

  var body: some Scene {
    WindowGroup {
      ContentView()
    }.commands {
      // ...
    }
  }
}

For Catalyst (UIKit) apps

For Catalyst-based macOS apps, the approach is similar to that above, except that a UIApplicationDelegate deriving from UIResponder is used:

class MyAppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
   override func buildMenu(with builder: UIMenuBuilder) {
      /// Only operate on the main menu bar.
      if builder.system == .main {
         builder.remove(menu: .edit)
      }
   }
}


@main
struct MyApp: App {
  @UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate

  var body: some Scene {
    WindowGroup {
      ContentView()
    }.commands {
      // ...
    }
  }
}

Upvotes: 1

ChrisR
ChrisR

Reputation: 12165

To my knowledge you cannot hide the whole menu, you can just hide element groups inside of it:

    .commands {
        CommandGroup(replacing: .pasteboard) { }
        CommandGroup(replacing: .undoRedo) { }
    }

Upvotes: 7

Related Questions