Swiftui MACos Commands (Menu Bar) and View

SwiftUI macOS Commands (menu bar) and View

Because Views in SwiftUI are transient, you can't hold a reference to a specific instance of ContentView to call a function on it. What you can do, though, is change part of your state that gets passed down to the content view.

For example:

@main
struct ExampleApp: App {
@StateObject var appState = AppState()

var body: some Scene {
WindowGroup {
ContentView(appState: appState)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}.commands {
CommandMenu("First menu") {
Button("Action!") {
appState.textToDisplay = "\(Date())"
}
}
}
}
}

class AppState : ObservableObject {
@Published var textToDisplay = "(not clicked yet)"
}

struct ContentView: View {
@ObservedObject var appState : AppState

var body: some View {
Text(appState.textToDisplay)
}
}

Note that the .commands modifier goes on WindowGroup { }

In this example, AppState is an ObservableObject that holds some state of the app. It's passed through to ContentView using a parameter. You could also pass it via an Environment Object (https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-environmentobject-to-share-data-between-views)

When the menu item is clicked, it sets textToDisplay which is a @Published property on AppState. ContentView will get updated any time a @Published property of AppState gets updated.

This is the general idea of the pattern you'd use. If you have a use case that isn't covered by this pattern, let me know in the comments.

Updates, based on your comments:

import SwiftUI
import Combine

@main
struct ExampleApp: App {
@StateObject var appState = AppState()

var body: some Scene {
WindowGroup {
ContentView(appState: appState)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}.commands {
CommandMenu("First menu") {
Button("Action!") {
appState.textToDisplay = "\(Date())"
}
Button("Change background color") {
appState.contentBackgroundColor = Color.green
}
Button("Toggle view") {
appState.viewShown.toggle()
}
Button("CustomCopy") {
appState.customCopy.send()
}
}
}
}
}

class AppState : ObservableObject {
@Published var textToDisplay = "(not clicked yet)"
@Published var contentBackgroundColor = Color.clear
@Published var viewShown = true

var customCopy = PassthroughSubject<Void,Never>()
}

class ViewModel : ObservableObject {
@Published var text = "The text I have here"
var cancellable : AnyCancellable?

func connect(withAppState appState: AppState) {
cancellable = appState.customCopy.sink(receiveValue: { _ in
print("Do custom copy based on my state: \(self.text) or call a function")
})
}
}

struct ContentView: View {
@ObservedObject var appState : AppState
@State var text = "The text I have here"
@StateObject private var viewModel = ViewModel()

var body: some View {
VStack {
Text(appState.textToDisplay)
.background(appState.contentBackgroundColor)
if appState.viewShown {
Text("Shown?")
}
}
.onReceive(appState.$textToDisplay) { (newText) in
print("Got new text: \(newText)")
}
.onAppear {
viewModel.connect(withAppState: appState)
}
}
}

In my updates, you can see that I've addressed the question of the background color, showing hiding a view, and even getting a notification (via onReceive) when one of the @Published properties changes.

You can also see how I use a custom publisher (customCopy) to pass along an action to ContentView's ViewModel

Hiding Edit Menu of a SwiftUI / MacOS app

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) { }
}

How to implement multi window with menu commands in SwiftUI for macOS?

Useful links:

  1. How to access NSWindow from @main App using only SwiftUI?
  2. How to access own window within SwiftUI view?
  3. https://lostmoa.com/blog/ReadingTheCurrentWindowInANewSwiftUILifecycleApp/

(this is what I was able to come up with, if anyone has a better idea/approach, please share)

The idea is to create a shared "global" view model that keeps track of opened windows and view models. Each NSWindow has an attribute with a unique windowNumber. When a window becomes active (key), it looks up the view model by the windowNumber and sets it as the activeViewModel.

import SwiftUI

class GlobalViewModel : NSObject, ObservableObject {

// all currently opened windows
@Published var windows = Set<NSWindow>()

// all view models that belong to currently opened windows
@Published var viewModels : [Int:ViewModel] = [:]

// currently active aka selected aka key window
@Published var activeWindow: NSWindow?

// currently active view model for the active window
@Published var activeViewModel: ViewModel?

func addWindow(window: NSWindow) {
window.delegate = self
windows.insert(window)
}

// associates a window number with a view model
func addViewModel(_ viewModel: ViewModel, forWindowNumber windowNumber: Int) {
viewModels[windowNumber] = viewModel
}
}

Then, react on every change on window (when it is being closed and when it becomes an active aka key window):

import SwiftUI

extension GlobalViewModel : NSWindowDelegate {
func windowWillClose(_ notification: Notification) {
if let window = notification.object as? NSWindow {
windows.remove(window)
viewModels.removeValue(forKey: window.windowNumber)
print("Open Windows", windows)
print("Open Models", viewModels)
}
}
func windowDidBecomeKey(_ notification: Notification) {
if let window = notification.object as? NSWindow {
print("Activating Window", window.windowNumber)
activeWindow = window
activeViewModel = viewModels[window.windowNumber]
}
}
}

Provide a way to lookup window that is associated to the current view:

import SwiftUI

struct HostingWindowFinder: NSViewRepresentable {
var callback: (NSWindow?) -> ()

func makeNSView(context: Self.Context) -> NSView {
let view = NSView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}

Here is the view that is updating the global view model with the current window and viewModel:

import SwiftUI

struct ContentView: View {
@EnvironmentObject var globalViewModel : GlobalViewModel

@StateObject var viewModel: ViewModel = ViewModel()

var body: some View {
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)
}
}

TextField("", text: $viewModel.inputText)
.disabled(true)
.padding()
}
}

Then we need to create the global view model and send it to the views and commands:

import SwiftUI

@main
struct multi_window_menuApp: App {

@State var globalViewModel = GlobalViewModel()

var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(self.globalViewModel)
}
.commands {
MenuCommands(globalViewModel: self.globalViewModel)
}

Settings {
VStack {
Text("My Settingsview")
}
}
}
}

Here is how the commands look like, so they can access the currently selected/active viewModel:

import Foundation
import SwiftUI
import Combine

struct MenuCommands: Commands {
var globalViewModel: GlobalViewModel

var body: some Commands {
CommandGroup(after: CommandGroupPlacement.newItem, addition: {
Divider()
Button(action: {
let dialog = NSOpenPanel();

dialog.title = "Choose a file";
dialog.showsResizeIndicator = true;
dialog.showsHiddenFiles = false;
dialog.allowsMultipleSelection = false;
dialog.canChooseDirectories = false;

if (dialog.runModal() == NSApplication.ModalResponse.OK) {
let result = dialog.url
if (result != nil) {
let path: String = result!.path
do {
let string = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
print("Active Window", self.globalViewModel.activeWindow?.windowNumber)
self.globalViewModel.activeViewModel?.inputText = string
}
catch {
print("Error \(error)")
}
}
} else {
return
}
}, label: {
Text("Open File")
})
.keyboardShortcut("O", modifiers: [.command])
})
}
}

All is updated and runnable under this github project: https://github.com/ondrej-kvasnovsky/swiftui-multi-window-menu

SwiftUI macOS respond to menu bar action from sub-view

In ContentView in can be as below

var body: some View {
VStack {
Text("Demo for receiving notifications")
.onReceive(NotificationCenter.default.publisher(for: .undo)) { _ in
/// call undo action
}
.onReceive(NotificationCenter.default.publisher(for: .redo)) { _ in
/// call redo action
}
}
}

Creating a macos windowless menu bar application with SwiftUI

Setting just the Application is agent (UIElement) to YES will not be sufficient. You will also have to change your AppDelegate by adding the following,

  1. A NSPopover
  2. Add a NSStatusItem

into your AppDelegate for things to work

How to make an NSPopover?

  1. Go to your app delegate. ( If you don't have an AppDelegate. Create an AppDelegate class and delegate it into the starting point of your app which will be annotated with @main. Add your AppDelegate as follows )
@main
struct SomeApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
}


  1. After doing so, you can begin making your Menubar app, by changing your Appdelegate to represent the following
class AppDelegate: NSObject, NSApplicationDelegate {

// popover
var popover: NSPopover!

func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view (i.e. the content).
let contentView = ContentView()

// Create the popover and sets ContentView as the rootView
let popover = NSPopover()
popover.contentSize = NSSize(width: 400, height: 500)
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: contentView)
self.popover = popover

// Create the status bar item
self.statusBarItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))

if let button = self.statusBarItem.button {
button.image = NSImage(named: "Icon")
button.action = #selector(togglePopover(_:))
}
}

// Toggles popover
@objc func togglePopover(_ sender: AnyObject?) {
if let button = self.statusBarItem.button {
if self.popover.isShown {
self.popover.performClose(sender)
} else {
self.popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}
}

}


  1. After doing so you should/can set Application is agent(UIElement) to YES

Final Step

This section will be broken down into 2 sections, namely 4.1 and 4.2

  • 4.1 is for those who used AppDelegate life cycle to initialize their project
  • 4.2 is for those who created a project using SwiftUI life cycle.
4.1 - AppDelegate Life cycle

Go to your Main.storyboard and to delete the Window Controller scene If you have the Main.storyboard. This should get rid of the NSWindow that pops up.

Sample Image
(image credit)

4.2 - SwiftUI Life cycle

Here, since you don't have a Storyboard file to delete scenes, at this point your app will launch with NSWindow and NSPopover. To remove the NSWindow that opens, go to your app starting point which is annotated as @main, and make the following changes to the code

@main
struct SomeApp: App {
// Linking a created AppDelegate
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
// IMPORTANT
Settings {
AnyView()
}
}
}

For more info, refer this article



Related Topics



Leave a reply



Submit