10.3 创建JavaFX桌面GUI应用程序
我们将使用桌面工具包JavaFX GUI来创建一个简单的Kotlin桌面应用程序。JavaFX是最新的GUI工具包,大多数流行的Java运行时环境(Java Runtime Environment,JRE)版本都提供了它。用于Windows、macOS和基于桌面的Linux的Java版本都提供了JavaFX;最初,随Raspberry Pi的Raspbian操作系统预安装的Java SE Embedded 8也提供了JavaFX,但Oracle在较近的更新中将其删除了。另外,Solaris用户也无法使用JavaFX。
Oracle以开源许可的方式提供用于Java SE Embedded的JavaFX的源代码,因此高级用户依然能够在Raspberry Pi上编译并运行基于JavaFX的应用程序,但这种使用JavaFX的方式不在本章的探讨范围内。
强烈建议你在开发本章的项目时随时参阅JavaFX文档,这个文档可通过如下链接找到:http://docs.oracle.com/javase/8/javase-clienttechnologies.htm。
Kotlin非常适合用于JavaFX开发。由于Kotlin能够通过属性名访问类的属性,因此在Kotlin中,你无需调用获取/设置函数,而是可以像访问字段一样访问属性,这让代码整洁得多。但在幕后,Kotlin还是会调用设置/获取函数。下面是一个使用Java编写的简单示例:
// Java代码示例,请不要在Eclipse中输入它们@Overridepublic void start(Stage stage) {stage.setTitle("Kotlin JavaFX Demo")stage.setScene(new Scene(new Pane(), 300.0, 300.0))}
使用Kotlin编写时,代码如下:
// Kotlin代码示例,请不要在Eclipse中输入它们override fun start(stage: Stage) {stage.title = "Kotlin JavaFX Demo"stage.scene = Scene(Pane(), 300.0, 300.0)}
虽然没什么大不了,但你可能也会认为Kotlin代码要整洁、易读些。所幸Eclipse IDE Kotlin插件会在自动补全建议中显示属性,这一点你稍后就会看到。
10.3.1 定制项目
我们将对前一节在Eclipse IDE中导入的项目进行定制。为此,请执行如下操作。
(1) 将目录src/test/kotlin中的文件HelloTest.kt删除:右击这个文件并选择Delete…。
(2) 将目录src/main/kotlin中的文件Hello.kt重命名为App.kt:在Package Explorer单击文件Hello.kt,并按F2。
(3) 打开文件pom.xml,找到<properties>部分,将其中的属性改为。这个类将包含程序的JVM入口方法main()。
(4) 刷新这个Maven项目:右击项目名并选择Maven>Update Project。
10.3.2 创建可运行的应用程序
打开目录src/main/kotlin中的文件App.kt,将其内容替换为如下代码:
package javafxdemofun main(args: Array<String>) {}
将光标放在package语句后面并添加一个空行,再输入如下代码:
class KotlinJavaFXDemo :
再输入Appl并按Ctrl + 空格(Apple机上为cmd + 空格),Eclipse IDE将打开一个包含建议的弹出窗口:

在列表中找到并双击javafx.application包中的Application类,也可单击并按回车键。这将自动输入类名Application,并添加相应的import语句。接下来添加(),让这个子类自动调用父类javafx.application.Application的不接受任何参数的构造函数。现在的代码应类似于下面这样:
package javafxdemoimport javafx.application.Applicationclass KotlinJavaFXDemo : Application() {}fun main(args: Array<String>) {}
Application是JavaFX工具包中的一个类,这是一个抽象类,包含多个具体方法和一个抽象方法,其中的抽象方法的Java定义如下:
// Java代码public abstract void start(Stage primaryStage)
基于JavaFX的应用程序必须扩展Application类,并实现其方法start(),因此我们必须在KotlinJavaFXDemo类中提供这个方法的实现。将光标放在KotlinJavaFXDemo类的定义中,按Ctrl + 1(在macOS中为cmd + 1)并双击Implement Members:

Ctrl + 1(在macOS中为cmd + 1)是在Eclipse IDE命令Quick Fix(快速修复)的快捷键,可快速修复常见问题。
现在这个类应类似于下面这样:
class KotlinJavaFXDemo : Application() {override fun start(primaryStage: Stage?) {TODO()}}
遗憾的是,这并不会自动添加导入Stage的语句;有鉴于此,将光标放在类名Stage?中的问号前面,并按Ctrl +空格(在macOS中为cmd +空格),再选择javafx.stage包中的Stage类。这将添加所需的import语句。
将类名Stage后面的问号删除(因为这个方法是JavaFX提供的,不可能为null),再将函数start()的实现替换为如下代码。输入每个类名时,都请使用快捷键Ctrl + 空格(macOS中为cmd + 空格)启动自动补全功能,并选择javafx包中的类:
override fun start(primaryStage: Stage) {primaryStage.title = "Kotlin JavaFX Demo"val pane = Pane()val scene = Scene(pane, 500.0, 500.0)primaryStage.scene = sceneprimaryStage.show()}
这些代码实现的功能很多,下面来详细说说。
Stage对象是JavaFX应用程序的顶级容器。JavaFX默认自动创建一个主窗口,并将指向这个窗口的引用(一个Stage对象)传递给方法start。- 我们将这个主窗口的title属性(标题)改为Kotlin JavaFX Demo。默认情况下,窗口的标题为空。
- 创建了一个
Pane()对象。Pane对象可以有子对象,其中每个子对象都可以是GUI元素,在Pane对象渲染自己时会被自动渲染。 - 我们创建了一个
Scene对象。Scene对象包含根对象,而根对象包含所有要绘制的子对象。就这里而言,这好像毫无用处,因为这里只有一个Pane()对象,但稍后你就会看到,窗口通常包含多个对象。我们告诉JavaFX,我们希望这个场景(scene)的高度和宽度都为500像素。 - 我们将这个
Scene对象赋给窗口primaryStage。知道所需的尺寸后,JavaFX创建一个500像素×500像素的对话框。 - 最后,让窗口
primaryStage可见。
JavaFX通常使用基本类型
double变量(而不是整数)来指定位置和尺寸。
我们还需要实现JVM入口函数main()。要启动JavaFX应用程序,必须调用Application类的公有静态方法launch。请将函数main()的实现替换为如下代码:
fun main(args: Array<String>) {Application.launch(KotlinJavaFXDemo::class.java)}
请确保这个函数没有放在类中,否则它将成为普通的实例方法。仅当这个方法不在任何类中时,Kotlin才会将它编译为可用作JVM入口方法的静态方法。
方法launch是Application类的一个静态方法,在Kotlin中,这意味着只能通过类名Application来访问它。调用这个方法时,必须指定一个这样的参数,即指向扩展Application类的Java类的引用;在Kotlin中,这是通过将它转换为class.java对象实现的。
前一章说过,当一个方法放在顶层(不在任何类中)时,Kotlin将创建一个类,这个类的名称由源代码文件名和后缀Kt组成。因此,就这里而言,方法main将放到javafxdemo包的AppKt类中,这个类也是我们前面修改构建文件pom.xml时指定的主类。
现在应该能够运行这个应用程序了,为此可按Ctrl + F11(在macOS中为cmd + F11),也可单击工具栏中的启动按钮。程序启动后,将出现一个空窗口:

这不太令人激动,下面着手在窗口中打印一些内容。
10.3.3 编写扩展函数
Kotlin提供了一种有趣的功能——扩展函数。扩展函数是与特定类型相关联的函数。与扩展函数相关联的类型称为接受类型(receiver type),是扩展函数将添加到其中的类。接受类型的实例可像调用其其他方法一样调用扩展函数,唯一的差别是必须先导入扩展函数才能调用它。无需通过继承就可添加扩展函数,因此可给任何类添加扩展函数,包括closed(Java中为final)类。下面给JavaFX类Pane添加一个扩展函数,以令人眼花缭乱的方式打印一条消息。
在Package Explorer中,右击目录src/main/kotlin并选择New>Kotlin File…。在文本框Package中输入javafxdemo.extensions,并在文本框Name中输入PaneExtensions,再单击Finish按钮。这将创建新文件,其中包含必要的package语句。在这个文件中添加如下函数:
import javafx.scene.layout.Panefun Pane.prettyPrint(y: Double, text: String) {}
通过在函数名前面加上类名Pane和句点,让Kotlin知道这是一个扩展函数,Pane类及其子类的所有实例都可调用它。导入这个扩展函数(我们稍后将这样做),就可像下面这样调用它:
// 示例代码(请不要在Eclipse中输入它们)val pane = Pane()pane.prettyPrint(50.0, primaryStage.title)
在调用prettyPrint的代码看来,prettyPrint就像是Pane类的一个普通方法一样。在函数Pane.prettyPrint()中,可访问用来调用它的Pane实例,为此只需使用this引用即可。明白这些后,我们来实现扩展函数prettyPrint。
别忘了使用Ctrl +空格(在macOS中为cmd + 空格)来输入所有的类名,以自动添加必要的
import语句;另外,务必选择javafx包中的相应类。
扩展函数prettyPrint的代码如下:
fun Pane.prettyPrint(y: Double, text: String) {val t = Text()t.text = textt.font = Font.font("Verdana", FontWeight.BOLD, 30.0)t.fill = Color.DARKBLUEt.x = 0.0t.y = ythis.children.add(t)}
为让你能够从正确的包中选择类(或手动编写正确的import语句),下面列出了上述代码中用到的所有类的全限定类名:
javafx.scene.layout.Pane;javafx.scene.text.Text;javafx.scene.text.Font;javafx.scene.text.FontWeight;javafx.scene.paint.Color。
打开目录src/main/kotlin中的文件App.kt,并添加如下import语句:
import javafxdemo.extensions.prettyPrint
注意,在扩展函数的全限定名中,并不包含源代码文件名
PaneExtensions,这是因为在Kotlin中,包名可以与源代码文件的目录结构完全不同。
如果我们要给这个文件中的其他类添加扩展函数prettyPrint,这完全可行;在这种情况下,只需前述import语句,就可调用所有名为prettyPrint的扩展函数。
打开文件App.kt,在JavaFXDemo类的方法start()中,将如下代码行添加到代码行val scene = Scene(pane, 500.0, 500.0)的后面:
pane.prettyPrint(100.0, primaryStage.title)
现在方法start()应类似于下面这样:
override fun start(primaryStage: Stage) {primaryStage.title = "Kotlin JavaFX Demo"val pane = Pane()pane.prettyPrint(50.0, primaryStage.title)val scene = Scene(pane, 500.0, 500.0)primaryStage.scene = sceneprimaryStage.show()}
再次运行这个程序,它应使用很大的字体打印文本Kotlin JavaFX Demo:

作为最后的修饰,我们来对打印的消息应用一种效果。为此,打开文件PaneExtensions.kt,并在代码行this.children.add(t)前面添加如下代码:
val shadow = InnerShadow()shadow.offsetX = 2.0shadow.offsetY = 2.0t.effect = shadow
为了让这种效果更突出,将代码行t.fill = Color.DARKBLUE改为t.fill = Color.YELLOW。
再次运行这个程序,现在它看起来要漂亮些:

10.3.4 布局窗格
JavaFX有多个内置的布局窗格(layout pane)类。可像前一个示例那样使用Pane类来手动放置每个子控件——通过计算它们的X和Y坐标,但这样做很繁琐,在窗口包含多个控件且可调整大小时尤其如此。
在大多数情况下,更佳的解决方案是使用布局窗格类,这些类负责自动放置子控件以及调整它们的大小。下表列出了最重要的内置布局。
| 布局类 | 描述 |
|---|---|
BorderPane
|
提供5个子窗格:上、左、中、右和下
|
HBox
| 在一个水平框中将每个子控件都放在前一个右边 |
VBox
| 在一个垂直框中将每个子控件都放在前一个下方 |
StackPane
| 将每个控件都堆叠在前一个上面,从而将控件组合起来 |
GridPane
| 以行列方式创建一个类似于网格的结构 |
FlowPane
| 将每个控件都放在前一个右边,到达最大宽度后重起一行,并将下一个控件放在这行的第一列。也可按先列后行的方式依次放置控件 |
TilePane
| 类似于FlowPane,但每个子控件的尺寸都相同 |
AncorPane
| 支持将子控件锚定在固定的位置(上、下、左、右或中);调整大小时,确保子控件相对于锚点的位置不变 |
由于所有的布局窗格都是Pane的子类,因此可混合使用不同的布局窗格。本章后面将演示一些较为常用的布局窗格。
有关布局的完整信息,请参阅Oracle JavaFX官方手册的Work with Layouts部分,其网址为http://docs.oracle.com/javase/8/javafx/layout-tutorial/。
10.3.5 实现基于BorderPane的布局
接下来在窗口中添加非常简单的动画。为此,首先来创建一个BorderPane,它将作为场景的根窗格。我们将把当前的Pane(它包含函数prettyPrint的输出)赋给BorderPane的top子窗格,让其充当应用程序的彩色标题。
打开文件App.kt,并重写函数start:使用下面的代码替换原来的代码。与往常一样,对于还未导入的类,别忘了使用Ctrl + 空格(或cmd + 空格)来输入它们的名称:
override fun start(primaryStage: Stage) {primaryStage.title = "Kotlin JavaFX Demo"val textField = TextField()val mainPane = BorderPane()mainPane.top = createHeaderPane(primaryStage.title)val scene = Scene(mainPane, 500.0, 500.0)primaryStage.scene = sceneprimaryStage.show()}
我们对代码进行了重构:对于表示标题的Pane实例,我们将创建它的代码放在一个独立的函数中,这个函数将在稍后编写。我们创建了一个TextField实例,用于放置用户输入的文本。还创建了一个名为mainPane的实例,并将其top子窗格设置为headerPane。现在,场景的根节点为mainPane。
下面来编写函数createHeaderPane()。将其放在函数start()的后面,并包含如下代码:
fun createHeaderPane(title: String): Pane {val pane = Pane()pane.prettyPrint(30.0, title)return pane}
运行这个项目,会发现没有什么变化。然而,拜BorderPane实例所赐,我们现在能够轻松地在窗口左边、中间、右边和底部添加新窗格了。
下面在窗口底部添加一个文本输入框,让用户能够输入将在屏幕上移动的文本。首先来编写一个函数,用于创建包含一个标签和文本框的HBox(水平框)。请将这个函数放在函数createHeaderPane()的后面。
对于还未导入的类,别忘了使用Ctrl + 空格(或cmd + 空格)来输入它们的名称,并选择
javafx包中的相应类。为了节省篇幅,本章后面不再提醒你这样做。
同样,我们将在一个独立的函数中创建并设置HBox窗格。为此,在前一个函数后面添加如下代码:
fun createInputPane(textField: TextField): Pane {val label = Label("Input text:")label.minWidth = 65.0val inputPane = HBox()inputPane.children.add(label)inputPane.children.add(textField)HBox.setHgrow(textField, Priority.ALWAYS)return inputPane}
下面来详细说说这些代码。
- 将一个文本输入框(在JavaFX中这种控件名为
TextField)作为参数传递给了这个函数。之所以这样做,是因为调用各种函数时,需要提供指向这个对象的引用;在这里,我们需要使用这个引用将文本框添加到HBox中。 - 我们创建了一个尺寸固定(宽65像素)的标签。
- 接下来,我们创建了一个
HBox布局窗格。前面说过,HBox布局窗格将其子节点(控件)依次放置在一行中。 - 将标签和
textField控件都添加到了HBox类实例inputPane的子控件列表中。 - 通过调用
HBox类的静态方法setHGrow,我们让HBox知道应让textField控件占据其余下的所有空间。 - 请注意,
HBox是Pane的子类,因此我们将函数createInputPane()返回类型指定为Pane而不是HBox。以后如果需要修改这个函数的实现,可保持其返回类型不变。
下面来将这个HBox添加到BorderPane中,否则将无法看到函数createInputPane()的效果。为此,向上滚动到函数start(),并在其中的代码行mainPane.top = createHeaderPane (primaryStage.title)后面添加如下代码:
mainPane.bottom = createInputPane(textField)
现在再次运行这个应用程序,你将在窗口底部看到一个标签和一个文本框:

如果你尝试调整窗口的大小,将发现标签和文本框依然位于窗口底部(这是因为当你调整窗口的高度时,LayoutPane会自动移动其底部部分),同时文本框的宽度总是窗口宽度减去标签宽度(这是因为我们调用了HBox类的静态方法setHGrow)。
10.3.6 实现动画
我们要让用户输入的文本在屏幕上移动。为了实现这种动画,需要跟踪一些值,因此我们将创建一个处理动画的类。不同于Java,Kotlin支持在同一个源代码文件中定义多个类,而不管这些类使用的访问限定符是什么。请在KotlinJavaFXDemo类的后面添加如下代码:
class AnimatedText {val animatedText = Text()val animationPane = Pane()var directionX = 3.0var directionY = 3.0fun getPane(textField: TextField): Pane {animatedText.x = 0.0animatedText.y = 0.0animatedText.font = Font.font("Verdana", FontWeight.BOLD, 15.0)animationPane.children.add(animatedText)return animationPane}}
通过将javafx.scene.text.Text对象的x和y属性都初始化为0.0,让文本一开始位于变量animationPane表示的窗口的左上角(0,0)。变量directionX和directionY用于实现动画:每一步都使用它们将文本沿水平和垂直方向都移动3像素。请注意,我们将包含用户输入的TextField控件传递给了函数getPane()。
现在,我们需要创建一个每帧都调用的函数,它计算文本的新位置并在必要时调整移动方向。为了让你能够完成这种任务,JavaFX提供了AnimationTimer类,这是一个抽象类,包含一个在每帧中都将调用的抽象方法——handle()。这个方法将帧的时间(一个表示上次调用后过去了多长时间的long值)作为参数,但为简单起见,我们没有在这个示例中使用它。如果你要在实际应用程序中实现逼真的动画,必须使用这个值来计算上次调用后过去了多长时间,并相应地移动物体。
我们在AnimatedText类中实现AnimationTimer类,为此将光标放在代码行var directionY = 3.0的末尾,添加一个空行,再添加如下代码:
val timer = object : AnimationTimer() {override fun handle(now: Long) {if (animatedText.x < 0.0 || animatedText.x > animationPane.width - animatedText.layoutBounds.width)directionX = -directionXif (animatedText.y < 0.0 || animatedText.y > animationPane.height)directionY = -directionYanimatedText.x += directionXanimatedText.y += directionY}}
语法object : AnimationTimer()可能看起来有点怪异。前面说过,AnimationTimer是一个抽象类,这意味着不能直接实例化它,因为扩展它的类必须提供它的抽象方法的实现。在刚才所说的代码行中,我们创建了一个扩展抽象类AnimationTimer的匿名对象。注意,我们没有给这个类指定名称,而是只指定了要将指向这个对象的引用存储到引用变量timer中。由于AnimationTimer类唯一的抽象方法是handle,因此匿名类只需重写这个方法。在这个方法中,我们检查文本的当前位置,并决定是否要在X和Y轴上沿相反的方向移动,再相应地更新对象animatedText的X和Y位置。
必须启动这个定时器(timer),我们在AnimatedText类的函数getPane()中这样做。为此,在代码行animationPane.children.add(animatedText)的后面,添加如下代码行:
timer.start()
我们需要将这个窗格加载到主场景的BorderPane布局的一个子窗格中,为此向上滚动到函数start(),并在其中的代码行mainPane.bottom = createInputPane(textField)后面添加如下代码行:
mainPane.center = AnimatedText().getPane(textField)
如果你现在运行这个应用程序,将发现没有任何变化。另外,当你在文本框中输入文本后,也不会发生任何事情。为什么会这样呢?因为我们还没有处理TextField中的输入,也没有在对象animatedText中以硬编码的方式指定文本。现在该来改变这种状况了。如果你使用的是当前市面上流行的其他的GUI工具包,这意味着需要给输入控件TextField添加一个侦听函数,再将其输入传递给对象animatedText,但JavaFX提供了一种强大得多的功能。
在JavaFX中,可将一个控件的属性绑定到另一个控件的属性。这种绑定可以是单向的(这意味着如果属性A被绑定到属性B,属性A将在它发生变化时自动更新属性B,但反过来不会),也可以是双向的(如果绑定了属性A和B,其中任何一个属性发生变化时,都会更新另一个属性)。显然,双向绑定的开销很高,且在这个示例中不需要双向绑定,因为在这个应用程序中,只有TextField控件能接受变化。为了将textField控件的属性text绑定到控件animatedText,在AnimatedText类的getPane()方法中,在代码行return animation Pane前面添加如下代码行:
animatedText.textProperty().bind(textField.textProperty())
这样,当textField的text属性发生变化时,animatedText的text属性将自动更新,但反过来不会。为防范这一点,JavaFX将在你通过代码修改animatedText的属性text时引发异常。
请运行这个应用程序。当你在文本框中输入文本后,这些文本将在窗口内来回移动:

如果你输入很长的文本,或者在文本移到窗口右边时缩小窗口,将发现文本可能不再沿水平方向移动,甚至不见了(当你缩小窗口后,它依然停留在原来的区域附近)。
下面来尝试找出导致这种问题的原因。请停止运行这个程序,我们将使用调试器来找出导致这种问题的原因。
10.3.7 调试程序
选择Eclipse菜单Run>Debug或按F11,程序将在调试模式下运行。输入一些文本,将窗口增大,等文本移到窗口右边后迅速缩小窗口,尝试让文本消失或停止不动。
出现这种情况时,使用调试器来尝试找出其中的原因:

按下面的说明打开并使用调试器。
(1)让程序继续运行并返回到Eclipse IDE。
(2)打开文件App.kt并找到代码行directionX = -directionX。在这行代码的左边有行号,请右击这个行号并在出现的菜单中选择Toggle Breakpoint。
(3)执行到包含断点的代码行后,程序将停止执行,并询问你是否要切换到Debug透视图(Eclipse提供的专门用于调试的透视图)。请选择Yes,因为这样Eclipse将显示针对调试进行了优化的用户界面。
(4)在Debug透视图中,左上角的窗口显示了当前应用程序中所有正在运行的线程,而右上角显示了当前函数的变量及其值。由于函数handle没有使用局部变量,因此你只能看到变量this(AnimatedText的实例变量)和now(函数handle的参数)。
(5)在包含代码的窗口中,包含断点的代码行将呈高亮显示。
(6)在工具栏中,找到并单击工具提示为“Resume”的按钮。
(7)你将发现程序将再次在运行到断点时立即停止。单击Resume按钮多次,你将发现每次调用函数handle时,好像都修改了directionX。
(8)为找出原因,在Variables选项卡中展开this变量,你将看到匿名的AnimationTimer实例的变量。
(9)注意,其中包含变量this$0。展开它,它包含AnimationTimer实例的父对象(一个AnimatedText对象)的变量。展开条目animatedText,这将显示对象animatedText的所有属性。找到属性x 并查看其值,你将发现它大于0。
由于animatedText.x大于当前窗口余下的空间,因此每次调用函数handle时都将改变方向,导致文本停止移动。在工具栏中,找到并单击工具提示为“Terminate”的按钮,再切换到Kotlin透视图:找到并单击Eclipse IDE窗口右上角的Kotlin按钮。
为了修复问题,可在修改移动方向时重置文本的位置。这样做,可确保文本在窗口内(除非窗口太小)。下面首先处理X方向。找到如下两行代码:
if (animatedText.x < 0.0 || animatedText.x > animationPane.width -animatedText.layoutBounds.width)directionX = -directionX
将上述代码替换为如下代码:
if (animatedText.x < 0.0) {animatedText.x = 0.0directionX = -directionX} else if (animatedText.x > animationPane.width - animatedText.layoutBounds.width) {animatedText.x = animationPane.width - animatedText.layoutBounds.widthdirectionX = -directionX}
对Y位置做同样的处理。找到如下两行代码:
if (animatedText.y < 0.0 || animatedText.y > animationPane.height)directionY = -directionY
将这些代码替换为如下代码:
if (animatedText.y < 0.0) {animatedText.y = 0.0directionY = -directionY} else if (animatedText.y > animationPane.height) {animatedText.y = animationPane.heightdirectionY = -directionY}
再次运行这个应用程序。现在它稳定得多了,不会在你调整窗口大小或输入的文本太长时停滞不前。当文本不在窗口内时,它将向后移动,以便在窗口内(条件是窗口足够大)。
