Skinnable Control in JavaFX 1.2

今回は JavaFX 1.2 で微妙に変更された Control をテーマにしてみました。

JavaFX 1.2 では 密かに javafx.scene.control.Behavior というクラスが新しく追加されています。
しかし、一体何のため?
考えるより、まずはサンプルを作ってみました。

今回、作成したのは お馴染みの 『進捗状況とは関係なく、ひたすらくるくる回るだけの ProgressIndicator』 です。
標準 API にも同じものが用意されていますが...

  • 見た目があまり好みでない
  • 使い勝手があまり良くない
という理由で、今回 新しく作ってみました。
微妙に前回までの非同期処理から抜け出せていないのは気のせい...?


で、完成したものはこんな感じ...


 ← 実際に動くもの...

今回、試してみて感じたこと...
かなりコードがスッキリ書けるようになったというのが第一印象です。
新たに Behavior が追加されたことで、曖昧だった Control, Skin の役割が

  • Control は 外部とのインターフェース。
  • Skin は 出力する Node の生成。
  • Behavior は Skin の操作。
のように明確になった。
Behavior がなかったころは、Skin の操作を Control に実装するべきか、Skin に実装するべきか朝まで悩んだものです。
これでまた一つ悩みごとが減り、ぐっすり眠れそうです...

また、役割が明確化され、実装が分離されれば、再利用性、カスタマイズ性の向上が期待できます。
見た目だけ変えたいのであれば、Skin だけ変更すればよいし、振る舞いだけ変えたければ、Behavior だけ変更すればよい...

と言うものの、残念ながら、JavaFX 1.2 では 各 Control の Skin, Behavior の実装は 標準 API として公開されていません。
どうやら、com.sun.javafx.scene.control.caspian パッケージに 各 Control の Skin が実装されているみたい。
ちなみに、標準 Skin の実装のことを Caspian と呼ぶらしい... (JavaFX - Skinnable Controls 参照)
なぜ、Caspian を標準 API として公開しないのだろうか? そうすればもっと楽に見た目を変更できるだろうに...

もう1つ残念なのは、Skin の behavior 属性が public-read protected であることです。
これでは、Skin の外部から Behavior を変更することができません。
なぜ、public-read protected なのだろうか? せめて public-init か public-read package にしてほしかったなぁ...
Control の skin 属性は public なのに、何か特別な理由でもあるのだろうか?

[Control] CircleProgressIndicator.fx

import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.scene.paint.Color;

public class CircleProgressIndicator extends Control {
    
    public-init var radius   = 10.0;
    public-init var interval = 1.0s;
    public-init var delay    = 0.5s;
    
    public var foreground = Color.web("#DDDDDD");
    public var background = Color.web("#000000");

    override var width   = bind 2 * radius;
    override var height  = bind 2 * radius;

    init {
        visible = false;
        opacity = 0.0;
    }

    protected override function createDefaultSkin(): Skin {
        return CircleProgressIndicatorSkin {};
    }

    public function start(): Void {
        (skin.behavior as CircleProgressIndicatorBehavior).start();
    }

    public function stop(): Void {
        (skin.behavior as CircleProgressIndicatorBehavior).stop();
    }
}

[Skin] CircleProgressIndicatorSkin.fx

import javafx.scene.Group;
import javafx.scene.control.Skin;
import javafx.scene.effect.GaussianBlur;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.Rotate;
import javafx.util.Math;

package def BLADE_NUM = 12;

public class CircleProgressIndicatorSkin extends Skin {

    var progressBehavior = bind behavior as CircleProgressIndicatorBehavior;
    var progressControl  = bind control  as CircleProgressIndicator;

    var width  = bind control.width;
    var height = bind control.height;

    var radius    = bind progressControl.radius;
    var barWidth  = bind radius / 5;
    var barHeight = bind radius / 2;
    var degree    = 360.0 / BLADE_NUM;

    var backRect = Rectangle {
        width:  bind width
        height: bind height
        fill:   bind progressControl.background
        opacity: 0.8
    };

    package var indicator = for (i in [1..BLADE_NUM]) Rectangle {

        translateX: bind (width  - barWidth) / 2
        translateY: bind height / 2 - radius;

        width:     bind barWidth
        height:    bind barHeight
        arcWidth:  bind Math.max(barWidth - 0.5, 0)
        arcHeight: bind Math.max(barWidth - 0.5, 0)

        transforms: Rotate {
            pivotX: bind barWidth / 2,
            pivotY: bind radius,
            angle:  bind (i - 1) * degree
        };

        fill: bind progressControl.foreground,
        effect: GaussianBlur { radius: 1.5 }
    };

    init {
        behavior = CircleProgressIndicatorBehavior {};
        node = Group { content: [ backRect, indicator ] };
    }

    override function intersects(localX: Number, localY: Number,
                                 localWidth: Number, localHeight: Number) {
        return node.intersects(localX, localY, localWidth, localHeight);
    }

    override function contains(localX: Number, localY: Number) {
        return node.contains(localX, localY);
    }
}

[Behavior] CircleProgressIndicatorBehavior.fx

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.animation.transition.FadeTransition;
import javafx.scene.control.Behavior;
import javafx.util.Math;

public class CircleProgressIndicatorBehavior extends Behavior {

    var progressSkin    = bind skin as CircleProgressIndicatorSkin;
    var progressControl = bind skin.control as CircleProgressIndicator;

    var location: Number on replace {
        for (i in [0..CircleProgressIndicatorSkin.BLADE_NUM - 1]) {
            var k = Math.abs(i - location);
            progressSkin.indicator[i].opacity =
                                if (k <=  1) 1.00
                           else if (k <=  2) 0.80
                           else if (k <=  3) 0.60
                           else if (k <=  4) 0.40
                           else if (k <=  5) 0.20
                           else if (k <=  6) 0.20
                           else if (k <=  7) 0.20
                           else if (k <=  8) 0.20
                           else if (k <=  9) 0.20
                           else if (k <= 10) 0.40
                           else if (k <= 11) 0.60
                           else if (k <= 12) 0.80
                           else 0.0;
        }
    }

    var timeline = Timeline {
        repeatCount: Timeline.INDEFINITE
        keyFrames: [
            KeyFrame {
                time: 0s
                values: [ location => 0.0 ]
            },
            KeyFrame {
                time: bind progressControl.interval
                values: [ location => CircleProgressIndicatorSkin.BLADE_NUM ]
            }
        ]
    }

    var fadeIn = FadeTransition {
        node: bind progressControl
        duration: bind progressControl.delay
        toValue: 1.0
    }

    var fadeOut = FadeTransition {
        node: bind progressControl
        duration: bind progressControl.delay
        toValue: 0.0
        action: function() {
            progressControl.toBack();
            progressControl.visible = false;
            timeline.stop();
        }
    }

    public function start(): Void {
        timeline.playFromStart();
        progressControl.visible = true;
        progressControl.toFront();
        fadeIn.playFromStart();
    }

    public function stop(): Void {
        fadeOut.playFromStart();
    }
}

Main.fx

import javafx.scene.Scene;
import javafx.scene.control.Hyperlink;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.Stage;

var indicator: CircleProgressIndicator = CircleProgressIndicator {
    radius: 20
    width:  bind indicator.scene.width
    height: bind indicator.scene.height
}

var text: Text = Text {
    content: "Hello World!!"
    x: 10, y: 40
    font: Font { size: 28 }
}

var link = Hyperlink {
    text: "もう一度"
    font: Font {size: 12 }
    action: function() {
        InitializeTask {}.start();
    }
}

var content = VBox {
    translateX: 5, translateY: 5, spacing: 5
    visible: false;
    content: [ text, link ]
}


Stage {
    title  : "JavaFX - Progress Indicator"
    scene: Scene {
        width: 320, height: 240
        content: [ indicator, content ]
    }
}

InitializeTask {}.start();

class InitializeTask extends ActionTask {
    init {
        action = function () {
            // 時間のかかる初期化処理...
            java.lang.Thread.sleep(5000);
        }
        onStart = function() {
            content.visible = false;
            indicator.start();
        }
        onDone = function() {
            content.visible = true;
            indicator.stop();
        }
    }
}