译自《Components》
组件
JavaFX使用戏剧类比来组织一个包含Stage
和Scene
组件的Application
。 TornadoFX通过提供View
,Controller
和Fragment
组件也构建在此类比基础之上。 虽然TornadoFX也使用Stage
和Scene
,但View
,Controller
和Fragment
引入了可以简化开发的新概念。 这些组件多数被自动维护为单例(singletons),并且可以通过简单的依赖注入(dependency injections)和其他方式相互通信。
您还可以选择使用FXML,稍后会讨论。 但首先,让我们继承App
来创建用于启动TornadoFX应用程序的入口点。
App和View的基础知识
要创建TornadoFX应用程序,您必须至少有一个继承了App
的类。App是应用程序的入口点,并指定初始View
。 实际上它继承了JavaFX的Application
,但是您不一定需要指定一个start()
或main()
方法。
但首先,让我们继承App
来创建自己的实现,并将主视图(primary view)指定为构造函数的第一个参数。
class MyApp: App(MyView::class)
视图(View)包含显示逻辑以及节点(Nodes)的布局,类似于JavaFX的Stage
。 它被作为单例(singleton)来自动管理。 当您声明一个View
,您必须指定一个root
属性,该属性可以是任何Node
类型,并且将保存视图(View)的内容。
在同一个Kotlin文件或在另一个新文件中,从View
继承出来一个新类。 覆盖其抽象root
属性并赋值VBox
,或您选择的任何其他Node
。
class MyView: View() {
override val root = VBox()
}
但是,我们可能想填充这个VBox
,作为root
控件。 使用初始化程序块 (initializer block),让我们添加一个JavaFX的Button
和一个Label
。 您可以使用 “plus assign” +=
运算符将子项添加到任何Pane
类型,包括这里的VBox
。
class MyView: View() {
override val root = VBox()
init {
root += Button("Press Me")
root += Label("")
}
}
虽然从查看上述代码来看,很清楚发生了什么,但TornadoFX还提供了一个构建器语法(builder syntax),可以进一步简化您的UI代码,并可通过查看代码来更轻松地推导出最终的UI。 我们将逐渐转向构建器语法,最后在下一章中全面介绍构建器(builders)。
虽然我们会向您介绍新概念,但您可能有时还会看到没有使用最佳做法的代码。 我们这样做是为了向您介绍这些概念,并让您更深入地了解底层发生的情况。 逐渐地,我们将会以更好的方式介绍更强大的结构来解决这个问题。
接下来我们将看到如何运行这个应用程序。
启动TornadoFX应用程序
较新版本的JVM知道如何在没有main()
方法的情况下启动JavaFX应用程序。 JavaFX应用程序(TornadoFX应用程序是其扩展),是继承javafx.application.Application
的任何类。 由于tornadofx.App
继承了javafx.application.Application
,TornadoFX应用程序没有什么不同。 因此,您将通过引用com.example.app.MyApp
启动该应用程序,并且您不一定需要一个main()
函数,除非您需要提供命令行参数。 在这种情况下,您将需要添加一个包级别的主函数到MyApp.kt文件:
fun main(args: Array<String>) {
Application.launch(MyApp::class.java, *args)
}
这个主函数将被编译进com.example.app.MyAppKt
- 注意最后的Kt
。 当您创建包级别的主函数时,它将始终具有完全限定包的类名,加上文件名,附加Kt
。
对于启动和测试App
,我们将使用Intellij IDEA。 导航到Run→Edit Configurations (图3.1)。
单击绿色“+”符号并创建一个新的应用程序配置(图3.2)。
指定 “主类(Main class)” 的名称,这应该是您的App
类。 您还需要指定它所在的模块(module)。给配置一个有意义的名称,如 “Launcher”。 之后点击 “OK”(图3.3)。
您可以通过选择Run→Run 'Launcher'或任何您命名的配置来运行 TornadoFX应用程序(图3.4)。
您现在应该看到您的应用程序启动了(图3.5)
恭喜! 您已经编写了您的第一个(虽然简单)TornadoFX应用程序。 现在看起来可能不是很好,但是当我们涵盖更多TornadoFX的强大功能时,我们将创建大量令人印象深刻的用户界面,几乎没有多少代码,而且只需要很少时间。 但首先让我们来更好地了解App
和View
之间发生的情况。
了解视图(View)
让我们深入了解View
的工作原理以及如何使用它。 看看我们刚刚构建的App
和View
类。
class MyApp: App(MyView::class)
class MyView: View() {
override val root = VBox()
init {
with(root) {
this += Button("Press Me")
this += Label("Waiting")
}
}
}
View
包含JavaFX节点的层次结构,并在它被调用的位置通过名称注入。 在下一节中,我们将学习如何利用强大的构建器(powerful builders)来快速创建这些Node
层次结构。TornadoFX维护的MyView
只有一个实例,有效地使其成为单例。TornadoFX还支持范围(scopes),它们可以将View
, Fragment
和Controller
的集合组合在一个单独的命名空间中,如果你愿意的话,那么View
只能是该范围内的单例。 这对于多文档接口应用程序(Multiple-Document Interface applications)和其他高级用例非常有用。 稍后再说。
使用inject()和嵌入视图(Embedding Views)
您也可以将一个或多个视图注入另一个View
。 下面我们将TopView
和BottomView
嵌入到MasterView
。 请注意,我们使用inject()
代理属性(delegate property)来懒惰地注入TopView
和BottomView
实例。 然后我们调用每个child View
的root
来赋值给BorderPane
(图3.6)。
class MasterView: View() {
val topView: TopView by inject()
val bottomView: BottomView by inject()
override val root = borderpane {
top = topView.root
bottom = bottomView.root
}
}
class TopView: View() {
override val root = label("Top View")
}
class BottomView: View() {
override val root = label("Bottom View")
}
如果您需要在视图间彼此沟通,您可以在每个child View
中创建一个属性来保存parent View
。
class MasterView : View() {
override val root = BorderPane()
val topView: TopView by inject()
val bottomView: BottomView by inject()
init {
with(root) {
top = topView.root
bottom = bottomView.root
}
topView.parent = this
bottomView.parent = this
}
}
class TopView: View() {
override val root = Label("Top View")
lateinit var parent: MasterView
}
class BottomView: View() {
override val root = Label("Bottom View")
lateinit var parent: MasterView
}
更通常地,您将使用Controller
或ViewModel
在视图之间进行通信,稍后我们将访问此主题。
使用find()来注入
inject()
代理(delegate)将懒惰地将一个给定的组件赋值给一个属性。 第一次调用该组件时,它将被检索。 或者,不使用inject()
代理,您可以使用find()
函数来检索View
或其他组件的单例实例。
class MasterView : View() {
override val root = BorderPane()
val topView = find(TopView::class)
val bottomView = find(BottomView::class)
init {
with(root) {
top = topView.root
bottom = bottomView.root
}
}
}
class TopView: View() {
override val root = Label("Top View")
}
class BottomView: View() {
override val root = Label("Bottom View")
}
您可以使用find()
或inject()
,但是使用inject()
代理是执行依赖注入的首选方法。
虽然我们将在下一章更深入地介绍构建器(builders),但现在是时候来揭示上述示例可以用更加简洁明了的语法来编写了:
class MasterView : View() {
override val root = borderpane {
top(TopView::class)
bottom(BottomView::class)
}
}
我们不是先注入TopView
和BottomView
,然后将它们各自的root
节点赋值给BorderPane
的top
和bottom
属性,而是使用构建器语法(builder syntax,全部小写)来指定BorderPane,然后声明性地告诉TornadoFX拉入两个子视图,并使他们自动赋值到top
和bottom
属性。 我们希望您会认同,这是很具表现力的,具有少得多的样板(boiler plate)。 这是TornadoFX试图以此为生的最重要的原则之一:减少样板(boiler plate),提高可读性。 最终的结果往往是更少的代码和更少的错误。
控制器(Controllers)
在许多情况下,将UI分为三个不同的部分被认为是一种很好的做法:
- 模型(Model) - 拥有核心逻辑和数据的业务代码层。
- 视图(View)- 具有各种输入和输出控件的视觉显示。
- 控制器(Controller) - “中间人(middleman)” 介入(mediating)模型和视图之间的事件。
还有其他的MVC流派,例如MVVM和MVP,所有这些都可以在TornadoFX中使用。
尽管您可以将模型和控制器的所有逻辑放在视图之中,但是最好将这三个部分清楚地分开,以便最大程度地实现可重用性。 一个常用的模式是MVC模式。 在TornadoFX中,可以注入一个Controller
来支持View
。
这里给出一个简单的例子。 使用一个TextField
创建一个简单的View
,当一个Button
被点击时,其值被写入到一个“数据库”。 我们可以注入一个处理与写入数据库的模型交互的Controller
。 由于这个例子是简化的,所以不会有实际的数据库,但打印的消息将作为占位符(图3.7)。
class MyView : View() {
val controller: MyController by inject()
var inputField: TextField by singleAssign()
override val root = vbox {
label("Input")
inputField = textfield()
button("Commit") {
action {
controller.writeToDb(inputField.text)
inputField.clear()
}
}
}
}
class MyController: Controller() {
fun writeToDb(inputValue: String) {
println("Writing $inputValue to database!")
}
}
当我们构建UI时,我们确保添加对inputField
的引用,以便以后可以在“Commit”按钮的onClick
事件处理程序中引用。 当单击“Commit”按钮时,您将看到控制器向控制台打印一行。
Writing Alpha to database!
重要的是要注意,虽然上述代码是可工作的,甚至可能看起来也不错,但是很好的做法是要避免直接引用其他UI元素。 如果您将UI元素绑定到属性并操作属性,那么您的代码将更容易重构。 稍后我们将介绍ViewModel
,它提供了更简单的方法来处理这种类型的交互。
长时间运行的任务
每当您在控制器中调用函数时,需要确定该函数是否立即返回,或者执行潜在的长时间运行的任务。 如果您在JavaFX应用程序线程中调用函数,则UI将在响应完成之前无响应。 无响应的UI是用户感知(user perception)的杀手,因此请确保您在后台运行昂贵的操作。 TornadoFX提供了runAsync
功能来帮助您。
放置在一个runAsync
块内的代码将在后台运行。 如果后台调用的结果需要更新您的UI,则必须确保您在JavaFX的应用程序线程中应用更改。ui
区块正是这样。
val textfield = textfield()
button("Update text") {
action {
runAsync {
myController.loadText()
} ui { loadedText ->
textfield.text = loadedText
}
}
}
当单击按钮时,将运行action
构建器(将ActionEvent
代理给setAction
方法)中的操作。 它调用myController.loadText()
,并当它返回shi将结果应用于textfield
的text
属性。 当控制器功能运行时,UI保持响应。
在表面以下, runAsync
会创建一个JavaFX的Task
对象,并将创建一个单独的线程以在Task
里运行你的调用。 您可以将此Task
赋值给变量,并将其绑定到UI,以在运行时显示进度。
事实上,这是很常见的,为此还有一个名为TaskStatus
的默认ViewModel
,它包含running
,message
,title
和progress
等可观察值。 您可以使用TaskStatus
对象的特定实例来提供runAsync
调用,或使用默认值。
TornadoFX源代码在AsyncProgressApp.kt
文件中包含一个示例用法。
还有一个名为runAsyncWithProgress
的runAsync
版本, runAsync
在长时间运行的操作运行时,以进度指示器来覆盖当前节点。
singleAssign()属性代理
在上面的例子中,我们用singleAssign
代理初始化了inputField
属性。 如果要保证只赋值一次值,可以使用singleAssign()
代理代替Kotlin的lateinit
关键字。 这将导致第二个赋值引发错误,并且在赋值之前过早访问时也会出错。
您可以在附录A1中详细查看有关singleAssign()的更多信息,但是现在知道它保证只能赋值一次给var。 它也是线程安全的,有助于减轻可变性(mutability)问题。
您还可以使用控制器向View提供数据(图3.8)。
class MyView : View() {
val controller: MyController by inject()
override val root = vbox {
label("My items")
listview(controller.values)
}
}
class MyController: Controller() {
val values = FXCollections.observableArrayList("Alpha","Beta","Gamma","Delta")
}
VBox
包含一个Label
和一个ListView
,Controller
的values
属性被赋值给ListView
的items
属性。
无论他们是读数据还是写数据,控制器都可能会执行长时间运行的任务,从而不能在JavaFX线程上执行任务。 本章后面您将学习如何使用runAsync
构造来轻松地将工作卸载到工作线程。
分段(Fragment)
您创建的任何View
都是单例,这意味着您通常只能在一个地方一次使用它。 原因是在JavaFX应用程序中View
的根节点(root node)只能具有单个父级。 如果你赋值另一个父级,它将从它的先前的父级消失。
但是,如果您想创建一个短暂(short-lived)的UI,或者可以在多个地方使用,请考虑使用Fragment
。 片段(Fragment)是可以有多个实例的特殊类型的View
。 它们对于弹出窗口或更大的UI甚至是单个ListCell
都特别有用。 稍后我们将会看到一个名为ListCellFragment
的专门的片段。
View
和Fragment
支持openModal()
, openWindow()
和openInternalWindow()
,它将在单独的窗口(Window)中打开根节点。
class MyView : View() {
override val root = vbox {
button("Press Me") {
action {
find(MyFragment::class).openModal(stageStyle = StageStyle.UTILITY)
}
}
}
}
class MyFragment: Fragment() {
override val root = label("This is a popup")
}
您也可以将可选参数传递给openModal()
以修改其一些行为。
openModal()
的可选参数
参数 | 类型 | 描述 |
---|---|---|
stageStyle | StageStyle | 定义·Stage·可能的枚举样式之一。 默认值: ·StageStyle.DECORATED· |
modality | Modality | 定义Stage一个可能的枚举模式类型。 默认值: Modality.APPLICATION_MODAL |
escapeClosesWindow | Boolean | 设置ESC键调用closeModal() 。 默认值: true |
owner | Window | 指定此阶段的所有者窗口 |
block | Boolean | 阻止UI执行,直到窗口关闭。 默认值: false |
InternalWindow
尽管openModal
在一个新的Stage
打开, openInternalWindow
却在当前的根节点(current root node)或任何你指定的其他节点上打开:
button("Open editor") {
action {
openInternalWindow(Editor::class)
}
}
内部窗口(internal window)的一个很好的用例是单舞台(single stage)环境(如JPro),或者如果要自定义窗口,修剪该窗口使其看起来更符合你的应用程序的设计。 内部窗口(Internal Window)可以使用CSS
样式。 有关样式可更改(styleable)属性的更多信息,请查看InternalWindow.Styles
类。
内部窗口(internal window)API在一个重要方面与模态/窗口(modal/window)不同。 由于窗口(window)在现有节点上打开,您通常会在你想要其在上打开的View
中调用openInternalWindow()
。 您提供要显示的视图(View),您也可以选择通过owner
参数提供要在其上打开的节点(node)。
openInternalWindow()
的可选参数
参数 | 类型 | 描述 |
---|---|---|
view | UIComponent | 组件将是新窗口的内容 |
view | KClass | 或者,您可以提供视图的类而不是实例 |
icon | Node | 可选的窗口图标 |
scope | Scope | 如果指定视图类,则还可以指定用于获取视图的作用域 |
modal | Boolean | 定义在内部窗口处于活动状态时是否应该禁用被覆盖节点。 默认值: true |
escapeClosesWindow | Boolean | 设置ESC键调用close() 。 默认值: true |
owner | Node | 指定此窗口的所有者节点。 默认情况下,该窗口将覆盖此视图的根节点 |
关闭模式窗口
使用openModal()
, openWindow()
或openInternalWindow()
打开的任何Component
都可以通过调用closeModal()
关闭。 如果需要使用findParentOfType(InternalWindow::class)
也可以直接访问InternalWindow
实例。
更换视图和对接事件(Replacing Views and Docking Events)
使用TornadoFX,可以使用replaceWith()
方便地与当前View
进行交换,并可选择添加一个转换(transition)。 在下面的示例中,每个View
上的Button
将切换到另一个视图,可以是MyView1
或MyView2
(图3.10)。
class MyView1: View() {
override val root = vbox {
button("Go to MyView2") {
action {
replaceWith(MyView2::class)
}
}
}
}
class MyView2: View() {
override val root = vbox {
button("Go to MyView1") {
action {
replaceWith(MyView1::class)
}
}
}
}
您还可以选择为两个视图之间的转换指定一个精巧的动画。
replaceWith(MyView1::class, ViewTransition.Slide(0.3.seconds, Direction.LEFT)
这可以通过用另一个View
的root
替换给定View
上的root
。 View
具有两个函数可以重载(override),用于在其root Node
连接到父级( onDock()
)以及断开连接( onUndock()
)时。 每当View
进入或退出时,您可以利用这两个事件进行“连接”和“清理”。 运行下面的代码时您会注意到,每当View
被交换时,它将取消(undock )上一个View
并停靠(dock )新的。 您可以利用这两个事件来管理初始化(initialization)和处理(disposal)任务。
class MyView1: View() {
override val root = vbox {
button("Go to MyView2") {
action {
replaceWith(MyView2::class)
}
}
}
override fun onDock() {
println("Docking MyView1!")
}
override fun onUndock() {
println("Undocking MyView1!")
}
}
class MyView2: View() {
override val root = vbox {
button("Go to MyView1") {
action {
replaceWith(MyView1::class)
}
}
}
override fun onDock() {
println("Docking MyView2!")
}
override fun onUndock() {
println("Undocking MyView2!")
}
}
将参数传递给视图
在视图之间传递信息的最佳方式通常是注入ViewModel
。 即使如此,可以将参数传递给其他组件仍然是很便利的。 find()
和inject()
函数支持Pair<String, Any>
这样的varargs
,就可以用于此目的。 考虑在一个客户列表中,为选定的客户项打开客户信息编辑器的情形。 编辑客户信息的操作可能如下所示:
fun editCustomer(customer: Customer) {
find<CustomerEditor>(mapOf(CustomerEditor::customer to customer).openWindow())
}
这些参数作为映射传递,其中键(key)是视图中的属性(property),值(value)是您希望的属性的任何值。 这为您提供了一种配置目标视图参数的安全方式。
这里我们使用Kotlin的to
语法来创建参数。 如果你愿意,这也可以写成Pair(CustomerEditor::customer, customer)
。 编辑器现在可以这样访问参数:
class CustomerEditor : Fragment() {
val customer: Customer by param()
}
如果要检查参数,而不是盲目依赖它们是可用的,您可以将其声明为可空(nullable),或参考其params
映射:
class CustomerEditor : Fragment() {
init {
val customer = params["customer"] as? Customer
if (customer != null) {
...
}
}
}
如果您不关心类型安全性,还可以将参数作为mapOf("customer" to customer)
传递,但是如果在目标视图中重命名属性,则会错过自动重构(automatic refactoring)。
访问主舞台(primary stage)
View
具有一个名为primaryStage
的属性,允许您操作支持它的Stage
的属性,例如窗口大小。 通过openModal()
打开的任何View
或Fragment
也将有一个modalStage
属性可用。
访问场景(scene)
有时需要从View
或Fragment
获取当前场景。 这可以通过root.scene
来实现,或者如果你位于一个类型安全的构建器(type safe builder)内部,还有一个更短的方法,只需使用scene
。
访问资源(resources)
许多JavaFX API将资源作为URL
或URL
的toExternalForm
。 要检索资源url,通常会如下所写:
val myAudioClip = AudioClip(MyView::class.java.getResource("mysound.wav").toExternalForm())
每个Component
都有一个resources
对象,可以检索resources
的外部形式url(external form url),如下所示:
val myAudiClip = AudioClip(resources["mysound.wav"])
如果您需要一个实际的URL
,可以这样检索:
val myResourceURL = resources.url( "mysound.wav" )
resources
助手还有一些其他有用的功能,可帮助您将相对于Component
的文件转换为所需类型的对象:
val myJsonObject = resources.json("myobject.json")
val myJsonArray = resources.jsonArray("myarray.json")
val myStream = resources.stream("somefile")
值得一提的是, json
和jsonArray
函数也可以在InputStream
对象上使用。
资源与Component
相对应,但您也可以通过完整路径,从/
开始检索资源。
动作的快捷键和组合键
您可以在键入某些组合键时触发动作(fire actions)。 这是用shortcut
函数完成的:
shortcut(KeyCombination.valueOf("Ctrl+Y")) {
doSomething()
}
还有一个字符串版本的shortcut
函数与此相同,但是不太冗长:
shortcut("Ctrl+Y")) {
doSomething()
}
您还可以直接向按钮操作添加快捷方式:
button("Save") {
action { doSave() }
shortcut("Ctrl+S")
}
触摸支持
JavaFX对触摸的支持开箱即用,现在唯一需要改进的地方就是以更方便的方式处理shortpress
和longpress
。 它由两个类似于action
的函数组成,可以在任何Node
上进行配置:
shortpress { println("Activated on short press") }
longpress { println("Activated on long press") }
这两个函数都接受consume
参数,默认情况下为false
。 将其设置为true
将防止按压事件(press event)发生事件冒泡(event bubbling)。longpress
函数还支持一个threshold
参数,用于确定longpress
积累的时间。 默认为700.millis
。
总结
TornadoFX充满了简单,直观而又强大的注入工具来管理视图和控制器(Views and Controllers)。 它还使用Fragment
简化对话框和其他小型UI。 尽管迄今为止,我们构建的应用程序非常简单,但希望您能欣赏到TornadoFX给JavaFX引入的简化概念。 在下一章中,我们将介绍可以说是TornadoFX最强大的功能:Type-Safe Builders
。