美文网首页 TornadoFX 教程
TornadoFX编程指南,第3章,组件

TornadoFX编程指南,第3章,组件

作者: 公子小水 | 来源:发表于2017-08-06 17:11 被阅读1463次

    译自《Components》

    组件

    JavaFX使用戏剧类比来组织一个包含StageScene组件的Application。 TornadoFX通过提供ViewControllerFragment组件也构建在此类比基础之上。 虽然TornadoFX也使用StageScene,但ViewControllerFragment引入了可以简化开发的新概念。 这些组件多数被自动维护为单例(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.1

    单击绿色“+”符号并创建一个新的应用程序配置(图3.2)。

    图3.2

    指定 “主类(Main class)” 的名称,这应该是您的App类。 您还需要指定它所在的模块(module)。给配置一个有意义的名称,如 “Launcher”。 之后点击 “OK”(图3.3)。

    图3.3

    您可以通过选择Run→Run 'Launcher'或任何您命名的配置来运行 TornadoFX应用程序(图3.4)。

    图3.4

    您现在应该看到您的应用程序启动了(图3.5)

    图3.5

    恭喜! 您已经编写了您的第一个(虽然简单)TornadoFX应用程序。 现在看起来可能不是很好,但是当我们涵盖更多TornadoFX的强大功能时,我们将创建大量令人印象深刻的用户界面,几乎没有多少代码,而且只需要很少时间。 但首先让我们来更好地了解AppView之间发生的情况。

    了解视图(View)

    让我们深入了解View的工作原理以及如何使用它。 看看我们刚刚构建的AppView类。

    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),它们可以将ViewFragmentController的集合组合在一个单独的命名空间中,如果你愿意的话,那么View只能是该范围内的单例。 这对于多文档接口应用程序(Multiple-Document Interface applications)和其他高级用例非常有用。 稍后再说。

    使用inject()和嵌入视图(Embedding Views)

    您也可以将一个或多个视图注入另一个View 。 下面我们将TopViewBottomView嵌入到MasterView。 请注意,我们使用inject()代理属性(delegate property)来懒惰地注入TopViewBottomView实例。 然后我们调用每个child Viewroot来赋值给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")
    }
    
    图3.6

    如果您需要在视图间彼此沟通,您可以在每个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
    }
    

    更通常地,您将使用ControllerViewModel在视图之间进行通信,稍后我们将访问此主题。

    使用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)
        }
    }
    

    我们不是先注入TopViewBottomView,然后将它们各自的root节点赋值给BorderPanetopbottom属性,而是使用构建器语法(builder syntax,全部小写)来指定BorderPane,然后声明性地告诉TornadoFX拉入两个子视图,并使他们自动赋值到topbottom属性。 我们希望您会认同,这是很具表现力的,具有少得多的样板(boiler plate)。 这是TornadoFX试图以此为生的最重要的原则之一:减少样板(boiler plate),提高可读性。 最终的结果往往是更少的代码和更少的错误。

    控制器(Controllers)

    在许多情况下,将UI分为三个不同的部分被认为是一种很好的做法:

      1. 模型(Model) - 拥有核心逻辑和数据的业务代码层。
      1. 视图(View)- 具有各种输入和输出控件的视觉显示。
      1. 控制器(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!")
        }
    }
    
    图3.7

    当我们构建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将结果应用于textfieldtext属性。 当控制器功能运行时,UI保持响应。

    在表面以下, runAsync会创建一个JavaFX的Task对象,并将创建一个单独的线程以在Task里运行你的调用。 您可以将此Task赋值给变量,并将其绑定到UI,以在运行时显示进度。

    事实上,这是很常见的,为此还有一个名为TaskStatus的默认ViewModel,它包含runningmessagetitleprogress等可观察值。 您可以使用TaskStatus对象的特定实例来提供runAsync调用,或使用默认值。

    TornadoFX源代码在AsyncProgressApp.kt文件中包含一个示例用法。

    还有一个名为runAsyncWithProgressrunAsync版本, 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")
    }
    
    图3.8

    VBox包含一个Label和一个ListViewControllervalues属性被赋值给ListViewitems属性。

    无论他们是读数据还是写数据,控制器都可能会执行长时间运行的任务,从而不能在JavaFX线程上执行任务。 本章后面您将学习如何使用runAsync构造来轻松地将工作卸载到工作线程。

    分段(Fragment)

    您创建的任何View都是单例,这意味着您通常只能在一个地方一次使用它。 原因是在JavaFX应用程序中View的根节点(root node)只能具有单个父级。 如果你赋值另一个父级,它将从它的先前的父级消失。

    但是,如果您想创建一个短暂(short-lived)的UI,或者可以在多个地方使用,请考虑使用Fragment。 片段(Fragment)是可以有多个实例的特殊类型的View。 它们对于弹出窗口或更大的UI甚至是单个ListCell都特别有用。 稍后我们将会看到一个名为ListCellFragment的专门的片段。

    ViewFragment支持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)
            }
        }
    
    图3.9

    内部窗口(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将切换到另一个视图,可以是MyView1MyView2 (图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)
                }
            }
        }
    }
    
    图3.10

    您还可以选择为两个视图之间的转换指定一个精巧的动画。

    replaceWith(MyView1::class, ViewTransition.Slide(0.3.seconds, Direction.LEFT)
    

    这可以通过用另一个Viewroot替换给定View上的rootView具有两个函数可以重载(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()打开的任何ViewFragment也将有一个modalStage属性可用。

    访问场景(scene)

    有时需要从ViewFragment获取当前场景。 这可以通过root.scene来实现,或者如果你位于一个类型安全的构建器(type safe builder)内部,还有一个更短的方法,只需使用scene

    访问资源(resources)

    许多JavaFX API将资源作为URLURLtoExternalForm。 要检索资源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")
    

    值得一提的是, jsonjsonArray函数也可以在InputStream对象上使用。

    资源与Component相对应,但您也可以通过完整路径,从/开始检索资源。

    动作的快捷键和组合键

    您可以在键入某些组合键时触发动作(fire actions)。 这是用shortcut函数完成的:

    shortcut(KeyCombination.valueOf("Ctrl+Y")) {
        doSomething()
    }
    

    还有一个字符串版本的shortcut函数与此相同,但是不太冗长:

    shortcut("Ctrl+Y")) {
        doSomething()
    }
    

    您还可以直接向按钮操作添加快捷方式:

    button("Save") {
        action { doSave() }
        shortcut("Ctrl+S")
    }
    

    触摸支持

    JavaFX对触摸的支持开箱即用,现在唯一需要改进的地方就是以更方便的方式处理shortpresslongpress。 它由两个类似于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

    相关文章

      网友评论

        本文标题:TornadoFX编程指南,第3章,组件

        本文链接:https://www.haomeiwen.com/subject/wjwilxtx.html