iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

玩轉ABCL Lisp!用奇怪偏門的方式學習JavaFX系列 第 20 篇

lesson 11: Hello FXML:分離畫面設計與程式邏輯

  • 分享至 

  • xImage
  •  

TL;DR

這篇將前一篇以 ABCL 建立的 CalcFX 畫面改用 FXML 描述,讓畫面布局與程式邏輯分開。FXML 負責建立 JavaFX 元件與配置 CSS,ABCL 則負責載入 FXML 並建立 Scene;按鈕事件綁定會在後續文章處理。

為什麼使用 FXML

JavaFX 的特色之一,是可以將畫面設計獨立使用 FXML 建立,與程式運行邏輯分開,並且繼續使用 CSS 宣告畫面風格。之前已經看過 CSS 如何改變畫面風格,現在改用 FXML 將畫面設計與運行邏輯分開。

這很像是Web技術三劍套:HTML、JavaScript、CSS。雖然確實在JavaScript可以建立畫面物件和調整風格,但通常會分開。
不過現代的三大前端框架:Angular、Vue、React又把這個邊界有點模糊在一起了。

FXML 的基本結構

FXML 是基於 XML 的擴展檔案格式,同樣具備 XML 的基本特性。例如自關閉元素可以在結尾直接加上 /:

<Element/>

而通常的元素會包含開始和結束標籤:

<Element></Element>

將 JavaFX 類別寫成 FXML 元素

有了 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 的布局設定

接著處理 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"))

使用 FXMLLoader 載入 FXML

讀取 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 的計算機邏輯連接起來。


上一篇
lesson 10: CalcFX 邏輯:用 ABCL 綁定動作處理,實現計算機邏輯
系列文
玩轉ABCL Lisp!用奇怪偏門的方式學習JavaFX 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言