SwiftUI Update the mainMenu [SOLVED] kludgey - xcode

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.

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:
Register the AppDelegate
Override the applicationWillUpdate method
Put the menu updating in a DispatchQueue.main.async closure
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

Mapping a Topbar Menu Item in SwiftUI app to opening a specific bundled PDF?

SwiftUI, macOS:
I'm trying to get a menu item to open "default PDF viewer of your choice", with a specific bundled PDF.
Here's what I have thus far:
import SwiftUI
import WebKit
import PDFKit
func Guide1(_ sender: Any) {
if let pdfURL = Bundle.main.url(forResource: "Guide1", withExtension: "pdf"){
if NSWorkspace.shared.open(pdfURL) {
}
}
}
func Guide2(_sender: Any) {
if let pdfURL = Bundle.main.url(forResource: "Guide2", withExtension: "pdf"){
if NSWorkspace.shared.open(pdfURL) {
}
}
}
Now, what I'm missing is how to call these functions.
From previous tutorials, I've found that one way of getting the menu items to "do something" is to ctrl-click and drag the Menu Item entry to First Responder, and then select functions from the list. However, these Guide1 + Guide2 functions are not displayed.
Other tutorials suggest using #IBAction - but the minute I type that into a SwiftUI app, an error tells me to get the #IBaction replaced with "nothing". So I cannot use those either.
So, are these even valid strings for opening a PDF - and if so, how do I connect a dropdown menu item so that these PDFs are opened?
It needs to be added not in SwiftUI view but, for example and simplest, in AppDelegate as below
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
#IBAction func Guide1(_ sender: Any) {
if let pdfURL = Bundle.main.url(forResource: "Guide1", withExtension: "pdf"){
if NSWorkspace.shared.open(pdfURL) {
}
}
}
then in your Main.storyboard (or XIB) just CTRL-drag from menu item to FirstResponder and Guide1 action is there to bind (sometime build might be required before that, but as tested on Xcode 11.2 it just works).

How to detect a Pan Gesture inside a NSTouchBarView

Is it possible to detect a finger pan on a NSTouchBarView?
Sorry for the lack of code but I don't even know where to start.
MacOS is not made for finger touches but the TouchBar is but I do not see how to do it on a NSTouchBarView
I don't know specifically about using NSTouchBarView, but using a pan recognizer in a touch bar usually works like this: create a view, then create a NSPanGestureRecognizer (don't forget to set the target and action) then add the recognizer to the previously created view. Finally, create your NSCustomTouchBarItem and assign the previously created view to the item's view. Quick example in Swift:
func touchBar(_ touchBar: NSTouchBar, makeItemForIdentifier identifier: NSTouchBarItemIdentifier) -> NSTouchBarItem? {
switch identifier {
case NSTouchBarItemIdentifier.yourCustomItem:
return itemWithRecognizer(identifier: identifier)
default:
return nil
}
}
func itemWithRecognizer(identifier: NSTouchBarItemIdentifier) -> NSTouchBarItem {
let customView = NSView()
customView.wantsLayer = true
let recognizer = NSPanGestureRecognizer()
recognizer.target = self
recognizer.action = #selector(doSomething)
customView.addGestureRecognizer(recognizer)
let item = NSCustomTouchBarItem(identifier: identifier)
item.view = customView
return item
}
func doSomething() {
// gesture was activated
}

How to hide the shortcut bar in iOS9

I have a textfield with a hidden keyboard (since I'm using it with bluetooth). However, in iOS9 the shortcut bar keeps appearing.
Is there a way to hide it too?
Thank you so much!
You can pass your textfield name in place of userNameTextField for which you want to remove shortcut bar.
UITextInputAssistantItem* item = [userNameTextField inputAssistantItem];
item.leadingBarButtonGroups = #[];
item.trailingBarButtonGroups = #[];
In Swift 2.0
if #available(iOS 9.0, *) {
let item : UITextInputAssistantItem = yourTextView.inputAssistantItem
item.leadingBarButtonGroups = []
item.trailingBarButtonGroups = []
} else {
// Fallback on earlier versions
}
I had the same issue. And so starts a search of SO. So the above helped me out, but the whole, "if iOS9 thing" might be best framed like this:
if ([self respondsToSelector:#selector(inputAssistantItem)]) {
// iOS9.
UITextInputAssistantItem* item = [self inputAssistantItem];
item.leadingBarButtonGroups = #[];
item.trailingBarButtonGroups = #[];
}
Happily, I'd created a sub-class of a UITextField, (CHTextField) and was in use everywhere. So it was a very easy fix to whack this in the over-ridden "init" method.
Hope it helps.
Alternatively, just create an extension for UITextField in Swift 2.0 like this.
extension UITextField
{
public func hideAssistantBar()
{
if #available(iOS 9.0, *) {
let assistant = self.inputAssistantItem;
assistant.leadingBarButtonGroups = [];
assistant.trailingBarButtonGroups = [];
}
}
}
Then you can just call hideAssistantBar() on any text field you like.
#IBOutlet weak var myTextField: UITextField?;
override public func viewDidLoad() {
super.viewDidLoad();
myTextField?.hideAssistantbar();
}
In Swift 3.0 and 4.0
self.textField.inputAssistantItem.leadingBarButtonGroups.removeAll()
self.textField.inputAssistantItem.trailingBarButtonGroups.removeAll()
An easy way to do this for all text fields in your app is to create a category on UITextInputAssistantItem and override the getters for leadingBarButtonGroups and trailingBarButtonGroups like this:
#implementation UITextInputAssistantItem (RemoveBars)
- (NSArray<UIBarButtonItemGroup *> *)leadingBarButtonGroups
{
return #[];
}
- (NSArray<UIBarButtonItemGroup *> *)trailingBarButtonGroups
{
return #[];
}
#end
This worked for me on iOS 9.x and 8.x, no need for any conditional code.
Be careful with this though, this overrides those properties for EVERYTHING that uses UITextInputAssistantItem
Just to expand on the other answers here. I cobbled together some Swift 2.0 code that will loop through all subviews of a given view and disable the UITextInputAssistantItems for all UITextFields and UISearchBars.
func hideTheAssistantBar(view:UIView) {
//Check this view
for case let textField as UITextField in view.subviews {
let item : UITextInputAssistantItem = textField.inputAssistantItem
item.leadingBarButtonGroups = []
item.trailingBarButtonGroups = []
}
for case let searchBar as UISearchBar in view.subviews {
let item : UITextInputAssistantItem = searchBar.inputAssistantItem
item.leadingBarButtonGroups = []
item.trailingBarButtonGroups = []
}
//Now find this views subviews
let subviews = view.subviews
for subview : AnyObject in subviews {
if subview.isKindOfClass(UIView) {
hideTheAssistantBar(subview as! UIView)
}
}
}
You can then call this function passing in whatever view you would like to start at. I call this inside of my ViewDidLoad() method and pass in self.view like hideTheAssistantBar(self.view).
I actually went a step further for my needs and added this function to a helper class I use for common code. Therefore inside of my viewDidLoad() function I actually just call helper.hideTheAssistantBar(self.view) and then I don't have to put that function in every file.
Hope this helps someone coming along looking for an easy way to remove the assistant bar from all UITextFields and UISearchBars in one fail swoop.
Thanks to #Arkader for the swift code to recursively find all subviews. Swift List Subviews
Just to build on what Pranavan posted because setting the bar button groups to an empty array doesn't seem to work in iOS 12 or 13 using Xcode 11.
let inputAssistantItem = textFieldForTypingText.inputAssistantItem
inputAssistantItem.leadingBarButtonGroups.removeAll()
inputAssistantItem.trailingBarButtonGroups.removeAll()
I placed the above code in the viewDidLoad() function.
You can also give the option to the user:
inputAssistantItem.allowsHidingShortcuts = true
In the case letting the user hide it, if the text field becomes first responder again, they'll have to hide it again.

Detecting key press event in Swift

I'm trying to find a way to detect if a key (on a keyboard) has been pressed on Swift. Any ideas and suggestions will be greatly appreciated.
Since you updated your question and you wanted to know how to do this for a window, here's an answer. Subclass NSWindow and use this subclass instead.
Your custom class should look like this:
import Cocoa
class EditorWindow: NSWindow {
override func keyDown(event: NSEvent) {
super.keyDown(event)
Swift.print("Caught a key down: \(event.keyCode)!")
}
}
If you've made your window in Interface Builder/XCode, click the window object and go to the Attribute Inspector (⌥+⌘+3). The Attribute Inspector will be in the sidebar on the right. Making sure your window is selected in Interface Builder, at the top of the Attribute Inspector in the Custom Class area put your new class in the class input.
In order to communicate the event from the this window class to my app I add a function to the window that accepts a callback function that I then store in an array of callback functions. I get access to this window through the AppDelegate which can get a weak reference to the current main window. Then in the above function I iterate overall the callbacks and call it with the NSEvent as the argument. I also first check to see if any command keys like the option keys are being pressed first through modifierFlags property on the event. It ends up looking like this:
import Cocoa
typealias Callback = (NSEvent) -> ()
class KeyCaptureWindow: NSWindow {
var keyEventListeners = Array<Callback>()
override func keyDown(event: NSEvent) {
if event.modifierFlags.contains(NSEventModifierFlags.CommandKeyMask) {
super.keyDown(event)
return
}
for callback in keyEventListeners {
callback(event)
}
}
func addKeyEventCallback(callback: Callback) {
keyEventListeners.append(callback)
}
}
And then elsewhere in my code I have a line like so:
let appDelegate = NSApplication.sharedApplication().delegate as! AppDelegate
let mainWindow = appDelegate.getWindow()
mainWindow.addKeyEventCallback(handleKeyEvent)
I added the getWindow method to my app delegate class. This method returns the NSWindow cast to KeyCaptureWindow. There may be a better way to do all this but this works for me. Another way to possibly do this is to use first responders and NSView, but that's not how I've been doing it.
You have to override the keyDown-method.
var direction:String = ""
override func keyDown(theEvent: NSEvent!) // A key is pressed
{
if theEvent.keyCode == 123
{
direction = "left" //get the pressed key
}
else if theEvent.keyCode == 124
{
direction = "right" //get the pressed key
}
println("Key with number: \(theEvent.keyCode) was pressed")
}

Transform LSUIElement to foreground application

I have an app which must run all the time (if the user agree whit this).
When the user quit the app, I transform the foreground app into a LSUIElement (the app only has a menu bar icon, the dock icon and the menu disappear).
I have an options in the menu item which works ok and transform the LSUIElement into a foreground app (I use the functions [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular] and [NSApp activateIgnoringOtherApps:YES]).
My problem appear when the user double click on the app. I use again the [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular] in the delegate method applicationWillUnhide:(NSNotification *)notification, and all works well except the menu which doesn't appear. If I go to another app, and then I came back the menu appear. I try different methods but I wasn't able to find a good one.
I want to know is a delegate method which is called when the user double clicks on the app, or what is the function from NSApplication which is called in that moment, because I think using the setActivationPolicy: in the applicationWillUnhide function is to late.
To transform a normal application to a LSUIElement I use
ProcessSerialNumber psn = { 0, kCurrentProcess };
TransformProcessType(&psn, kProcessTransformToUIElementApplication);
And to change it back to foreground :
ProcessSerialNumber psn = { 0, kCurrentProcess };
TransformProcessType(&psn, kProcessTransformToForegroundApplication);
Here is the answer. I have already done the hide/show before I find this question. And this question inspired me to the final answer.
Here what the below code does:
when app starts, app shows in dock and a menubar item shows.
when user clicks the menubar item, app hides and remove from dock.
when user clicks again, the app shows back to dock.
if the app is hidden and user opens the app again from double click or launchpad, the app shows again in dock.
if the app is not hidden but obscured by other apps, clicking the menubar item or relaunching it will put the app to the front.
when a user click the close button on the window, the app removed from dock.
when a user quit the app by cmd+q or from file menu, the app quits and the menubar item quits as well.
I have removed other code that is not directly related.
Other things you may notice:
LSUIElement is not set or set to NO for my Info.plist. If you want to set to yes. You need to set no initiate view controller in storyboard and construct from the window controller yourself.
You will also to deal the logic from left mouse click on menubar item, as you doesn't has the window from the very beginning.
Codes:
import Cocoa
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
private let statusItem = NSStatusBar.system.statusItem(withLength:NSStatusItem.squareLength)
weak private var window:NSWindow? = nil
func applicationDidFinishLaunching(_ aNotification: Notification) {
setupMenubarTray()
self.window = NSApp.orderedWindows.first
NotificationCenter.default.addObserver(self, selector: #selector(windowWillClose(_:)), name: NSWindow.willCloseNotification, object: self.window!)
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if !window!.isVisible {
activeApp()
return false
}
return true
}
}
extension AppDelegate {
#objc func windowWillClose(_ noti:Notification) {
removeFromDock()
}
private func showInDock() {
NSApp.setActivationPolicy(.regular)
}
private func removeFromDock() {
NSApp.setActivationPolicy(.accessory)
}
}
// MARK: - setup menubar button
extension AppDelegate {
private func setupMenubarTray() {
guard let button = statusItem.button else {
fatalError()
}
setTrayIcon(for:button)
button.action = #selector(mouseLeftButtonClicked)
}
private func setTrayIcon(for button:NSStatusBarButton) {
let useMonochromeIcon = UserDefaults.standard.bool(forKey: DefaultsKey.useMonochromeIcon.key)
button.image = NSImage(imageLiteralResourceName: useMonochromeIcon ? "MonochromeIcon" : "TrayIcon")
}
#objc private func mouseLeftButtonClicked() {
if NSApp.isHidden || !window!.isKeyWindow {
self.activeApp()
} else {
self.hide()
}
}
private func activeApp() {
showInDock()
window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
checker.sendNotification()
}
private func hide() {
removeFromDock()
NSApp.hide(nil)
}
}

Resources