3

I'm working on adding keyboard shortcuts on my application. There is a view controller that presents another controller:

class ViewController: UIViewController {

    override var canBecomeFirstResponder: Bool { true }

    override func viewDidLoad() {
        super.viewDidLoad()

        addKeyCommand(UIKeyCommand(
            input: "M",
            modifierFlags: .command,
            action: #selector(ViewController.handleKeyCommand),
            discoverabilityTitle: "Command from the container view"
        ))
    }

    @objc func handleKeyCommand() {
        present(ModalViewController(), animated: true)
    }

    override func canPerformAction(
        _ action: Selector, withSender sender: Any?
    ) -> Bool {
        if action == #selector(ViewController.handleKeyCommand) {
            return isFirstResponder
        }

        return super.canPerformAction(action, withSender: sender)
    }
}

class ModalViewController: UIViewController {

    override var canBecomeFirstResponder: Bool { true }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
        addKeyCommand(UIKeyCommand(
            input: "D",
            modifierFlags: .command,
            action: #selector(ModalViewController.handleKeyCommand),
            discoverabilityTitle: "Command from the modal view"
        ))

        if !becomeFirstResponder() {
            print("⚠️ modal did not become first responder")
        }
    }

    @objc func handleKeyCommand() {
        dismiss(animated: true)
    }
}

Both define shortcuts. When the modal view controller is presented, the Discoverability popup includes shortcuts for both presenting and presented view controller. Intuitively, only the modal view controller shortcuts should be included (we are not supposed to be able to interact with the presenting view controller, right?)

I can fix this by overriding the presenting view controller's keyCommands property, but is this a good idea?

In general, what is the reason behind this behavior? Is this a bug or a feature?


UPDATE: Added the canPerformAction(_:sender:) to the presenting view controller (as suggested by @buzzert), but the problem persists.

Andrii Chernenko
  • 9,873
  • 7
  • 71
  • 89

2 Answers2

2

This is happening because the presenting view controller (ViewController) is your ModalViewController's nextResponder in the responder chain.

This is because the OS needs some way to trace from the view controller that's currently presented on screen all the way back up to the application.

If your presenting view controller only has commands that make sense when it is first responder, the easiest way to resolve this is by simply overriding canPerformAction(_:) on ViewController, and return false if it is not first responder.

For example,

    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        if self.isFirstResponder {
            return super.canPerformAction(action, withSender: sender)
        } else {
            return false
        }
    }

Otherwise, if you want more control over the nextResponder in the responder chain, you can also override the nextResponder getter to "skip" your presenting view controller. This is not recommended though, but serves as an illustration of how it works.

buzzert
  • 21
  • 2
  • 1
    Makes sense, thanks for the explanation! Looks like returning `false` from `canPerformAction()` doesn't affect the discoverability popup though, the commands from the presenting VC still show up, is there a way to prevent that? In my actual app the presenting VC has a dozen of commands, so the popup becomes very cluttered. – Andrii Chernenko Jun 24 '20 at 22:22
  • I did not have the same outcome in my test apps. Returning `false` for `canPerformAction` should also hide it from the discoverability HUD. I would suggest setting a breakpoint in canPerformAction and make sure it's being called. – buzzert Jun 25 '20 at 00:50
  • I think I found why it does that. Selectors in both `ViewController` and `ModalViewController` have the same name. I thought specifying the class (`#selector(ViewController.handleKeyCommand)` instead of `#selector(handleKeyCommand)`) would help, but it didn't (see the updated code in the question). As soon as I renamed one of the selectors though, everything worked as you described. Am I shooting myself in the foot somehow? – Andrii Chernenko Jun 25 '20 at 21:42
0

ref to @buzzert, i use this code in root viewcontroller, it seems work fine.

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    if sender is UIKeyCommand{ /* keyboard event */
        if self.presentedViewController != nil{ /* modal now */
            return false
        }
    }
    
    return super.canPerformAction(action, withSender: sender)
}
slboat
  • 502
  • 6
  • 14