2019-05-29  Java プログラミング

マウスホバーでゆっくり色が変わるボタンを作る

JavaFX では CSS を使って UI の外観や振る舞いを装飾することができます。CSS セレクタに hover 擬似クラスを指定することもできるので マウスカーソルを重ねたときに外観を変えるのも簡単です。

ですが JavaFX CSS3 transition プロパティをサポートしていないため 時間経過で次第にボタンの色を変えていくといったことは CSS だけでは 実現できません。

マウスホバー時にゆっくりと色が変わるボタンを JavaFX で作ってみます。

CSSだけを使った場合

JavaFX CSS hover 擬似クラスだけを使った場合は マウスを重ねると一瞬で色が変わってしまいます。

このサンプル プログラムは以下の構成になっています。

  • src/main/java
    • com/example
      • Main.java
      • Main.css
      • Main.fxml
Main.java
package com.example; import javafx.application.Application; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.stage.Stage; public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) throws Exception { FXMLLoader loader = new FXMLLoader(getClass().getResource("Main.fxml")); loader.setController(this); Parent root = (Parent)loader.load(); Scene scene = new Scene(root); primaryStage.setScene(scene); primaryStage.show(); } }
Main.fxml
<?xml version="1.0" encoding="UTF-8"?> <?import javafx.scene.*?> <?import javafx.scene.control.*?> <?import javafx.scene.layout.*?> <StackPane xmlns:fx="http://javafx.com/fxml/1" stylesheets="@Main.css" prefWidth="300" prefHeight="200"> <Button text="Click Me!"/> </StackPane>
Main.css
.button { -fx-background-color: lightgrey; -fx-font-weight: bold; -fx-text-fill: black; } .button:hover { -fx-text-fill: red; }

hover 擬似クラスで文字色 -fx-text-fill プロパティ に赤 red を指定しているだけの単純なプログラムです。このプログラムに 時間経過でゆっくりと色が変わっていく機能を追加していきます。なお 背景色を変えるとコードが少し長くなってしまいますので まずは文字色のみを変化させていきます。

TransitionとTimelineの違い

JavaFX javafx.animation.Animation クラスはアニメーションのコア機能を提供しています。サブクラスに javafx.animation.Transition javafx.animation.Timeline があります。

Transition

一般的なアニメーションを提供するいくつかのサブクラスが Transition クラスから派生しています。フェードイン フェードアウトのアニメーションを提供する FadeTransition クラス 回転アニメーションを提供する RotateTransition クラスなどです。目的に合致するアニメーションを Transition のサブクラスの中からが見つけられれば それを使うのが簡単です。

Timeline

一般的なアニメーションを定義している Transition とは異なり Timeline は任意のアニメーションを定義できます。Timeline は任意のプロパティの値を時間経過で変化させていくことができます。たとえばノードの不透明度を表す opacityProperty 0.0 から 1.0 に変化させれば ゆっくりと浮かび上がってくるフェードイン効果になります。

初期値 1.0 DoublePropery 200 ミリ秒かけて 8.0 まで変化させる場合のコードは以下のようになります。

DoubleProperty value = new SimpleDoubleProperty(1.0);

new Timeline(
	new KeyFrame(Duration.millis(200),
		new KeyValue(value, 8.0)
	)
).play();

Timeline で指定しているのは 200 ミリ秒後に 値を 8.0 にする ということだけです。あとは JavaFX が自動的に中間値を補間してくれます。

DoubleProperyが 1.0 から 8.0 に変化していく様子
1.35 2.83 3.39 4.05 4.51 5.07 5.43 5.63 6.19 6.75 7.31 7.87 8.00

Timeline で変化させられるのは DoubleProperty のような数値プロパティだけではありません。ObjectPropety<Color> のような数値以外のプロパティを変化させることもできます。つまり Color.BLUE から Color.RED に変化するように指定すれば 途中の色を JavaFX が補完してなめらかに変化していくアニメーションにしてくれるわけです。

今回は Transition クラスではなく Timeline クラスを使って 文字色のプロパティを変化させていくことでアニメーションを実現してみます。

スキンを作る

マウスホバーでゆっくりとボタンの色を変える 機能をどこに実装しましょうか? javafx.scene.control.Button クラスを継承した独自のボタンクラスを作ることもできますが 外観や振る舞いを少し変えるために独自のボタンクラスを新たに作るというのはあまり良い方法ではありません。

JavaFX にはスキンという仕組みが用意されており ボタンなどの UI コントロールに別のスキンを適用することで外観や振る舞いを変えられるようになっています。適用するスキンは CSS -fx-skin プロパティで指定できるので 既存の Java コードを変更せずに CSS の変更のみで外観を変えることできます。

この 2 つの手順で進めていきます。

TransitionButtonSkin

作成するスキンのクラス名は TransitionButtonSkin としました。ソースコード全体は以下の通りです。

TransitionButtonSkin.java
package com.example; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.control.Button; import javafx.scene.control.skin.ButtonSkin; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.util.Duration; public class TransitionButtonSkin extends ButtonSkin { private Duration duration = Duration.millis(1000); private ObjectProperty<Color> textColorProperty = new SimpleObjectProperty<Color>(Color.TRANSPARENT); private Color textEndColor = getTextColor(); public TransitionButtonSkin(Button control) { super(control); registerChangeListener(control.focusedProperty(), o -> updateTextColor()); registerChangeListener(control.hoverProperty(), o -> updateTextColor()); registerChangeListener(control.pressedProperty(), o -> updateTextColor()); } protected Color getTextColor() { Paint paint = getSkinnable().getTextFill(); if(paint instanceof Color) { return (Color)paint; } return Color.TRANSPARENT; } protected void updateTextColor() { Button control = getSkinnable(); control.textFillProperty().unbind(); control.applyCss(); textColorProperty.setValue(textEndColor); textEndColor = getTextColor(); control.textFillProperty().bind(textColorProperty); new Timeline( new KeyFrame(duration, new KeyValue(textColorProperty, textEndColor) ) ).play(); } }

ButtonSkin クラスを継承して TransitionButtonSkin を作ります。ButtonSkin クラスは Skin インターフェースを実装しています

スキンは 適用対象となるコントロールを引数として受け取るコンストラクタを実装する必要があります。このコンストラクタは JavaFX によって自動的に呼び出されます。

適用対象のコントロールを受け取るコンストラクタ
public TransitionButtonSkin(Button control)

コントロールに適用されているスタイルを参照することはできない?

目的となるスキンを実装するためには ホバーしたときに何色に変えるべきなのかを知る必要があります。

CSS
.button { -fx-text-fill: black; } .button:hover { -fx-text-fill: red; }

このような CSS が適用されているとき マウスをホバーすると文字色が何色になるのかを知りたいわけです。

JavaFX のコントロール javafx.scene.control.Control には getCssMetaData メソッドがあるのですが このメソッドでは状態 擬似クラス ごとに適用される値を参照することはできないようです。残念です。

getTextFill メソッドは現在の状態を反映した文字色を返してくれます。マウスをホバーしていない状態でこのメソッドを呼び出しても ホバーしたときの色を知ることはできません。ホバーしている状態でこのメソッドを呼び出せば ホバー状態の色 red を返してくれます。

つまり ホバー状態に変わったときに getTextFill メソッドを呼び出せば ホバー状態で使うべき文字色を知ることができるということです。

ホバー状態 hoverProperty の変化を監視するリスナーを登録しましょう。

registerChangeListener(control.hoverProperty(), o -> updateTextColor());

これで ホバー状態が変化するたびに updateTextColor メソッドが呼び出されます。

applyCssメソッドが必要な理由

updateTextColor メソッドが呼ばれたときに getTextFill メソッドを呼び出しても何故か直前の文字色が返されてしまいます。ホバー状態になったときにホバー直前の文字色が返され ホバー解除時にホバー状態の文字色が返されます

どうやら hoverProperty が変化した瞬間はまだ文字色が更新されておらず 次のサイクル JavaFX パルスイベント にならないと文字色が更新されないようです。Platform.runLater で少し遅らせて 次のパルスに進ませて から処理することも考えたのですが スマートではありません。

何か良い方法はないものかと探してみたところ applyCss メソッドを明示的に呼び出すと 次のパルスを待たずに 現在の状態に応じたスタイルが適用され 正しく文字色を参照できることが分かりました。

hoverPropery の変化を監視するリスナー内で 最新の状態を参照するためには applyCss メソッドを呼び出す必要があるわけですね。

最新の文字色が参照できない
protected void updateTextColor() { Button control = getSkinnable(); Paint paint = control.getTextFill(); // NG }
applyCssメソッドを呼び出すと最新の文字色が参照できる
protected void updateTextColor() { Button control = getSkinnable(); control.applyCss(); Paint paint = control.getTextFill(); // OK }

unbindメソッドが必要な理由

textFillProperty に値をバインドしていると getTextFill メソッドはバインドしている値を返します。当たり前ですね。

バインドしてると getTextFill メソッドを呼び出しても CSS で適用される値を参照することはできなくなります。CSS で適用される値を参照するためには 一時的にバインディングを解除する必要があります。

バインド中はCSS指定の文字色が参照できない
protected void updateTextColor() { Button control = getSkinnable(); Paint paint = control.getTextFill(); // NG }
バインドを解除すればCSS指定の文字色が参照できる
protected void updateTextColor() { Button control = getSkinnable(); control.textFillProperty().unbind(); Paint paint = control.getTextFill(); // OK }

仕上げ

ホバー状態が変化したときに CSS の文字色設定を正しく参照するためには applyCss unbind が不可欠であるということが分かりました。

適切な文字色を参照した後は 再び textColorProperty をバインドします。そして Timeline textColorProperty の値を変化させていくと 文字色がゆっくりと変化していきます。

control.textFillProperty().bind(textColorProperty);
new Timeline(
	new KeyFrame(duration,
		new KeyValue(textColorProperty, textEndColor)
	)
).play();

サンプルプログラムでは変化が分かりやすいように 1,000 ミリ秒のとてもスローなアニメーションになっています。実際は 50~100 ミリ秒ほどのさりげないアニメーションにするのが良いと思います。

private Duration duration = Duration.millis(1000);

独自のスキンを適用する

ひとまず 文字色を変化させる独自のスキン TransitionButtonSkin ができました。

このスキンをボタンに適用してみましょう。

適用方法は簡単です。CSS -fx-skin プロパティで スキンのパッケージ名を含む完全修飾クラス名を指定するだけです。

-fx-skin: "com.example.TransitionButtonSkin";

スキンの指定を追加した Main.css は以下のようになります。

Main.css
.button { -fx-skin: "com.example.TransitionButtonSkin"; -fx-background-color: lightgrey; -fx-font-weight: bold; -fx-text-fill: black; } .button:hover { -fx-text-fill: red; }
CSSではなくJavaプログラムコードでスキンを指定する方法
Button btn = ・・・;
btn.setSkin(new TransitionButtonSkin(btn));

TransitionButtonSkin が追加されたサンプルプログラムの構成です。

  • src/main/java
    • com/example
      • Main.java
      • Main.css
      • Main.fxml
      • TransitionButtonSkin.java

プログラムを実行して マウスにカーソルを重ねるとゆっくりと文字が赤色に変わっていきます。カーソルを離すと文字が黒色に戻っていきます。

背景色も変わるスキン完成版

文字色だけでなく背景色も変わるバージョンの TransitionButtonSkin.java を以下に示します。

TransitionButtonSkin.java
package com.example; import java.util.List; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.beans.binding.ObjectBinding; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.geometry.Insets; import javafx.scene.control.Button; import javafx.scene.control.skin.ButtonSkin; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.util.Duration; public class TransitionButtonSkin extends ButtonSkin { private Duration duration = Duration.millis(1000); private ObjectProperty textColorProperty = new SimpleObjectProperty(Color.TRANSPARENT); private ObjectProperty backgroundColorProperty = new SimpleObjectProperty(Color.TRANSPARENT); private DoubleProperty opacityProperty = new SimpleDoubleProperty(0.0); private OpaqueBackgroundBinding backgroundBinding = new OpaqueBackgroundBinding(backgroundColorProperty, opacityProperty); private Color textEndColor = getTextColor(); private Color backgroundEndColor = (Color)getBackgroundFill().getFill(); public TransitionButtonSkin(Button control) { super(control); registerChangeListener(control.focusedProperty(), o -> updateBackground()); registerChangeListener(control.hoverProperty(), o -> updateBackground()); registerChangeListener(control.pressedProperty(), o -> updateBackground()); } protected Color getTextColor() { Paint paint = getSkinnable().getTextFill(); if(paint instanceof Color) { return (Color)paint; } return Color.TRANSPARENT; } protected BackgroundFill getBackgroundFill() { Background background = getSkinnable().getBackground(); if(background != null) { List fills = background.getFills(); if(fills.size() > 0) { Paint paint = fills.get(0).getFill(); if(paint instanceof Color) { return fills.get(0); } } } return new BackgroundFill(null, null, null); } protected void updateBackground() { Button control = getSkinnable(); control.textFillProperty().unbind(); control.backgroundProperty().unbind(); control.applyCss(); textColorProperty.setValue(textEndColor); backgroundColorProperty.setValue(backgroundEndColor); opacityProperty.setValue(backgroundEndColor.getOpacity() == 0.0 ? 0.0 : 1.0); textEndColor = getTextColor(); control.textFillProperty().bind(textColorProperty); BackgroundFill fill = getBackgroundFill(); backgroundEndColor = (Color)fill.getFill(); backgroundBinding.radii = fill.getRadii(); backgroundBinding.insets = fill.getInsets(); control.backgroundProperty().bind(backgroundBinding); if(backgroundColorProperty.getValue().getOpacity() > 0.0 && backgroundEndColor.getOpacity() > 0.0) { new Timeline( new KeyFrame(duration, new KeyValue(textColorProperty, textEndColor), new KeyValue(backgroundColorProperty, backgroundEndColor) ) ).play(); } else if(backgroundColorProperty.getValue().getOpacity() > 0.0) { new Timeline( new KeyFrame(duration, new KeyValue(textColorProperty, textEndColor), new KeyValue(opacityProperty, 0.0) ) ).play(); } else if(backgroundEndColor.getOpacity() > 0.0) { new Timeline( new KeyFrame(Duration.ONE, new KeyValue(backgroundColorProperty, backgroundEndColor) ), new KeyFrame(duration, new KeyValue(textColorProperty, textEndColor), new KeyValue(opacityProperty, 1.0) ) ).play(); } else { new Timeline( new KeyFrame(duration, new KeyValue(textColorProperty, textEndColor) ) ).play(); } } private class OpaqueBackgroundBinding extends ObjectBinding {
private ObjectProperty colorProperty; private DoubleProperty opacityProperty; private CornerRadii radii; private Insets insets; public OpaqueBackgroundBinding(ObjectProperty colorProperty, DoubleProperty opacityProperty) { this.colorProperty = colorProperty; this.opacityProperty = opacityProperty; bind(colorProperty, opacityProperty); } @Override protected Background computeValue() { Color c = colorProperty.getValue(); Color color = new Color( c.getRed(), c.getGreen(), c.getBlue(), c.getOpacity() * opacityProperty.getValue()); return new Background(new BackgroundFill(color, radii, insets)); } } }

背景 Background は色だけでなくコーナーの丸み CornerRadii と余白 Insets を持っているので 文字色を変えるときよりも処理が複雑になっています。ですが 基本的な考え方は文字色のときと同じです。

サンプル・プログラム

完成したサンプル プログラムのソースコードは下記のリンクからダウンロードできます。

サンプル プログラムを実行している様子です。

マウスホバーで色を変えるカスタム スキンの作成は以上です。

最終更新日 2024-12-13
この記事を共有しませんか?
ブックマーク ポスト