How to Implement accessibilityCustomActions for VoiceOver on Mac? - swift

I have a button that responds to various mouse clicks (regular click, right click, control+click, option+click, command+click...) to show different popup menus. Since it would be annoying for VoiceOver users to use actual physical mouse, I would like to map those to different VoiceOver actions.
However, I'm not getting the results I expected. Could someone help me to understand better what I'm missing? Here is what I discovered so far.
If I subclass NSButton and override the following functions, they work fine. Except there's one odd thing. If I press vo+command+space to bring up the list of available actions, VoiceOver says Action 1 instead of Show Menu.
override func accessibilityPerformPress() -> Bool {
print("Pressed!")
return true
}
override func accessibilityPerformShowAlternateUI() -> Bool {
print("Show Alternate UI")
return true
}
override func accessibilityPerformShowMenu() -> Bool {
print("Show Menu")
return true
}
In the same NSButton subclass, if I also override accessibilityCustomActions function, "Do Something" never comes up in the list of available actions when I press vo+command+space.
override func accessibilityCustomActions() -> [NSAccessibilityCustomAction]? {
let custom = NSAccessibilityCustomAction(name: "Do Something", target: self, selector: #selector(doSomething))
return [custom]
}
#objc func doSomething() -> Bool {
print("Done something.")
return true
}
If I subclass NSView instead of NSButton, and override the same functions from #1, everything works fine. Unlike first case, even VoiceOver correctly says "Show Menu" for the action from accessibilityPerformShowMenu instead of "Action 1".
in the same NSView subclass, if I override accessibilityCustomActions along with accessibilityPerformPress, accessibilityPerformShowMenu, or accessibilityPerformShowAlternateUI, "Do Something" doesn't come up in the action list.
However, "Do Something" does come up in the action list if I just override accessibilityCustomActions by itself without accessibilityPerformPress, accessibilityPerformShowMenu, and accessibilityPerformShowAlternateUI.
I tried creating another action with the name "Press" that does the same thing when pressing vo+space, and including in the return value of accessibilityCustomActions. However, Vo+space did not trigger the action. Instead, I had to press vo+command+space, and then select "Press". I guess the action just has the name "Press", but it's not actually connected to vo+space. I'm not sure how I can actually make that particular custom action to respond to vo+space.
I would appreciate if someone could help me to implement accessibilityCustomActions as well as accessibilityPerformPress, accessibilityPerformShowMenu, and accessibilityPerformShowAlternateUI together into NSButton.
Thanks so much!

The problem is that you are overriding these AX methods on the NSButton, not the NSButtonCell. For nearly everything to do with accessibility in NSControls, you will want to deal with the NSCell in question. If you use the custom action code you've written above and stick it in a subclass of NSButtonCell used by your button, then it will work.

Related

Passing keyboard events from NSTextView to another

I have NSTextView which pops up a NSPopOver that contains another NSTextView. In some cases, I'd like to pass the keyboard events (arrow keys, mostly) from the "top" text view to the bottom one.
I have overridden keyDown on the topmost text view and tried various ways of forwarding the keyboard event to the view below.
Just calling bottomTextView.keyDown(with: event) doesn't do anything. The closest I've gotten is using window.sendEvent(event).
override func keyDown(with event: NSEvent) {
if (NSLocationInRange(Int(event.keyCode), NSMakeRange(123, 4)) &&
self.string.count == 0 && event.modifierFlags.contains(.shift)) {
// Pass the event through somehow
return
}
super.keyDown(with: event)
}
Through trial and error, I found one way which actually did pass on the events: calling the methods twice.
// Make the bottom text field first responder
self.window?.makeFirstResponder(self.delegate.bottomTextView)
self.window?.makeFirstResponder(self.delegate.bottomTextView)
// Send the keydown event to window
self.window?.sendEvent(event)
self.window?.sendEvent(event)
However, this causes strange issues, such as the topmost text view getting a one-byte string as its contents.
Is there a correct way to actually forward any key events from a text view to another, or is it even possible?

Mutually exclusive radio buttons for MacOS in Swift

I've got five radio buttons, and selecting one should deselect the others.
I've been over a lot of the questions here about radio buttons in Swift, but they're either for iOS or outdated versions of Swift, because Xcode isn't offering me options like ".isSelected". I've got ".isEnabled" but clearly semantics matter here, because "enabled" isn't the same thing as "selected" and it shows.
Writing my code as a series of "if-else" statements along these lines:
func disableUnselectedButtons() {
if Button2.isEnabled == true {
Button1.isEnabled = false
Button3.isEnabled = false
Button4.isEnabled = false
Button5.isEnabled = false
}
}
results in a situation where I can select all five buttons, and can't DEselect any of them after another has been selected. I've tried variations of .on/.off as well, and can't find the right one for this situation.
It's also clumsy as heck to write a method with five if-else statements along those lines. So there's that.
What's the best way to go about implementing this?
If your radio buttons have the same superview and have the same action then they should work as expected.
To set the same action for each of your radio buttons you can do one of the following.
If you are using Storyboards, open both storyboard and related NSViewController swift file. Ctrl-drag your first radio button to the swift file. Then do the same for each of the other radio buttons ensuring you are dragging onto the function generated from the first Ctrl-drag.
If you are creating the radio buttons in code then set the action parameter in the init for each radio button to be the same.
Another way to approach this is to represent the buttons as a Set and then it's easy to iterate through them and configure their state. The below code actually allows for allowing multiple selections to support a scenario that wants to "select three of the six options".
let allButtons = Set(1...5). //or however many you want
func selectActiveButtons(_ activeButtons: Set<Int>, from allButtons: Set<Int>){
let inactive = allButtons.subtracting(activeButtons)
setButtonState(forButtons: inactive, isSelected: false)
setButtonState(forButtons: activeButtons, isSelected: true)
}
func setButtonState(forButtons buttons: Set<Int>, isSelected: Bool) {
buttons.forEach{
//remove below line and replace with code to update buttons in UI
print("Button \($0): \(isSelected ? "Selected" : "Unselected")")
}
}
// select buttons 1 & 3.
//If wanting a "classic" radio button group just use one value in the arrayLiteral.
selectActiveButtons(Set(arrayLiteral: 1,3), from: allButtons)

validateMenuItem not called for every menu item

I have main menu that has several menu items (File, Edit, View, Window - and more). All menu items have their action set to an operation in FirstResponder.
The application has a single window and that window is of the type MyWindow that inherits from NSWindow (see below).
Note that NSWindow implements NSMenuValidation and hence it is flagged as an error when MyWindow would declare conformance to NSMenuValidation.
I have overriden the function validateMenuItem as follows:
class MyWindow: NSWindow, NSMenuDelegate {
...
override func validateMenuItem(_ item: NSMenuItem) -> Bool {
Log.atDebug?.log("\(item.title)")
....
}
}
When I run the application the validateMenuItem function is called for the File and the Window menu items but not for the Edit and View items.
Note: Log is an instance of a logging framework (SwifterLog).
The actions for all menu items are called correctly. (Also for the menu items for which the validateMenuItem is not called)
It is not difficult for me to work around this problem (the function menuNeedsUpdate is called for all menu's and can be used for this), but I would like to know why this behaviour occurs.
Answer can be found here:
validateMenuItem or menuWillOpen not called for NSMenu
validateMenuItem: belongs to the NSMenuValidation informal protocol;
for it to be called the relevant menu items must have a target.
This is not an answer, but to anyone interested in a work-around:
#objc func menuNeedsUpdate(_ menu: NSMenu) {
Log.atDebug?.log("\(menu.title)")
... // do other stuff
menu.items.forEach( { $0.isEnabled = validateMenuItem($0) } )
}
You must als set the delegate of each menu that must be handled to the MyWindow object (in this example). In my example, the menu of the menu item View must have its delegate set to MyWindow.

Delete command is not working for NSTextField

I have a textfield to which I need to listen to tab key, so that when ever the user press tab from that text field I can move the focus to next text field. I have implemented the below code to perform that operation.
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if (commandSelector == #selector(insertTab)) {
if control == firstTextField {
makeNextTextFieldAsFirstResponder()
}
}
return true
}
My problem is that as I have implemented this code, delete key is not doing what it suppose to do(removing last character from the text field's text). Am I missing something here?
I am new to Mac development so excuse me if this question has been asked already somewhere.
I found the solution to my own problem. It turns out to be a simple mistake. I am not sure about how exactly this method works and how the return value affect the nature of the text field as I am new to Mac development, but it seems that the default return value should be false. Any insights to this behaviour are welcome.

Clicking keyboard 'Next' key using UIAutomation

I have a search field in my app and I have set the return key type of the keyboard for this field to UIReturnKeyNext. I am attempting to write a UIAutomation test that clicks the Next button on the keyboard using the following line:
UIATarget.localTarget().frontMostApp().mainWindow().keyboard().keys().firstWithName("next");
This call is failing because the key with name 'next' is not being found. I have done a dump of all of the elements in my app using:
UIATarget.localTarget().frontMostApp().logElementTree();
This reveals that there is indeed a key in the keyboard with name 'next', but somehow my attempt to retrieve it as show above still fails. I can however retrieve other keys (like the key for the letter 'u') using this method. Is there a known issue here or am I doing something wrong?
I've tried other variations with no luck:
UIATarget.localTarget().frontMostApp().mainWindow().keyboard().elements()["next"];
Here is a screen capture of the elements in my UIAKeyboard:
If you just want to click it, and you know the keyboard has "next" as "Return key" (defined in your nib), then you can use this:
app.keyboard().typeString("\n");
Jelle's approach worked for me. But I also found an alternative way if anybody needed it.
XCUIApplication().keyboards.buttons["return"].tap()
Where you can create XCUIApplication() as a singleton on each UI Test session. The thing about this approach is you can now distinguish between return and done and other variants and even check for their existence.
You can go extra and do something like following:
extension UIReturnKeyType {
public var title: String {
switch self {
case .next:
return "Next"
case .default:
return "return"
case .continue:
return "Continue"
case .done:
return "Done"
case .emergencyCall:
return "Emergency call"
case .go:
return "Go"
case .join:
return "Join"
case .route:
return "Route"
case .yahoo, .google, .search:
return "Search"
case .send:
return "Send"
}
}
}
extension XCUIElement {
func tap(button: UIReturnKeyType) {
XCUIApplication().keyboards.buttons[button.title].tap()
}
}
And you can use it like:
let usernameTextField = XCUIApplication().textFields["username"]
usernameTextField.typeText("username")
usernameTextField.tap(button: .next)
I dont't have an example to test, but as the "Next" button is an UIAButton, and not an UIAKey you could try :
UIATarget.localTarget().frontMostApp().mainWindow().keyboard().buttons()["next"];
If it doesn't work, you can also try
UIATarget.localTarget().frontMostApp().mainWindow().keyboard().buttons()[4];
The following works for me:
UIATarget.localTarget().frontMostApp().mainWindow().keyboard().buttons().firstWi‌​thPredicate("name contains[c] 'next'");
For me, keyboard does not fall under mainWindow() in the View Hierarchy. It is at the same level as mainWindow() when you logElementTree() from top level. So, what you want to do is:
UIATarget.localTarget().frontMostApp().keyboard().buttons()["next"];
This worked for me when I was trying to press the "Search" button on keyboard.