SwiftUI Invoke NSPopover with Keyboard Shortcut - macos

I'm building a menu bar application with SwiftUI for macOS Big Sur and can't figure out how to open the popover (the app's main window, since it's a menu bar app) with a keyboard shortcut. I want users to be able to view the window by pressing Command + [a letter] regardless of what else they're doing on their computer (as long as the application is open of course). Here are the main functions and code that control the popover:
#main
struct MenuBarPopoverApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings{
EmptyView()
}
.commands {
MenuBarPopoverCommands(appDelegate: appDelegate)
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
var popover = NSPopover.init()
var statusBarItem: NSStatusItem?
var contentView: ContentView!
override class func awakeFromNib() {}
func applicationDidFinishLaunching(_ notification: Notification) {
print("Application launched")
NSApplication.shared.activate(ignoringOtherApps: true)
contentView = ContentView()
popover.animates = false
popover.behavior = .transient
let contentVc = NSViewController()
contentVc.view = NSHostingView(rootView: contentView.environmentObject(self))
popover.contentViewController = contentVc
statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let itemImage = NSImage(named: "statusBarIcon")
itemImage?.isTemplate = true
statusBarItem?.button?.image = itemImage
statusBarItem?.button?.action = #selector(AppDelegate.togglePopover(_:))
}
#objc func showPopover(_ sender: AnyObject?) {
if let button = statusBarItem?.button {
NSApplication.shared.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}
#objc func closePopover(_ sender: AnyObject?) {
popover.performClose(sender)
}
#objc func togglePopover(_ sender: AnyObject?) {
if popover.isShown {
closePopover(sender)
} else {
showPopover(sender)
}
}
}
And the MenuBarPopoverCommands (the main part of the app is a text editor, so I have a bunch of keyboard shortcuts relating to that):
struct MenuBarPopoverCommands: Commands {
let appDelegate: AppDelegate
init(appDelegate: AppDelegate) {
self.appDelegate = appDelegate
}
var body: some Commands {
CommandMenu("Edit") {
Section {
Button("Cut") {
appDelegate.contentView.editCut()
}.keyboardShortcut(KeyEquivalent("x"), modifiers: .command)
Button("Copy") {
appDelegate.contentView.editCopy()
}.keyboardShortcut(KeyEquivalent("c"), modifiers: .command)
Button("Paste") {
appDelegate.contentView.editPaste()
}.keyboardShortcut(KeyEquivalent("v"), modifiers: .command)
Button("Undo") {
appDelegate.contentView.undo()
}.keyboardShortcut(KeyEquivalent("z"), modifiers: .command)
Button("Redo") {
appDelegate.contentView.redo()
}.keyboardShortcut(KeyEquivalent("z"), modifiers: [.command, .shift])
Button("Bold") {
appDelegate.contentView.bold()
}.keyboardShortcut(KeyEquivalent("b"), modifiers: .command)
Button("Italic") {
appDelegate.contentView.italic()
}.keyboardShortcut(KeyEquivalent("i"), modifiers: .command)
Button("Select All") {
appDelegate.contentView.editSelectAll()
}.keyboardShortcut(KeyEquivalent("a"), modifiers: .command)
}
}
}
}

Swift 5 solution was presented in https://stackoverflow.com/a/58225397/3984522. However, there's a nice package, which does the job https://github.com/soffes/HotKey in a couple of lines of code:
import SwiftUI
import HotKey
#main
struct MenuBarPopoverApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings{
EmptyView()
}
.commands {
MenuBarPopoverCommands(appDelegate: appDelegate)
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
var popover = NSPopover.init()
var statusBarItem: NSStatusItem?
var contentView: ContentView!
let hotKey = HotKey(key: .x, modifiers: [.control, .shift]) // Global hotkey
override class func awakeFromNib() {}
func applicationDidFinishLaunching(_ notification: Notification) {
print("Application launched")
NSApplication.shared.activate(ignoringOtherApps: true)
contentView = ContentView()
popover.animates = false
popover.behavior = .transient
let contentVc = NSViewController()
contentVc.view = NSHostingView(rootView: contentView.environmentObject(self))
popover.contentViewController = contentVc
statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let itemImage = NSImage(systemSymbolName: "eye", accessibilityDescription: "eye")
itemImage?.isTemplate = true
statusBarItem?.button?.image = itemImage
statusBarItem?.button?.action = #selector(AppDelegate.togglePopover(_:))
hotKey.keyUpHandler = { // Global hotkey handler
self.togglePopover()
}
}
#objc func showPopover(_ sender: AnyObject? = nil) {
if let button = statusBarItem?.button {
NSApplication.shared.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}
#objc func closePopover(_ sender: AnyObject? = nil) {
popover.performClose(sender)
}
#objc func togglePopover(_ sender: AnyObject? = nil) {
if popover.isShown {
closePopover(sender)
} else {
showPopover(sender)
}
}
}

Related

Getting currently focused NSTextView in SwiftUI from WindowGroup's CommandGround-item

Question
I've implemented a NSTextView SwiftUI-wrapper (following this great example). There are several of these NSTextViews on my view. In the app's menu, there is a button that should change the currently focused NSTextView's content, e.g:
Is there a way to determine which NSTextView is currently focused? In my current solution, I resorted to storing the NSTextView in a global view model's variable by passing the NSTextView when its "becomeFirstResponder" is called.
However, I'm afraid this solution could either lead to retain cycles or to the NSTextView stored in the view model becoming nil. Is there a cleaner way of doing this? Any help is appreciated!
Current solution/Code
NSTextView
struct TextArea: NSViewRepresentable {
// Source : https://stackoverflow.com/a/63761738/2624880
#Binding var text: NSAttributedString
#Binding var selectedRange: NSRange
#Binding var isFirstResponder: Bool
func makeNSView(context: Context) -> NSScrollView {
context.coordinator.createTextViewStack()
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
if let textArea = nsView.documentView as? NSTextView {
textArea.textStorage?.setAttributedString(self.text)
if !(self.selectedRange.location == textArea.selectedRange().location && self.selectedRange.length == textArea.selectedRange().length) {
textArea.setSelectedRange(self.selectedRange)
}
// Set focus (SwiftUI 👉 AppKit)
if isFirstResponder {
nsView.becomeFirstResponder()
DispatchQueue.main.async {
if ViewModel.shared.focusedTextView != textArea {
ViewModel.shared.focusedTextView = textArea
}
}
} else {
nsView.resignFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, selectedRange: $selectedRange, isFirstResponder: $isFirstResponder)
}
class Coordinator: NSObject, NSTextViewDelegate {
var text: Binding<NSAttributedString>
var selectedRange: Binding<NSRange>
var isFirstResponder: Binding<Bool>
init(text: Binding<NSAttributedString>,
selectedRange: Binding<NSRange>,
isFirstResponder: Binding<Bool>) {
self.text = text
self.selectedRange = selectedRange
self.isFirstResponder = isFirstResponder
}
func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementNSAttributedString text: NSAttributedString?) -> Bool {
defer {
self.text.wrappedValue = textView.attributedString()
self.selectedRange.wrappedValue = textView.selectedRange()
}
return true
}
fileprivate lazy var textStorage = NSTextStorage()
fileprivate lazy var layoutManager = NSLayoutManager()
fileprivate lazy var textContainer = NSTextContainer()
fileprivate lazy var textView: NSTextViewWithFocusHandler = NSTextViewWithFocusHandler(frame: CGRect(), textContainer: textContainer)
fileprivate lazy var scrollview = NSScrollView()
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
self.text.wrappedValue = NSAttributedString(attributedString: textView.attributedString())
self.selectedRange.wrappedValue = textView.selectedRange()
}
func textViewDidChangeSelection(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
DispatchQueue.main.async {
if !(self.selectedRange.wrappedValue.location == textView.selectedRange().location && self.selectedRange.wrappedValue.length == textView.selectedRange().length) {
self.selectedRange.wrappedValue = textView.selectedRange()
}
}
}
func textDidBeginEditing(_ notification: Notification) {
DispatchQueue.main.async {
self.isFirstResponder.wrappedValue = true
}
}
func textDidEndEditing(_ notification: Notification) {
DispatchQueue.main.async {
self.isFirstResponder.wrappedValue = false
}
}
func createTextViewStack() -> NSScrollView {
let contentSize = scrollview.contentSize
textContainer.containerSize = CGSize(width: contentSize.width, height: CGFloat.greatestFiniteMagnitude)
textContainer.widthTracksTextView = true
textView.minSize = CGSize(width: 0, height: 0)
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isVerticallyResizable = true
textView.frame = CGRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height)
textView.autoresizingMask = [.width]
textView.delegate = self
scrollview.borderType = .noBorder
scrollview.hasVerticalScroller = true
scrollview.documentView = textView
scrollview.layer?.cornerRadius = 10
scrollview.drawsBackground = false
textStorage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(textContainer)
return scrollview
}
}
}
class NSTextViewWithFocusHandler: NSTextView {
override func becomeFirstResponder() -> Bool {
// ⚠️ Set self as currently focused TextView (AppKit 👉 SwiftUI)
ViewModel.shared.focusedTextView = self
return super.becomeFirstResponder()
}
}
ViewModel
class ViewModel: ObservableObject {
static let shared = ViewModel()
#Published var attributedTextQuestion: NSAttributedString = NSAttributedString(string: "Initial value question")
#Published var attributedTextAnswer: NSAttributedString = NSAttributedString(string: "Initial value answer")
#Published var selectedRangeQuestion: NSRange = NSRange()
#Published var selectedRangeAnswer: NSRange = NSRange()
#Published var questionFocused: Bool = false // Only works in direction SwiftUI 👉 AppKit
#Published var answerFocused: Bool = false // (dito)
weak var focusedTextView: NSTextView? {didSet{
DispatchQueue.main.async {
self.menuItemEnabled = self.focusedTextView != nil
}
}}
#Published var menuItemEnabled: Bool = false
}
ContentView
struct ContentView: View {
#ObservedObject var model: ViewModel
var body: some View {
VStack {
Text("Question")
TextArea(text: $model.attributedTextQuestion,
selectedRange: $model.selectedRangeQuestion,
isFirstResponder: $model.questionFocused)
Text("Answer")
TextArea(text: $model.attributedTextAnswer,
selectedRange: $model.selectedRangeAnswer,
isFirstResponder: $model.answerFocused)
}
.padding()
}
}
App
#main
struct TextViewMacOSSOFrageApp: App {
#ObservedObject var model: ViewModel = ViewModel.shared
var body: some Scene {
WindowGroup {
ContentView(model: model)
}.commands {
CommandGroup(replacing: .textFormatting) {
Button(action: {
// ⚠️ The currently focused TextView is retrieved and its AttributedString updated
guard let focusedTextView = model.focusedTextView else { return }
let newAttString = NSMutableAttributedString(string: "Value set through menu item")
newAttString.addAttribute(.backgroundColor, value: NSColor.yellow, range: NSRange(location: 0, length: 3))
focusedTextView.textStorage?.setAttributedString(newAttString)
focusedTextView.didChangeText()
}) {
Text("Insert image")
}.disabled(!model.menuItemEnabled)
}
}
}
}

TextField in popover. "This would eventually crash when the view is freed"

I have written an app to display timezones in a NSStatusBar popover. All good so far but when I add a second popover from a button inside the original popover view and include a TextField I start getting problems.
The TextField shows as having focus but it refuses input. If I toggle in and out of the second popover then I get a crash.
...as the first responder for window <_NSPopoverWindow: 0x7fb9c1807bb0>, but it is in a different window ((null))! This would eventually crash when the view is freed. The first responder will be set to nil.
I'm assuming these are related.
I have extracted just the NSStatus bar setup and the two popover views and it is fully repeatable in this toy instance. I use an EventMonitor to catch a click outside of the popover to close it. I don't think that is relevant but I have included it in the toy app for completeness
AppDelegate.swift
import Cocoa
import SwiftUI
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var popover: NSPopover!
var statusBarItem: NSStatusItem!
var eventMonitor: EventMonitor?
var contentView = ContentView()
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the popover
let popover = NSPopover()
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: contentView)
self.popover = popover
// Create the status 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(_:))
}
eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [unowned self] event in
if self.popover.isShown {
closePopover(event)
}
}
eventMonitor?.start()
NSApp.activate(ignoringOtherApps: true)
}
#objc func togglePopover(_ sender: AnyObject?) {
if popover.isShown {
closePopover(sender)
} else {
showPopover(sender)
}
}
func showPopover(_ sender: AnyObject?) {
if let button = statusBarItem.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
self.popover.contentViewController?.view.window?.becomeKey()
}
eventMonitor?.start()
}
func closePopover(_ sender: AnyObject?) {
popover.performClose(sender)
eventMonitor?.stop()
}
}
open class EventMonitor {
fileprivate var monitor: AnyObject?
fileprivate let mask: NSEvent.EventTypeMask
fileprivate let handler: (NSEvent?) -> ()
public init(mask: NSEvent.EventTypeMask, handler: #escaping (NSEvent?) -> ()) {
self.mask = mask
self.handler = handler
}
deinit {
stop()
}
open func start() {
monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) as AnyObject?
}
open func stop() {
if monitor != nil {
NSEvent.removeMonitor(monitor!)
monitor = nil
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#State private var showingPopover = false
#State var value: String = "Initial Value"
var body: some View {
VStack {
Button {
showingPopover = true
} label: {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
}
.popover(isPresented: $showingPopover) {
EditView(value: $value)
}
Text("Hello, world!")
}
.padding()
}
}
struct EditView: View {
#Binding var value: String
var body: some View {
VStack {
TextField("Location ", text: $value) // the location as a string
.multilineTextAlignment(.center)
.lineLimit(1)
.onSubmit {
print("value submit \(value)")
}
}
.padding()
.frame(width:200, height: 50)
}
}
I've searched for information and found references to similar problems with windowed applications and needing to click in the 'window' before clicking in the TextField but that doesn't seem to do anything in this context
I'm building on MacOS 12.6.2 with Target set for 12.0

Trying to implement swiftui preview on uikit project

Why my preview crashes when I apply this code at the bottom of my viewController?
I am on iMac M1. Is it something related to M1? Because I copied the exact code from the tutorial on YouTube.
import UIKit
final class ViewController: UIViewController {
let button = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = .systemBlue
button.setTitle("Button", for: .normal)
view.addSubview(button)
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct UIViewControllerPreview<ViewController: UIViewController>: UIViewControllerRepresentable {
let viewController: ViewController
init(_ builder: #escaping () -> ViewController) {
viewController = builder()
}
// MARK: - UIViewControllerRepresentable
func makeUIViewController(context: Context) -> ViewController {
viewController
}
func updateUIViewController(_ uiViewController: ViewController, context: UIViewControllerRepresentableContext<UIViewControllerPreview<ViewController>>) {
return
}
}
#endif
#if canImport(SwiftUI) && DEBUG
import SwiftUI
let deviceNames: [String] = [
"iPhone SE",
"iPhone 11 Pro Max",
"iPad Pro (11-inch)"
]
#available(iOS 13.0, *)
struct ViewController_Preview: PreviewProvider {
static var previews: some View {
ForEach(deviceNames, id: \.self) { deviceName in
UIViewControllerPreview {
ViewController()
}.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
}
}
#endif

Binding with "RichTextField" (i.e. NSTextView in NSViewRepresentable) - Value resets after redraw, LostFocus, etc

I'm struggling with a custom made RichTextField, i.e. a NSTextView in a NSViewRepresentable, which has the below code (programmed with the help of How to use an NSAttributedString with a ScrollView in SwiftUI?):
Setting the attributedString in the code works and I can change the formatting, but as soon as the application loses the focus, the RichTextField resets to the last value set programmatically:
Furthermore, when using the RichTextField in a List, the application goes into a loop.
RichTextField
import Foundation
import SwiftUI
struct RichTextField: NSViewRepresentable {
typealias NSViewType = NSTextView
#Binding var attributedString: NSMutableAttributedString
var isEditable: Bool
func makeNSView(context: Context) -> NSTextView {
let textView = NSTextView(frame: .zero)
textView.textStorage?.setAttributedString(self.attributedString)
textView.isEditable = isEditable
textView.translatesAutoresizingMaskIntoConstraints = false
textView.autoresizingMask = [.width, .height]
return textView
}
func updateNSView(_ nsView: NSTextView, context: Context) {
nsView.textStorage?.setAttributedString(self.attributedString)
}
}
View
import SwiftUI
struct EditWindow: View {
#ObservedObject var model: EditEntryViewModel
var body: some View {
VStack (alignment: .leading, spacing: 20) {
RichTextField(attributedString: self.$model.answer1, isEditable: true)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Spacer()
}.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
}
}
ViewModel
import Foundation
import SwiftUI
class EditEntryViewModel: ObservableObject {
init(entryID: Int32) {
let entryToUse = db.getEntry(id: entryID)
id = entryToUse!.id
answer1 = entryToUse!.answer1.getNSMutableAttributedStringFromHTML() // Converts HTML from the DB to a NSMutableAttributedString
}
#Published var id: Int32
#Published var answer1: NSMutableAttributedString = NSMutableAttributedString() {
didSet {
print("NEW answer1: " + answer1.string)
}
}
}
I wonder if there is a way to bind the attributedString to the ViewModel?
Thanks a lot for the help.
Not sure if it's the correct way to do it, but I got the binding to work with the following code:
import Foundation
import SwiftUI
struct RichTextField: NSViewRepresentable {
typealias NSViewType = NSTextView
#Binding var attributedString: NSAttributedString
var isEditable: Bool
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSTextView {
let textView = NSTextView(frame: .zero)
textView.textStorage?.setAttributedString(self.attributedString)
textView.isEditable = isEditable
textView.delegate = context.coordinator
textView.translatesAutoresizingMaskIntoConstraints = false
textView.autoresizingMask = [.width, .height]
return textView
}
func updateNSView(_ nsView: NSTextView, context: Context) {
nsView.textStorage!.setAttributedString(self.attributedString)
}
// Source: https://medium.com/fantageek/use-xib-de9d8a295757
class Coordinator: NSObject, NSTextViewDelegate {
let parent: RichTextField
init(_ RichTextField: RichTextField) {
self.parent = RichTextField
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
self.parent.attributedString = textView.attributedString()
}
}
}
(I still get the "cycle detected" error when using the RichTextField in a List (non-editable) though..)

SwiftUI Wrapper for UITextView not updating ObservedObject

I hope I'm wrong, but I have not been able to find a SwiftUI equivalent to an editable
UITextView. So, I built one using UIViewRepresentable. Populating both a SwiftUI Text
and my own view with the ObservableObject works - but updates made in my view are
not propagated to the ObservableObject. I must be missing something important with
the Binding concept. Any guidance would be appreciated.
import SwiftUI
import Combine
struct ContentView: View {
#ObservedObject var myOText: MyOText
var body: some View {
ScrollView {
VStack {
Text("This is a bound Text View")
.padding(.top, 10)
.font(.headline)
Text(myOText.inTheCourse)
.lineLimit(3)
.padding()
Text("This is a multi-line UITextView wrapper:")
.font(.headline)
MultilineTextView(myOText: myOText)
.frame(height: 100)
.padding()
Spacer()
}
}
}
}
struct MultilineTextView: UIViewRepresentable {
#ObservedObject var myOText: MyOText
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.isScrollEnabled = true
view.isEditable = true
view.isUserInteractionEnabled = true
view.textAlignment = .center
view.font = UIFont(name: "Times New Roman", size: 20)
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = myOText.inTheCourse
}
}
class MyOText: ObservableObject {
#Published var inTheCourse: String = "When in the Course of human events, it becomes necessary for one people to dissolve the political bands which have connected them ..."
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(myOText: MyOText())
}
}
Xcode Version 11.2 beta 2 (11B44), iOS 13.
Your multiline text view needs a coordinator to observe the text updates from UITextView
struct MultilineTextView: UIViewRepresentable {
#ObservedObject var myOText: MyOText
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.isScrollEnabled = true
view.isEditable = true
view.isUserInteractionEnabled = true
view.textAlignment = .center
view.font = UIFont(name: "Times New Roman", size: 20)
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = myOText.inTheCourse
}
func makeCoordinator() -> MultilineTextView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextViewDelegate {
var control: MultilineTextView
init(_ control: MultilineTextView) {
self.control = control
}
func textViewDidChange(_ textView: UITextView) {
control.myOText.inTheCourse = textView.text
}
}
}

Resources