若視 Window 為 UI 的最外層,那 MenuBar 應該就算是第二層吧?MenuBar 是 Desktop App 很重要的元素,一般來說整個 App 提供的功能幾乎都會整理在選單裡,也會依照不同作業系統配上快速鍵顯示,讓使用者在使用的過程中可以一併學習和記憶。今天就要筆記一下 MenuBar 、與之關聯的元件,還有各元件的設定。
其實應用程式選單是一個「階層式」的架構,在 Compose for Desktop 裡,與選單相關的元件共有 MenuBar
、Menu
、Item
、Separator
、CheckboxItem
及 RadioButtonItem
共六個,其相互的階層關係以樹狀圖的方式呈現如下:
MenuBar
└── Menu
├── Item
├── CheckboxItem
├── RadioButtonItem
└── Separator
從上面的圖例就可以看出,MenuBar
指的是應用程式的整個選單區域,可以把它視為一個容器,裡面裝了多個 Menu
及其下不同型式的 Item
。MenuBar
根據不同作業系統有不同的顯示方式,比方說在 Windows 上,MenuBar
是顯示在視窗內的最上方;但在 macOS 上則是顯示在視窗外,集中顯示在桌面上方的全域選單裡。
在 MenuBar
底下,我們可以宣告數個 Menu
,一般來說,每個 Menu
會放相同類別的功能選項。以 IntelliJ IDEA 為例,跟檔案有關的功能都會放在 File 選單裡、跟編輯有關的操作都會放在 Edit 裡,以此類推。Menu
本身外顯的只有其選單的名字,至於選單裡有哪些功能可以點選,要再宣告下一層的 Item
。
使用者最後會點選到的功能,都是在 Item
這一層,Compose for Desktop 有四種 Item
可使用:Item
就是一般的文字功能選單,CheckboxItem
和 RadioButtonItem
可以做出較複雜的選擇互動,至於 Separator
只是拿來做為選項間視覺上的間隔,顯示時是一條水平線,並沒有實質的功能。
接著,就來筆記一下各 MenuBar 元件的設定用法。
MenuBar
元件MenuBar
元件並沒任何設定值,本身只做為容器,盛裝其他選單元件。要注意的是,MenuBar
是跟著 Window
元件走的,所以宣告時要寫在 Window
元件底下。宣告一個基本選單的方式大致如下:
fun main() = application {
Window(
// ...
) {
MenuBar {
// ...
}
}
}
Menu
元件做為 MenuBar
的下層,Menu
是盛裝 Item
的容器,有三個參數可供設定:
MenuBar {
Menu(
text = "...",
mnemonic = 'C',
enabled = true,
) {
// ...
}
}
text
:設定選單的外顯文字,是使用者實際會點選到的範圍。mnemonic
:將選單對應到鍵盤上的字元,當使用者按下 Alt 及該字元時可打開選單,預設為 null
。enabled
:設定選單是否啟用,若關閉則以灰色字樣顯示,預設為 treu
。Item
元件Item
是選單元件裡最底層的元件,也是使用者在選取時的終點,元件本身提供豐富的設定讓開發者自訂其行為:
Menu(/* ... */) {
Item(
text = "...",
onClick = { /* ... */ },
icon = null,
enabled = true,
mnemonic = null,
shortcut = null,
)
}
text
:設定顯示在選項裡的文字。onClick
:設定點擊選項時要做的行為。icon
:設定選項文字前的圖示,預設 null
代表沒有圖示。enabled
:設定選項是否啟用,預設為 true
。mnemonic
:設定選項對應到鍵盤上的字元,當使用者按下 Alt 及該字元時等於點擊該選項,預設為 null
。shortcut
:設定選項對應的鍵盤快速鍵,預設 null
代表沒有快速鍵對應。CheckboxItem
元件CheckboxItem
與 Item
的差別,在於 Checkbox 多了狀態,也因此在參數上多了一個 Boolean 值來紀錄狀態值。
Menu(/* ... */) {
CheckboxItem(
text = "...",
checked = false,
onCheckedChange = { /* ... */ },
icon = null,
enabled = true,
mnemonic = null,
shortcut = null,
)
}
text
:設定顯示在選項裡的文字。checked
:設定該選項是否被選取。onCheckedChange
:設定當選項值改變時要做的行為。icon
:設定選項文字前的圖示,預設 null
代表沒有圖示。enabled
:設定選項是否啟用,預設為 true
。mnemonic
:設定選項對應到鍵盤上的字元,當使用者按下 Alt 及該字元時等於點擊該選項,預設為 null
。shortcut
:設定選項對應的鍵盤快速鍵,預設 null
代表沒有快速鍵對應。RadioButtonItem
元件RadioButtonItem
與 CheckboxItem
的差別在於選取的型式,設定值都是類似的。
Menu(/* ... */) {
RadioButtonItem(
text: String,
selected: Boolean,
onClick: () -> Unit
shortcut: KeyShortcut? = null,
icon: Painter? = null,
enabled: Boolean = true,
mnemonic: Char? = null,
)
}
text
:設定顯示在選項裡的文字。selected
:設定該選項是否被選取。onClick
:設定點擊選項時要做的行為。shortcut
:設定選項對應的鍵盤快速鍵,預設 null
代表沒有快速鍵對應。icon
:設定選項文字前的圖示,預設 null
代表沒有圖示。enabled
:設定選項是否啟用,預設為 true
。mnemonic
:設定選項對應到鍵盤上的字元,當使用者按下 Alt 及該字元時等於點擊該選項,預設為 null
。Separator
元件Separator
元件是在選項間顯示一條水平線,用於區隔選單裡不同區域的選項,屬於視覺修飾用。使用上也不需任何參數,直接呼叫即可。
Menu(/* ... */) {
Item(/* ... */)
Separator()
Item(/* ... */)
}
了解各選單元件的使用方式後,就可以將以上這些元件綜合運用,寫出多階層選單了。範例如下:
MenuBar {
Menu(
text = "Item",
) {
(1..3).forEach { topLevel ->
Menu("SubMenu $topLevel") {
if (isSubmenuShowing) {
(1..3).forEach { secondLevel ->
Item(text = "SubItem $secondLevel", onClick = { })
}
}
}
}
}
Menu("Setting") {
CheckboxItem(
text = "Generate submenu?",
checked = isSubmenuShowing,
onCheckedChange = { isSubmenuShowing = !isSubmenuShowing }
)
}
}
值得一提的是,由於 Compose 的語法是 100% 原生的 Kotlin 程式碼,因此在宣告 UI 時可混合任何 Kotlin 語法進行條件判斷,運用 for
或 Collection 技巧產生多筆資料等技巧都是可以的。
各 Item
級別的元件都可以設定文字旁的 Icon,只要傳入一個 Painter
實例到 Item
的 icon
參數即可:
MenuBar {
Menu("Menu1", mnemonic = 'A') {
Item(
text = "ResourceIcon",
icon = painterResource("window-icon.png"),
onClick = { /* ... */ }
)
}
Menu("Menu2", mnemonic = 'B') {
Item(
text = "PainterIcon",
icon = PainterIcon,
onClick = { /* ... */ }
)
}
}
object PainterIcon : Painter() {
override val intrinsicSize = Size(5f, 5f)
override fun DrawScope.onDraw() {
drawOval(Color(0xFFFFA500))
}
}
我們可以用兩種方式來繪製 Icon:一種方式是使用圖片做為 Painter
的來源進行繪製,另一種方式是使用 Painter
類別,以向量繪製的方式呈現 Icon。
在解析 Item
、CheckboxItem
及 RadioButtonItem
時發現,這三個元件都可以設定 Event Handler,根據不同元件,共有兩種事件:onClick
(當滑鼠點擊時)及 onCheckedChange
(當選中狀態改變時)。
透過監聽事件,我們就能改變應用程式狀態,進而改變顯示的結果。由於 Declarative UI 的核心精神之一,就是要讓狀態統一儲存在單一來源(即所謂 Single Source of Truth),因此在 Event Handler 裡,我們只改變應用程式的狀態,而不改變元件的外觀。透過 Compose 的 State 管理,各元件會自動監聽 State,若有變化就會自動依照新的狀態重繪 UI。
fun main() = application {
var action by remember { mutableStateOf("None") }
Window(/* ... */) {
MenuBar {
Menu("Item") {
Item(text = "Cut", onClick = { action = "Cut" })
Item(text = "Copy", onClick = { action = "Copy" })
Item(text = "Paste", onClick = { action = "Paste" })
}
}
DisplayActionScreen(action)
}
}
在上面的例子裡,我們先宣告 action
變數來儲存使用者點擊到的選單名稱,預設為 None
。當使用者點擊不同的 Item
時,在 Event Handler 裡我們就只改變 action
變數的值,而監聽 action
值的 DisplayActionScreen()
元件就會自動依照 action
的內容更新顯示。
關於 Compose 裡的狀態管理機制,後續會再有更深入的研究筆記,現階段先有基本觀念即可。
前面有提到,各 Item
型的元件支援綁定鍵盤快速鍵,當使用者按下指定的快速鍵組合時,等同於用滑鼠點擊選單。只要針對要綁定鍵盤快速鍵的 Item
元件設定 shortcut
參數即可:
@OptIn(ExperimentalComposeUiApi::class)
fun main() = application {
var action by remember { mutableStateOf("None") }
Window(
onCloseRequest = ::exitApplication,
title = "MenuBar Keyboard Shortcut Demo",
) {
MenuBar {
Menu("File", mnemonic = 'F') {
Item(
text = "Cut",
onClick = { action = "Cut" },
shortcut = KeyShortcut(Key.X, meta = true)
)
Item(
text = "Copy",
onClick = { action = "Copy" },
shortcut = KeyShortcut(Key.C, meta = true)
)
Item(
text = "Paste",
onClick = { action = "Paste" },
shortcut = KeyShortcut(Key.V, meta = true)
)
}
}
DisplayActionScreen(action)
}
}
從上面的範例可以看到,shortcut
參數接受一個 KeyShortcut
物件,其封裝了鍵盤按鈕與輔助鍵的組合,以 Cut 為例,我們想要將 macOS 鍵盤的 ⌘ + x 綁定到這個選單,首先宣告 Key.X
綁定鍵盤的 x 鍵,再設定 meta = true
表示要同時按下 macOS 鍵盤的 ⌘ 鍵(⌘ 在程式裡被稱為 Meta 鍵)做組合。程式運行後按下 ⌘ + x,就可以在中間的 DisplayActionScreen()
元件看到 Last action: Cut
的字樣。
要注意的是,在設計鍵盤快速鍵時,要考量各作業系統的操作習慣。比方說在 macOS 上,使用者習慣用 ⌘ + x 做剪下的指令,但在 Windows 或 Linux 上,因為沒有 ⌘ 鍵,所以一般會用 Ctrl 鍵。因此比較好的作法,是在設定鍵盤快速鍵時,依不同作業系統設定 meta = true
或 ctrl = true
。
另外,由於 Key
類別還在實驗階段(Experimental),因此在使用前,需要在函式前加上 @OptIn(ExperimentalComposeUiApi::class)
Annotation 的標記。