這篇將前一篇以 ABCL 建立的 CalcFX 畫面改用 FXML 描述,讓畫面布局與程式邏輯分開。FXML 負責建立 JavaFX 元件與配置 CSS,ABCL 則負責載入 FXML 並建立 Scene;按鈕事件綁定會在後續文章處理。
JavaFX 的特色之一,是可以將畫面設計獨立使用 FXML 建立,與程式運行邏輯分開,並且繼續使用 CSS 宣告畫面風格。之前已經看過 CSS 如何改變畫面風格,現在改用 FXML 將畫面設計與運行邏輯分開。
這很像是Web技術三劍套:HTML、JavaScript、CSS。雖然確實在JavaScript可以建立畫面物件和調整風格,但通常會分開。
不過現代的三大前端框架:Angular、Vue、React又把這個邊界有點模糊在一起了。
FXML 是基於 XML 的擴展檔案格式,同樣具備 XML 的基本特性。例如自關閉元素可以在結尾直接加上 /:
<Element/>
而通常的元素會包含開始和結束標籤:
<Element></Element>
有了 XML 的基本概念後,就可以直接用 Java 類別名稱建立畫面物件,例如 CalcFX 場景的 GridPane:
<javafx.scene.layout.GridPane></javafx.scene.layout.GridPane>
可以類似Java import,同樣先宣告引入使用,簡化元素宣告的寫法:
<?import javafx.scene.layout.GridPane?>
<GridPane></GridPane>
以下是CalcFX引入的類別:
<?import java.lang.String?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.RowConstraints?>
通常來說,Java 類別是大寫開頭,對應到 FXML 的大寫開頭標籤元素;小寫開頭的 FXML 標籤元素則對應到物件屬性。以 GridPane 為例:
<GridPane>
<columnConstraints>......</columnConstraints>
<rowConstraints>......</rowConstraints>
<children>......</children>
</GridPane>
在<GridPane>的子標籤元素設定columnConstraints、rowConstraints和children。如果物件屬性足夠簡單,還可以用 XML 內連屬性(inline attribute)方式設定:
<GridPane id="root-pane" styleClass="root-pane" stylesheets="@calcfx.css" vgap="10.0" hgap="10.0"></GridPane>
對於第一個CalcFX元素,可以這麼定義完成:
<GridPane id="root-pane"
styleClass="root-pane"
stylesheets="@calcfx.css"
vgap="10.0"
hgap="10.0"
xmlns="http://javafx.com/javafx/25.0.1" xmlns:fx="http://javafx.com/fxml/1">
<columnConstraints>......</columnConstraints>
<rowConstraints>......</rowConstraints>
<children>......</children>
</GridPane>
↑
對應
↓
(let* ((grid (new "javafx.scene.layout.GridPane"))) ;; 申明grid、display變數
(chain (#"getStyleClass" grid) ("add" "root-pane"))
(chain (#"getStyleClass" display) ("add" "display"))
(#"setVgap" grid 10.0d0) ; 垂直間距 10.0 (Java double)
(#"setHgap" grid 10.0d0) ; 水平間距 10.0 (Java double)
要特別注意的是,將 CSS 資源路徑直接定義在 stylesheets="@calcfx.css",表示 JavaFX 處理這份 FXML 時也需要找到 calcfx.css。原本 CSS 是在 Scene 中宣告:
(let* ((scene (new "javafx.scene.Scene" grid 320.0d0 420.0d0))
(stylesheets (#"getStylesheets" scene))
(css-data-url (get-system-resource "res/calcfx.css")))
(#"add" stylesheets css-data-url)
接著處理 GridPane 的 columnConstraints 屬性:
<GridPane id="root-pane" styleClass="root-pane" ......>
<columnConstraints>
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="25.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="25.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="25.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="25.0" prefWidth="100.0" />
</columnConstraints>
<rowConstraints>......</rowConstraints>
<children>......</children>
</GridPane>
↑
對應
↓
(dotimes (i 4)
(let ((col (new "javafx.scene.layout.ColumnConstraints")))
(#"setPercentWidth" col 25.0d0)
(#"add" (#"getColumnConstraints" grid) col)))
再處理 rowConstraints 屬性:
<GridPane id="root-pane" styleClass="root-pane" ......>
<columnConstraints>......</columnConstraints>
<rowConstraints>
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="NEVER" />
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="SOMETIMES" />
</rowConstraints>
<children>......</children>
</GridPane>
↑
對應
↓
;; 第 0 列(顯示幕)不自動隨視窗長高,維持原高度
(let ((row0 (new "javafx.scene.layout.RowConstraints")))
(#"setVgrow" row0 (jfield "javafx.scene.layout.Priority" "NEVER"))
(#"add" (#"getRowConstraints" grid) row0))
;; 第 1 到 4 列(按鈕列)要平均隨視窗長高 (百分比填滿)
(dotimes (i 4)
(let ((row (new "javafx.scene.layout.RowConstraints")))
(#"setPercentHeight" row 20.0d0) ; 每列分配總高度的 20%
(#"add" (#"getRowConstraints" grid) row)))
接著是顯示器部分,它是 GridPane 的 child:
<children>
<TextField id="display"
text="0"
editable="false"
styleClass="display"
GridPane.columnSpan="4"
maxWidth="1.7976931348623157E308" />
</children>
↑
對應
↓
(let* ((display (new "javafx.scene.control.TextField" "0")))
(#"setEditable" display nil)
(chain (#"getStyleClass" display) ("add" "display"))
(#"setMaxWidth" display (jfield "java.lang.Double" "MAX_VALUE"))
(#"add" grid display 0 0 4 1)
按鈕則透過 GridPane.rowIndex 與 GridPane.columnIndex 放入對應位置:
<children>
<Button text="7" GridPane.rowIndex="1" styleClass="calc-btn" />
<Button text="8" GridPane.rowIndex="1" GridPane.columnIndex="1" styleClass="calc-btn" />
<Button text="9" GridPane.rowIndex="1" GridPane.columnIndex="2" styleClass="calc-btn" />
<Button text="4" GridPane.rowIndex="2" styleClass="calc-btn" />
<Button text="5" GridPane.rowIndex="2" GridPane.columnIndex="1" styleClass="calc-btn" />
<Button text="6" GridPane.rowIndex="2" GridPane.columnIndex="2" styleClass="calc-btn" />
<Button text="1" GridPane.rowIndex="3" styleClass="calc-btn" />
<Button text="2" GridPane.rowIndex="3" GridPane.columnIndex="1" styleClass="calc-btn" />
<Button text="3" GridPane.rowIndex="3" GridPane.columnIndex="2" styleClass="calc-btn" />
<Button text="C" GridPane.rowIndex="4" styleClass="calc-btn" />
<Button text="0" GridPane.rowIndex="4" GridPane.columnIndex="1" styleClass="calc-btn" />
<Button text="=" GridPane.rowIndex="4" GridPane.columnIndex="2" >......</Button>
<Button text="/" GridPane.rowIndex="1" GridPane.columnIndex="3" >......</Button>
<Button text="*" GridPane.rowIndex="2" GridPane.columnIndex="3" >......</Button>
<Button text="-" GridPane.rowIndex="3" GridPane.columnIndex="3" >......</Button>
<Button text="+" GridPane.rowIndex="4" GridPane.columnIndex="3" >......</Button>
</children>
↑
對應
↓
(#"add" btn-styles "calc-btn")
;; 將按鈕加入面板
(#"add" grid btn col row))))
像是功能按鈕的styleClass有兩個,就不能使用內連屬性的方式設定。需要這樣做:
<children>
<Button text="=" GridPane.rowIndex="4" GridPane.columnIndex="2" >
<styleClass>
<String fx:value="op-btn" />
<String fx:value="calc-btn" />
</styleClass>
</Button>
</children>
↑
對應
↓
(when (member label '("/" "*" "-" "+" "=") :test #'string=)
(#"add" btn-styles "op-btn"))
讀取 FXML 需要使用 FXMLLoader。提供 FXML 資源位置後,再呼叫載入器的 load() 方法讀取。將 FXML 內容保存到 res/CalcFX.fxml,載入結果就可以用來建立 Scene,後續顯示場景的方式與前一篇相同:
(defun create-calculator-scene ()
"建立並回傳一個包含彈性計算機介面與事件綁定的 JavaFX Scene 物件"
(let* ((parent (jstatic "load" "javafx.fxml.FXMLLoader" (new "java.net.URL" (get-resource-uri "res/CalcFX.fxml")))) ;; 載入 res/CalcFX.fxml 資源
(scene (new "javafx.scene.Scene" parent 320.0d0 420.0d0))) ;; 建立Scene
scene))
(defconstant +MY-APP+ ;; 這部分沒有變化
(jnew-runtime-class
"MyAPP"
:superclass "javafx.application.Application"
:methods `(("start" :void ("javafx.stage.Stage")
(lambda (this stage)
(prog* ((calculator-scene (create-calculator-scene))
......)
......
(#"setScene" stage calculator-scene)
(#"show" stage)))))
:access-flags '(:public)))
(jstatic "launch" +application+ +MY-APP+ (jnew-array "java.lang.String" 1))
res/CalcFX.fxml 完整內容<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.String?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.RowConstraints?>
<GridPane id="root-pane" hgap="10.0" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="488.0" prefWidth="413.0" styleClass="root-pane" stylesheets="@calc.css" vgap="10.0" xmlns="http://javafx.com/javafx/25.0.1" xmlns:fx="http://javafx.com/fxml/1">
<columnConstraints>
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="25.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="25.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="25.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="25.0" prefWidth="100.0" />
</columnConstraints>
<rowConstraints>
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="NEVER" />
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" percentHeight="20.0" prefHeight="30.0" vgrow="SOMETIMES" />
</rowConstraints>
<children>
<TextField id="display" editable="false" maxWidth="1.7976931348623157E308" styleClass="display" text="0" GridPane.columnSpan="4" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="7" GridPane.rowIndex="1" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="8" GridPane.columnIndex="1" GridPane.rowIndex="1" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="9" GridPane.columnIndex="2" GridPane.rowIndex="1" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="4" GridPane.rowIndex="2" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="5" GridPane.columnIndex="1" GridPane.rowIndex="2" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="6" GridPane.columnIndex="2" GridPane.rowIndex="2" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="1" GridPane.rowIndex="3" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="2" GridPane.columnIndex="1" GridPane.rowIndex="3" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="3" GridPane.columnIndex="2" GridPane.rowIndex="3" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="C" GridPane.rowIndex="4" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" styleClass="calc-btn" text="0" GridPane.columnIndex="1" GridPane.rowIndex="4" />
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" text="=" GridPane.columnIndex="2" GridPane.rowIndex="4">
<styleClass>
<String fx:value="op-btn" />
<String fx:value="calc-btn" />
</styleClass>
</Button>
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" text="/" GridPane.columnIndex="3" GridPane.rowIndex="1">
<styleClass>
<String fx:value="op-btn" />
<String fx:value="calc-btn" />
</styleClass>
</Button>
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" text="*" GridPane.columnIndex="3" GridPane.rowIndex="2">
<styleClass>
<String fx:value="op-btn" />
<String fx:value="calc-btn" />
</styleClass>
</Button>
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" text="-" GridPane.columnIndex="3" GridPane.rowIndex="3">
<styleClass>
<String fx:value="op-btn" />
<String fx:value="calc-btn" />
</styleClass>
</Button>
<Button maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" mnemonicParsing="false" text="+" GridPane.columnIndex="3" GridPane.rowIndex="4">
<styleClass>
<String fx:value="op-btn" />
<String fx:value="calc-btn" />
</styleClass>
</Button>
</children>
</GridPane>
這樣一來,CalcFX 的畫面布局、元件位置與 CSS 類別都由 FXML 描述,ABCL 只需要負責載入資源並建立 Scene。畫面外觀與前一篇相同,但畫面設計已經從 Lisp 程式碼中拆分出來。
下一篇將進一步處理 FXML 中元件的事件設定,讓畫面上的按鈕可以和 ABCL 的計算機邏輯連接起來。