UITabBarController, menu bar and first responder

I'm using a standard UITabBarController on iPad. When first selecting any tab, the corresponding menu bar items are grayed out for this view controller. It's only when I tap any button in that view controller, like in the toolbar, that the view controller truly becomes the first responder (you can see the sidebar selection turns to gray from blue), enabling those menu bar items.

Am I doing something wrong here?

A video of the issue can be found here: https://mastodon.social/@nicoreese/114949924393554961

AppDelegate:

...
builder.insertChild(MenuController.viewModeMenu(), atStartOfMenu: .view)

class func viewModeMenu() -> UIMenu {
        let listViewModeCommand = UICommand(
            title: String(localized: "As List"),
            image: UIImage(systemName: "list.bullet"),
            action: #selector(GamesViewController.setListViewMode),
            propertyList: SettingsService.ViewMode.list.rawValue
        )
    
...
        
        let viewModeMenu = UIMenu(
            title: "",
            image: nil,
            identifier: .viewModeMenu,
            options: .displayInline,
            children: [listViewModeCommand...]
        )
        
        return viewModeMenu
    }

GamesViewController:

@objc
func setListViewMode() {
    updateViewMode(.list)
}

I can do this, but then the sidebar selection instantly turns gray, which looks odd and other system apps do not behave this way.

override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
                        
        becomeFirstResponder()
    }
    
    override var canBecomeFirstResponder: Bool {
        return true
    }
Answered by Frameworks Engineer in 851730022

The solution here is largely going to depend on what behavior you want for your app. When you tap an item in the tab sidebar, for example, focus moves to the sidebar so you can hit the arrow keys to move up and down tabs. On the other hand, if your app wants arrow keys to navigate the actual view controller opened by that tab, you likely want a solution like you have where you make your view controller first responder in viewDidAppear.

The sidebar changing colors when the detail view controller becomes first responder is expected behavior—it is the way the system indicates to the user that focus is moving out of the sidebar.

If you want your keyboard shortcuts to work on both the sidebar and the detail view controller, you may want to implement the actions on a higher-level responder like the UITabBarController/UISplitViewController itself, and then have the implementation call into the appropriate child view controller.

Doing the same when in top tab bar mode works perfectly fine, it's just in sidebar mode where it does not.

I also recreated the issue in UISplitViewController. Either I am missing something fundamental or it's impossible to implement keyboard shortcuts in a good way in those scenarios.

When making selections in a sidebar, its view controller becomes first responder and accepts shortcuts. When making the secondary view controller the first responder (either programmatically or by triggering something like a search bar), it can accept shortcuts but the sidebar cannot. You would also lose stuff like the sidebar selection color if you do this.

Curiously I have not seen this behavior in other system apps. They can seemingly have the right behavior where both sidebar and the content view can react to their respective shortcuts at all times.

Am I expected to implement all keyboard shortcuts on UISplitViewController?

The solution here is largely going to depend on what behavior you want for your app. When you tap an item in the tab sidebar, for example, focus moves to the sidebar so you can hit the arrow keys to move up and down tabs. On the other hand, if your app wants arrow keys to navigate the actual view controller opened by that tab, you likely want a solution like you have where you make your view controller first responder in viewDidAppear.

The sidebar changing colors when the detail view controller becomes first responder is expected behavior—it is the way the system indicates to the user that focus is moving out of the sidebar.

If you want your keyboard shortcuts to work on both the sidebar and the detail view controller, you may want to implement the actions on a higher-level responder like the UITabBarController/UISplitViewController itself, and then have the implementation call into the appropriate child view controller.

So, I'm now in the mood to try this again.

I think for any app it's reasonable that a UIViewController should be able to perform actions once it's visible, even if it's not first responder. Right now, this is not the case.

So what you're suggesting is that I do something like this. This really overcomplicates app logic when each UIViewController could perfectly handle their own actions, if only the system would ask each visible view controller if it can perform them.

Now I have to implement functions for each menu action at the top level, then check if the action should be enabled and then find the responsible view controller myself and tell it to execute the function. And this is just the basic implementation. What if I want to determine if a menu action should be enabled/disabled based on state in a specific view controller? Then I have to do this manually too. Also, all my view controllers functions need to be public then. I'm really not sure this is a thought-through approach.

@objc
func test() {
let gamesViewController = ...
gamesViewController.test()
}
    
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        if action == #selector(test) {
            if let a = self.selectedViewController as? UINavigationController {
                if a.viewControllers.last is GamesViewController {
                return true
                }
            }
        }
        
        return false
    }

Even the Apple sample project "Adding Menus and Shortcuts to the Menu Bar and User Interface" struggles with this, as the two actions for the PrimaryViewController do not work unless you add an item and then select it to make it the first responder.

Additional problem:

Implementing validate(_ command: UICommand) is difficult. I can't put it where I do canPerformAction because if the first responder changes, then I'd have to manage it in the view controller. But if the sidebar becomes the first responder again, then I'd need to do it in my tab bar controller subclass. This is all so convoluted.

UITabBarController, menu bar and first responder
 
 
Q