Kotlin TornadoFXで電卓アプリを作ってみよう

こんにちは、成田です。
今週からアドテク領域を担当する部署に異動することになってやる気に満ちています🔥🔥🔥
いろいろ学ばせていただいて、得た知識はこの場で共有するかもしれません。

TornadoFXとは

Javaの標準ライブラリであるJavaFXをKotlin用に拡張したものです。
そもそもJavaFXはUIプラットフォームを提供するライブラリなため、TornadoFXではJavaFXの機能をKotlinライクに実装できるようになっています。
github.com

導入

今回はgradleを使った導入手順を説明します。
手順は以下のようになります。

  1. gradleプロジェクトの生成
  2. build.gradleをKotlin用に編集
  3. TornadoFXを依存関係に追加

まずgradleが必要なため、インストールしていない場合はインストールしましょう。
gradleのインストール

1. gradleプロジェクトの生成

gradleには初期プロジェクト生成時に使用できるテンプレートがいくつかあります。
テンプレートの種類は以下のコマンドで確認できます。

$ gradle help --task :init

今回はjava-libraryを使用します。

$ gradle init --type java-library

プロジェクトが生成された後は以下のようなディレクトリ構造が確認できるかと思います。

├── build.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
└── src
    ├── main
    │   └── java
    │       └── Library.java
    └── test
        └── java
            └── LibraryTest.java

次は出来上がったプロジェクトをKotlin用に書き換えていきましょう。

2. build.gradleをKotlin用に編集

Kotlin用にgraldeプロジェクトを生成してくれるオプションは今の所存在しないため、上記で生成したプロジェクトをKotlin用に編集していきます。
まず、Kotlin公式ページの説明に沿って諸々をbuild.gradleに記述していきます。

apply plugin: "kotlin"
apply plugin: "application"
mainClassName = "Main" 


buildscript {
    ext.kotlin_version = "1.2.30"

    repositories {
        mavenCentral()
    }   

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }   
}

repositories {
    jcenter()
}

dependencies {
    implementation "com.google.guava:guava:21.0"
    testImplementation "junit:junit:4.12"
}

build.gradleの記述方法はKotlin公式ページのUsing Gradleを見てみると理解しやすいでしょう。
次はsrc/配下のディレクトリ構造を少し変えていきます。
gradle init時にjava-libraryテンプレートを使用したためjavaディレクトリが存在しますが、これは不要です。
また、kotlinディレクトリを生成する必要があります。
そして、build.gradleのmainClassNameMainというクラス名にしたのでMain.ktというファイルを追加していきます。

$ rm -rf src/main/java
$ rm -rf src/test/java
$ mkdir src/main/kotlin
$ touch src/main/kotlin/Main.kt

次はgradleのdependenciesタスクにTornadoFXを追加しましょう。

3. TornadoFXを依存関係に追加

build.gradleというファイルに依存関係を追加していきます。
dependenciesのブロックに以下のように記述します。

dependencies {
    implementation "com.google.guava:guava:21.0"
    testImplementation "junit:junit:4.12"

    // これを追加
    compile "no.tornado:tornadofx:1.7.14"
}

以上で、導入は完了です。

空のウィンドウを表示してみる

導入が完了したので最後に空のウィンドウを表示してみて今回は最後にします。

// src/main/kotlin/Main.kt
import tornadofx.*
import javafx.scene.layout.VBox

class Main : App(MyView::class)

class MyView : View() {
    override val root = VBox()
}

この記述が完了したらビルド、実行をしてみてください。
真っ白なウィンドウが表示されるはずです。

$ gradle build
$ gradle run

f:id:AdwaysEngineerBlog:20180713025834p:plain

JavaFXにはUIを記述する方法がいくつかあります。

  • FXMLというマークアップで記述し実装側でレイアウト系のクラスを継承したクラスと紐づける
  • コードでcontrol系のインスタンス(ボタン、ラベル、テキスト)を生成しレイアウト系のインスタンスに割り当てていく

今回は前者のFXML形式を使っていきます。
JavaFXとは言いましたが、TornadoFXはJavaFXの拡張なので実装の違いは特にありません。
ではまずUIから実装していきます。

UIツールセットの導入

今回はマテリアルデザインを簡単にしてくれるJFoenixというライブラリを使用しましょう。
build.gradleのdependenciesに以下の1行を追加します。
 

build.gradle

dependencies {
    implementation "com.google.guava:guava:21.0"
    testImplementation "junit:junit:4.12"
    compile "no.tornado:tornadofx:1.7.14"

    // これを追加
    compile 'com.jfoenix:jfoenix:1.10.0'
}

UIの実装

まず完成後の実装を載せます。
 

src/main/resources/Calculator.fxml

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.layout.HBox?>
<?import java.net.URL?>
<?import com.jfoenix.controls.JFXButton ?>

<VBox spacing="10" xmlns="http://javafx.com/javafx/null" xmlns:fx="http://javafx.com/fxml/1">
  <stylesheets>
    <URL value="@/css/application.css"/>
  </stylesheets>
  <padding>
    <Insets top="30" right="30" bottom="30" left="30"/>
  </padding>

  <GridPane alignment="center" hgap="5" vgap="5">
    <HBox alignment="top_right" styleClass='result'
      GridPane.rowIndex="0" 
      GridPane.columnIndex="0"
      GridPane.columnSpan="4">
      <Label fx:id="count" style="-fx-text-fill: white"/>
    </HBox>

    <JFXButton GridPane.columnIndex="0" GridPane.rowIndex="1" text="C"   onAction="#onClearClick"     styleClass="symbol"/>
    <JFXButton GridPane.columnIndex="1" GridPane.rowIndex="1" text="+/-" styleClass="symbol"/>
    <JFXButton GridPane.columnIndex="2" GridPane.rowIndex="1" text="%"  styleClass="symbol"/>
    <JFXButton GridPane.columnIndex="3" GridPane.rowIndex="1" text="÷"   onAction="#onOperationClick" styleClass="symbol"/>

    <JFXButton GridPane.columnIndex="0" GridPane.rowIndex="2" text="7" onAction="#onNumberClick"     styleClass="number"/>
    <JFXButton GridPane.columnIndex="1" GridPane.rowIndex="2" text="8" onAction="#onNumberClick"     styleClass="number"/>
    <JFXButton GridPane.columnIndex="2" GridPane.rowIndex="2" text="9" onAction="#onNumberClick"     styleClass="number"/>
    <JFXButton GridPane.columnIndex="3" GridPane.rowIndex="2" text="x" onAction="#onOperationClick"  styleClass="symbol"/>

    <JFXButton GridPane.columnIndex="0" GridPane.rowIndex="3" text="4" onAction="#onNumberClick"      styleClass="number"/>
    <JFXButton GridPane.columnIndex="1" GridPane.rowIndex="3" text="5" onAction="#onNumberClick"      styleClass="number"/>
    <JFXButton GridPane.columnIndex="2" GridPane.rowIndex="3" text="6" onAction="#onNumberClick"      styleClass="number"/>
    <JFXButton GridPane.columnIndex="3" GridPane.rowIndex="3" text="-" onAction="#onOperationClick"   styleClass="symbol"/>

    <JFXButton GridPane.columnIndex="0" GridPane.rowIndex="4" text="1" onAction="#onNumberClick"      styleClass="number"/>
    <JFXButton GridPane.columnIndex="1" GridPane.rowIndex="4" text="2" onAction="#onNumberClick"      styleClass="number"/>
    <JFXButton GridPane.columnIndex="2" GridPane.rowIndex="4" text="3" onAction="#onNumberClick"      styleClass="number"/>
    <JFXButton GridPane.columnIndex="3" GridPane.rowIndex="4" text="+" onAction="#onOperationClick"   styleClass="symbol"/>

    <JFXButton GridPane.columnIndex="0" GridPane.rowIndex="5" text="0" onAction="#onNumberClick"    styleClass="number"/>
    <JFXButton GridPane.columnIndex="2" GridPane.rowIndex="5" text="." styleClass="symbol"/>
    <JFXButton GridPane.columnIndex="3" GridPane.rowIndex="5" text="=" onAction="#onCalculateClick" styleClass="symbol"/>
  </GridPane>
</VBox>

各レイアウトについては特に難しい概念がないので省きます。
JFXButtonに対してonAction属性を付与していますが、これはロジックの実装の際にController側で実装するハンドラーメソッドです。
各ボタンが押されたら、Controller側の任意のメソッドが実行されます。

graldeプロジェクトで初期化したので、このfxmlはsrc/main/resources直下に置きます。
mavenを使っている人も同じだと思います。

一点注意したいのは以下の一部抜粋した箇所です。

  <stylesheets>
    <URL value="@/css/application.css"/>
  </stylesheets>

今回スタイルも記述したので、application.cssというファイルをsrc/main/resources/css直下に配置し、以下のスタイルを記述します。
 

src/main/resources/css/application.css

.root {
  -fx-background-color: #111;
}

.jfx-button {
  -fx-text-fill: WHITE;
  -fx-min-width: 45px;
  -fx-min-height: 45px;
}

.result {
  -fx-text-fill: WHITE;
  -fx-font-size: 25px;
}

.number {
  -fx-background-color: #00d11e;
  -fx-font-size:15px;
}

.symbol {
  -fx-background-color: #ff8421;
  -fx-font-size:15px;
}

.rootはHTMLでいうところのbodyだと思ってください。
各レイアウト要素自体へのスタイル割り当てはほぼHTML, CSSと同様ですが、JavaFXの場合は.要素名で任意の要素を使用する全ての箇所へのスタイル割り当てが可能です。

例) 全てのLabel要素へテキストカラーを白くするスタイルを割り当てる

.label {
  -fx-text-fill: black;
}

ロジック側の実装

UIが完成したので次はUIと紐づくアクション(イベントハンドラー)の実装をします。
まず、前回実装したメインファイルとなるMain.ktを書き換えます。
 

src/main/kotlin/calculator/Main.kt

package calculator
import calculator.controller.CalculatorController
import tornadofx.App

class Main : App(CalculatorController::class)

CalculatorControllerというクラスをimportし、アプリケーション起動時に最初に実行されるControllerとします。
ControllerはFXMLと紐づくため、最初に表示されるのはCalculatorControllerと紐づけようとしている先ほど記述したFXMLです。
では次にCalculatorControllerを実装します。
 

src/main/kotlin/calculator/controller/CalculatorController.kt

package calculator.controller
import  calculator.Operation

import com.jfoenix.controls.JFXButton
import javafx.event.ActionEvent
import javafx.scene.control.Label
import javafx.scene.layout.VBox
import tornadofx.View

class CalculatorController: View() {
  var result: Int =  0
  var refresh: Boolean = false
  var keepingNumber: Int = 0
  var operation: String = ""
  val count: Label by fxid()
  override val root: VBox by fxml("/Calculator.fxml")

  init {
    count.text = ""
    with(root) {
      root.prefWidth = 250.0
      root.prefHeight = 400.0
    }
  }

  /* 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 */
  fun onNumberClick(e: ActionEvent) {
    if (refresh) {
      count.text = ""
      refresh = false
    }

    val text = e.getSource()
    if (text is JFXButton) {
      count.text += text.getText()
      keepingNumber = count.text.toInt()
    }
  }

  /* +, -, x, ÷  */
  fun onOperationClick(e: ActionEvent) {
    if (count.text.isNullOrEmpty()) return
    result = count.text.toInt()
    refresh = false

    count.text = ""
    val text = e.getSource()
    if (text is JFXButton) {
      operation = text.getText()
    }
  }

  fun onCalculateClick() {
    if (count.text.isNullOrEmpty()) return
    when (operation) {
      Operation.plus -> {
        if (refresh) {
          result += keepingNumber
        } else {
          result += count.text.toInt()
        }
      }
      Operation.minus -> {
        if (refresh) {
          result -= keepingNumber
        } else {
          result -= count.text.toInt()
        }
      }
      Operation.multiple -> {
        if (refresh) {
          result *= keepingNumber
        } else {
          result *= count.text.toInt()
        }
      }
      Operation.divide -> {
        if (refresh) {
          result /= keepingNumber
        } else {
          result /= count.text.toInt()
        }
      }
    }
    count.text = result.toString()
    refresh = true
  }

  fun onClearClick() {
    count.text = ""
    result = 0
  }
}

この実装内にはUIの実装で記述したFXMLのonAction属性のvalueに紐づくメソッドが複数あります。
前述した通り、FXMLのonAction属性のvalue値のイベントハンドラーとして実行されるメソッドです。
これを実装したら、最後に実行してみると下図のようにデスクトップアプリが起動するはずです。

f:id:AdwaysEngineerBlog:20180713025933p:plain

今回は簡素化のため、整数の四則演算のみしか実装していません。
(小数点、剰余、符号反転のボタンは押せるけどなにも動作しない)
もし興味を持って頂けた方は僕のgithubからリポジトリをcloneして追加実装してみてください。

github.com