在 macOS 中,你会看到系统菜单有许多有意思的设计,如程序菜单中按下 键会出现替代菜单项,又或者在按住 时按下 Wi-Fi 的状态栏图标会出现额外的菜单项。这是怎么实现的呢?

使用替代菜单项来折叠菜单项

按下 (右) 与未按下 (左) option 键的 Time Machine 菜单

在菜单中,如果某个菜单项快捷键的基础键 keyEquivalent 与上一个菜单项相同,但是修饰键 keyEquivalentModifierMask 不同,那么,这些菜单项是可以折叠在一起的。

比如说有两个菜单项 SaveSave As…:

// `s` with `.commnad` mask
let saveItem = NSMenuItem(title: "Save", action: #selector(save:), keyEquivalent: "s")
// `s` with `.command` and `.option` mask
let saveAsItem = NSMenuItem(title: "Save As...", action: #selector(saveAs:), keyEquivalent: "S")

Save 的快捷键为 + S,而 Save As… 的快捷键为 + + S

现在,如果你将 saveAsItem 菜单项的 isAlternate 设置为 true:

saveAsItem.isAlternate = true

那么,打开菜单时,Save As… 将不会显示出来,直到你按下 键。这时,Save 将被 Save As… 替代显示。

这一方法同样也适用于 keyEquivalent 都为空的菜单项。 因为这个情况的重点在于基础键相同且修饰键不同,而不是有没有基础键。

如果你对某个菜单项设置了 isAlternate = true,但是在它上面又没有符合条件的菜单项,那么,这个选项不会起任何作用。

按住修饰键打开菜单来显隐菜单项

当你按住 时按下 Wi-Fi 的状态栏图标会出现额外的菜单项,而松开 以后,这些菜单项并不会消失。这种要怎么做呢?

按下 (右) 与未按下 (左) option 键打开的 Volume 菜单

一个常见的思路是使用 isHidden 属性来隐藏这些需要隐藏的菜单项,当菜单打开的时候,我们检测一下 是否被按下,然后根据情况来修改这些菜单项是否需要取消隐藏。

首先是打开菜单时的回调。NSMenu 的代理 NSMenuDelegate 中有个 menuWillOpen(_:) 正好符合我们的需求:

optional func menuWillOpen(_ menu: NSMenu)

在这个方法中,我们可以通过 NSEventmodifierFlags 来得到当前按下的修饰符的情况。

func menuWillOpen(_ menu: NSMenu) {
    if NSEvent.modifierFlags.contain(.option) {
        // show item
    } else {
        // hide item
    }
}

我们只需要在这里更新一下菜单项的显示隐藏即可。因为我们是使用 isHidden 属性来控制菜单项的显隐,所以,在菜单显示以后,松开 并不会影响到这些菜单项。

仅在按下修饰键时显示菜单项

有一种特殊的情况是你希望在菜单打开着的前提下,仅在按下 时显示菜单项,并且在松开 以后立即隐藏菜单项,如 Finder → Go 中的 Library 菜单项。这明显不符合上面说的两种情况。

Library 仅在按下 option 键时显示

实际上,这种情况除了使用私有方法以外别无他法 (至少以我所知)。

你将需要调用私有的 _setRequiresModifiersToBeVisible: 方法来设置菜单项是否需要按下修饰键才显示:

item.perform(NSSelectorFromString("_setRequiresModifiersToBeVisible:"), with: 1)
// in this case, a non-command modifier mask is required
item.keyEquivalentModifierMask = .option