`onCopyCommand` on macOS is never being called. How to use it? - macos

Does anyone know how to use onCopyCommand / onPasteCommand modifier in SwiftUI on macOS?
I can't make it being called.
No matter where I put it, 'copy / cut / paste` menu items are disabled.

In case you are using a custom view, you need to make sure it is focused.
Currently, the only way I know to focus a view (in SwiftUI) is with the focusable modifier
import SwiftUI
struct MyView {
var body: some View {
VStack {
Text("can be focused by pressing 'TAB'")
}
.focusable()
.onCopyCommand {
return [NSItemProvider(/* some data */)]
}
}
}
Note:
The only way to focus a custom View with focusable() modifier, is with the TAB key. I guess that mouse clicks are not yet supported.
I have got more Info Here

Related

SwiftUI option key alternate menu items on macOS

In many macOS apps, if you open a menu from the main menubar, you can press the option key to change some of the items.
For example in Safari, I can open the File menu, and there is a "Close Tab" item. Pressing option changes it to "Close Other Tabs".
Is there a way to do this with SwiftUI?
I know how to create basic menus, but I don't see a way to detect the option key.
I think in AppKit you use NSMenuItem's isAlternate property.
struct MyApp: App {
var body: some Scene {
WindowGroup {
...
}.commands {
CommandGroup(replacing: .newItem) {
Button { newFolder() } label: { Text("New Folder") }
.keyboardShortcut("n")
...
}
}
Update to Question
I've tried a few more things, and run into two problems.
I can connect a custom object to the end of the NSResponder chain, and the system will call its flagsChanged method when the user presses the option key. However, it will not call this method when a menu is open.
Even if I find a way to observe the option key while the menu is open, changing state used to build a CommandMenu causes the menu to disappear, not rebuild and stay open.
var body: some Commands {
// If the `isOptionDown` property changes when this menu is open,
// SwiftUI doesn't change the menu, it simply closes it.
// That's not how macOS apps usually behave.
CommandGroup(replacing: .pasteboard) {
Button(...)
if optionKeyWatcher.isOptionDown {
Button(...)
} else {
Button(...)
}

How do I stop double clicking a NavigationLink opening a second window view?

Writing a Macos app. The following code just puts up a simple navigation list. Everything is fine with single clicking the Row links and displaying the Detail row.
If you double click the NavigationLink, another window opens with only the Text view on it. During my testing, there was a button to dismiss the view on the detail window, and if clicked, the original window would close leaving this stripped down view open.
I have to assume that no one else is seeing this since I cannot see anything from other people.
Does anyone have any ideas what would cause this to happen?
import SwiftUI
struct ContentView: View {
var body: some View {
HStack {
NavigationView {
List(0..<100) { row in
NavigationLink(destination: Text("Detail \(row)")) {
Text("Row \(row)")
}
}
}
}
}
}
Just use sidebar list style explicitly
List(0..<100) { row in
// ... content here
}
.listStyle(SidebarListStyle()) // << here !1
Tested with Xcode 13.2 / macOS 12.1

NSTableView selected row flickering while scrolling with arrow keys

I have a view based NSTableView and can't figure out how to work around a visual glitch where the currently selected row flickers while scrolling up or down with the arrow keys.
The selected row should appear 'glued' to either the top or bottom of the view, depending on scroll direction. The Finder shows this correct behavior in list view but a regular table view seems to not behave this way out of the box. I'm confused as to why that is and see no obvious way to circumvent it. Can anybody point me to possible causes / solutions?
Edit No. 1
A cell based NSTableView behaves in the desired way by default, so this is presumably a bug specific to the view based implementation. I don't want to use a cell based table for unrelated reasons though.
Edit No. 2
I've tried making the table view's parent view layer backed, as well as intercepting the up / down arrow keystrokes to do my own scrolling, but so far I haven't been able to eliminate the flickering.
Edit No. 3
I've created a small sample project that reproduces the issue.
It looks like the selection changes and the old and new selected rows are redrawn. Next the selected row is animated up/down. Disabling scroll animation fixes the issue. Scroll animation can be disabled by subclassing NSClipView and overriding scroll(to:).
override func scroll(to newOrigin: NSPoint) {
setBoundsOrigin(newOrigin)
}
It might have some side effects.
Edit
Copied from zrzka's solution, with some adjustments. Scroll animation is temporarily disabled when using the up arrow or down arrow key.
class TableView: NSTableView {
override func keyDown(with event: NSEvent) {
if let clipView = enclosingScrollView?.contentView as? ClipView,
(125...126).contains(event.keyCode) && // down arrow and up arrow
event.modifierFlags.intersection([.option, .shift]).isEmpty {
clipView.isScrollAnimationEnabled = false
super.keyDown(with: event)
clipView.isScrollAnimationEnabled = true
}
else {
super.keyDown(with: event)
}
}
}
class ClipView: NSClipView {
var isScrollAnimationEnabled: Bool = true
override func scroll(to newOrigin: NSPoint) {
if isScrollAnimationEnabled {
super.scroll(to: newOrigin)
} else {
setBoundsOrigin(newOrigin)
documentView?.enclosingScrollView?.flashScrollers()
}
}
}
Did you try change the view ?
scrollView.wantsLayer = true
If you used Interface Builder:
Select the scroll view
Open the View Effects Inspector (or press Cmd-Opt-8)
In the table, find the row for your scroll view and check the box.

How to align a toolbar (or its items) with the leading edge of a split view controller's child?

In iOS, a toolbar can be added to any view. In macOS however, it seems only possible to add a toolbar to a window.
I'm working on an app with a split view controller with a toolbar but the toolbar's items only have a meaning with respect to the right view controller's context.
E.g. let's say I have a text editor of some sort, where the left pane shows all documents (like in the Notes app) and the right pane shows the actual text which can be edited. The formatting buttons only affect the text in the right pane. Thus, it seems very intuitive to place the toolbar within that right pane instead of stretching it over the full width of the window.
Is there some way to achieve this?
(Or is there a good UX reason why this would be a bad practice?)
I've noticed how Apple solved this problem in terms of UX in their Notes app: They still use a full-width toolbar but align the button items that are only related to the right pane with the leading edge of that pane.
So in case, there is no way to place a toolbar in a view controller, how can I align the toolbar items with the leading edge of the right view controller as seen in the screenshot above?
Edit:
According to TimTwoToes' answer and the posts linked by Willeke in the comments, it seems to be possible to use Auto Layout for constraining a toolbar item with the split view's child view. This solution would work if there was a fixed toolbar layout. However, Apple encourages (for a good reason) to let users customize your app's toolbar.
Thus, I cannot add constraints to a fixed item in the toolbar. Instead, a viable solution seems to be to use a leading flexible space and adjust its size accordingly.
Initial Notes
It turns out this is tricky because there are many things that need to be considered:
Auto Layout doesn't seem to work properly with toolbar items. (I've read a few posts mentioning that Apple has classified this as a bug.)
Normally, the user can customize your app's toolbar (add and remove items). We should not deprive the user of that option.
Thus, simply constraining a particular toolbar item with the split view or a layout guide is not an option (because the item might be at a different position than expected or not there at all).
After hours of "hacking", I've finally found a reliable way to achieve the desired behavior that doesn't use any internal / undocumented methods. Here's how it looks:
How To
Instead of a standard NSToolbarFlexibleSpaceItem create an NSToolbarItem with a custom view. This will serve as your flexible, resizing space. You can do that in code or in Interface Builder:
Create outlets/properties for your toolbar and your flexible space (inside the respective NSWindowController):
#IBOutlet weak var toolbar: NSToolbar!
#IBOutlet weak var tabSpace: NSToolbarItem!
Create a method inside the same window controller that adjusts the space width:
private func adjustTabSpaceWidth() {
for item in toolbar.items {
if item == tabSpace {
guard
let origin = item.view?.frame.origin,
let originInWindowCoordinates = item.view?.convert(origin, to: nil),
let leftPane = splitViewController?.splitViewItems.first?.viewController.view
else {
return
}
let leftPaneWidth = leftPane.frame.size.width
let tabWidth = max(leftPaneWidth - originInWindowCoordinates.x, MainWindowController.minTabSpaceWidth)
item.set(width: tabWidth)
}
}
}
Define the set(width:) method in an extension on NSToolbarItem as follows:
private extension NSToolbarItem {
func set(width: CGFloat) {
minSize = .init(width: width, height: minSize.height)
maxSize = .init(width: width, height: maxSize.height)
}
}
Make your window controller conform to NSSplitViewDelegate and assign it to your split view's delegate property.1 Implement the following NSSplitViewDelegate protocol method in your window controller:
override func splitViewDidResizeSubviews(_ notification: Notification) {
adjustTabSpaceWidth()
}
This will yield the desired resizing behavior. (The user will still be able to remove the space completely or reposition it, but he can always add it back to the front.)
1 Note:
If you're using an NSSplitViewController, the system automatically assigns that controller to its split view's delegate property and you cannot change that. As a consequence, you need to subclass NSSplitViewController, override its splitViewDidResizeSubviews() method and notify the window controller from there. Your can achieve that with the following code:
protocol SplitViewControllerDelegate: class {
func splitViewControllerDidResize(_ splitViewController: SplitViewController)
}
class SplitViewController: NSSplitViewController {
weak var delegate: SplitViewControllerDelegate?
override func splitViewDidResizeSubviews(_ notification: Notification) {
delegate?.splitViewControllerDidResize(self)
}
}
Don't forget to assign your window controller as the split view controller's delegate:
override func windowDidLoad() {
super.windowDidLoad()
splitViewController?.delegate = self
}
and to implement the respective delegate method:
extension MainWindowController: SplitViewControllerDelegate {
func splitViewControllerDidResize(_ splitViewController: SplitViewController) {
adjustTabSpaceWidth()
}
}
There is no native way to achieve a "local" toolbar. You would have to create the control yourself, but I believe it would be simpel to make.
Aligning the toolbar items using autolayout is described here. Align with custom toolbar item described by Mischa.
The macOS way is to use the Toolbar solution and make them context sensitive. In this instance the text attribute buttons would enable when the right pane has the focus and disable when it looses the focus.

Mac NSTextField won't resign firstResponder

I have a window with some NSTextFields. When I click in one and edit the value and press return, I want the focus to go back to what it was before. I don't want the blue ring around the text field and I don't want further keystrokes going to that text field. I would have thought this would happen automatically.
I tried these, and none of them work
sender.resignFirstResponder()
sender.window?.makeFirstResponder(nil)
InspectorWindowController.window?.makeFirstResponder(nil)
AnotherWindowController.window?.becomeFirstResponder()
I'm doing these at the end of my IBAction associated with the text field. Maybe I have to do it from somewhere else?
Thanks
I figured this out. I guess the sent action is happening on another thread. So you have to call makeFirstResponder using Dispatch async.
DispatchQueue.main.async { //omg
sender.window?.makeFirstResponder(nil)
}
I needed to dismiss first responder in my SwiftUI macOS app and here what I found working in a way I need:
func controlTextDidEndEditing(_ obj: Notification) {
DispatchQueue.main.async {
guard let window = self.textField.window else {
return
}
// https://stackoverflow.com/questions/5999148/how-to-determine-whether-an-nssearchfield-nstextfield-has-input-focus
// We need to make sure that our text field is still first responder.
guard let textView = window.firstResponder as? NSTextView,
textView.delegate === self.textField else {
return
}
window.makeFirstResponder(nil)
}
}

Resources