JavaFX SlideViewer¶
執筆中 |
JavaFXで、スライドビューアーを作成しました。スライド1枚を1つのFXMLで定義し、複数のFXMLを順次表示していくことでスライドビューアーを実現したものです。
スライドビューアーをJavaFXで作成した発端は、Java Championの櫻庭さんが講演で使用するプレゼンテーションのスライドはJavaFXで作っているのを見聞きして、いつか自分も!と思ったところです。といってもなかなか実現できず、やっと発表ネタ JJUG CCC 2017 Spring向けで最初のJavaFXスライドショーを実現しました。
このページは、JavaFXで作ったスライドショープログラムの作成メモです。ソースコードはGithubの次のURLに上げています。
https://github.com/torutk/javaslideviewer
実現方式¶
どんな方法があるかの悩み¶
スライドショーをJavaFXで実現する方法をいろいろ考えました。最初はJavaのコードで各スライドのノードツリーを作成し、ページめくりでツリーを入れ替えるという方法を思いつきますが、これではコーディング量が膨大になってしまいます。JavaFXでスライドショーを作っている事例も探してみました。中にはコンテンツをマークダウン等独自のマークアップ記述で記載し、それを実行時に解析してスライド化するものもありました。しかし、発表のコンテンツを作る傍らでそこまでの作り込みまではできそうにありませんでした。
そこで、スライドのコンテンツはFXMLで記述することとし、スライドビューアーはFXMLファイルを読み込みイテレートして画面遷移させることに限定した機能としました。
FXMLの記述は、Scene Builderを使ってビジュアルに編集できれば楽です。
参考にした先人のJavaFXのスライドショー¶
- 櫻庭さんのJavaFXでプレゼンツール
- toastkidjpさんのJavaFXでスライドショーを作る
JJUG CCC 2016 Fallで登壇時にJavaFXのスライドショーを使っていたとのことです。
スライドの構成とFXML¶
通常、スライドにはページごとのコンテンツ領域と全体で共通するヘッダーやフッターなどの領域があります。
FXMLで個々のページのスライドを作成する際に、共通するヘッダーやフッターをいちいち配置していくのは手間ですし、変更が一元化できません。そこで、次のようにFXMLを多段で構成しました。
まず、外側の画面をFXMLで作成し、スライドビューアーを起動するとこの画面が表示されるようにします。
外側の画面では、各スライドの領域をPane
で設定しておきます。共通する領域はここで作成しておきます。上の図ではフッターの領域を設けています。
次に、スライド毎のFXMLをページめくりのたびにロードして、外側の画面のPane
に貼ります。
このような構成にすると、ページめくり等の操作は外側の画面とコントローラークラスで実装し、各コンテンツの画面はそれぞれのFXMLで定義することができます。
Javaのコードイメージ¶
外側のFXMLと中側のFXMLの読み込みと設定のコードイメージを記述します。
public class JavaSlideViewerApp extends Application {
:
public void start(Stage stage) throws Exception {
Parent root = FXMLLoader.load(getClass().getResource("JavaSlideMainView.fxml"));
Scene scene = new Scene(root);
stage.setScene(scene);
stage.show();
}
}
JavaFXアプリケーションクラスでは、外側のFXMLファイル(JavaSlideMainView.fxml)を読んで画面を生成します。
次に、外側のFXMLファイル(JavaSlideMainView.fxml)と対になるコントローラークラス(JavaSlideMainViewContoroller.java)では、中側のFXMLファイルを読んで中側の表示領域を生成します。
public class JavaSlideMainViewController implements Initializable {
@FXML
private Pane contentPane;
private void setContent() {
Pane pane = FXMLLoader.load(Paths.get(fileName).toUri().toURL())
contentPane.getChildren().clear();
contentPane.getChildren().add(pane);
}
}
中側のFXMLをFXMLLoaderで読み込み、外側の画面のPane
であるcontentPaneに貼っています(前のスライドはclear()で外しています)。
画面の大きさとコンテンツ(文字)の大きさ¶
実際にプレゼンテーションを実施するときは、プレゼンテーションツールは通常フルスクリーンモードで表示します。
ここで、フルスクリーンの大きさ(画素数)は実行するPCやプロジェクターによって異なります。つまり、画面のレイアウト上、各部品や文字の大きさは環境によって異なってしまいます。
JJUG CCC 2017 Spring でのプレゼンテーションツールでは、使用するノートPCのフルスクリーンサイズに依存して部品の大きさや文字の大きさを設定していました。そのため、違う環境へもっていったときや、画面レイアウトを編集しているときのウィンドウサイズでは大きさが崩れてしまいます。
そこで、改良版として、画面の部品や文字の大きさはある画面サイズ固定(例えば800×600)で作り込んでおき、画面の大きさが変わったときは座標変換のスケーリングでコンテンツの大きさを変えることとしました。
ウィンドウ(画面)の大きさに合わせてスケーリングするコード¶
public class JavaSlideViewerApp extends Application {
private double initialSceneWidth; // 初期起動時のシーンの大きさ(幅)
private double initialSceneHeight; // 初期起動時のシーンの大きさ(高さ)
private final ObjectProperty<Scale> scaleProperty = new SimpleObjectProperty<>(new Scale(1d, 1d, 0d, 0d));
public void start(Stage stage) throws Exception {
Parent root = FXMLLoader.load(getClass().getResource("JavaSlideMainView.fxml"));
root.getTransforms().add(scaleProperty.get());
Scene scene = new Scene(root);
stage.setScene(scene);
stage.show();
// 起動時のシーンの大きさを保存(stage.show()の後でないと値が確定しない)
initialSceneWidth = scene.getWidth();
initialSceneHeight = scene.getHeight();
// シーンの大きさが変更された際、起動時のシーンの大きさに対する比をルートノードのスケールに反映
scaleProperty.get().xProperty().bind(Bindings.divide(scene.widthProperty(), initialSceneWidth));
scaleProperty.get().yProperty().bind(Bindings.divide(scene.heightProperty(), initialSceneHeight));
}
}
- ウィンドウ(シーン)の大きさを起動時に覚えておきます。
- シーンの拡大縮小は、座標変換の一つ
Scale
で行います。
拡大縮小は画面の中心ではなく、左上隅を基準点(pivot
)とするので、scaleX
やscaleY
メソッドではなく、Scale
を使いました。 - シーンの大きさが変更されたら、シーンの拡大縮小を変更するようプロパティとバインドを使って連動させています。
スライドを保持するモデル¶
モデルは、FXMLファイルが置かれているフォルダー(ディレクトリ)へのパスを受け取りインスタンス化します。
そして、nextメソッド・previousメソッドで次のスライドまたは前のスライドをPaneとして返却します。
なおスライドの先頭でpreviousメソッドを呼んだ時、スライドの最終でnextメソッドを呼んだ時は、nullを返すのを避けてOptional型(Optional<Pane>)を戻り値型としています。
モデルクラス(JavaSlideViewModel)の骨格は次のとおりです。
public class JavaSlideViewModel {
public JavaSlideViewModel(Path folder) {
}
public Optional<Pane> next() {
}
public Optional<Pane> previous() {
}
}
インスタンス化すると、フォルダーにあるFXMLファイルを調べてFXMLファイル名一覧をリストで保持します。
try (Stream<Path> stream = Files.list(folder)) {
slides = stream.map(path -> path.toFile().getName())
.filter(name -> name.toLowerCase().endsWith(".fxml"))
.sorted()
.collect(Collectors.toList());
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
FXMLファイル名をリストで保持し、スライドの表示のタイミングでFXMLLoaderでロードします。
都度FXMLファイルから読み込むことで、スライドを表示させている途中でFXMLの修正をすることができるようになります。
ファイルI/Oでは検査例外が発生し得るのですが、ここではエラー処理を簡略化(ファイルI/Oでエラーがあった場合、非検査例外 UncheckedIOExceptionで包んでスロー)しています。
ページめくりは次のコードとなります。
public Optional<Pane> next() {
if (slides.size() <= currentIndex + 1) {
return Optional.empty();
}
return loadFxml(slides.get(++currentIndex));
}
public Optional<Pane> previous() {
if (currentIndex <= 0) {
return Optional.empty();
}
return loadFxml(slides.get(--currentIndex));
}
FXMLファイルの読み込みは次のようにしています。
private Optional<Pane> loadFxml(String fileName) {
try {
return Optional.of(FXMLLoader.load(Paths.get(fileName).toUri().toURL()));
} catch (IOException ex) {
logger.log(Level.SEVERE, null, ex);
}
return Optional.empty();
}
ページめくり操作¶
ページめくりは、画面上(右下)にある、[次へ][前へ]ボタンを操作する他、キーボードでもめくることができるようにしています。
ボタン操作でページめくり¶
ボタンのアクションをメソッドで結びつけます。
@FXML
private void nextAction(ActionEvent event) {
model.next().ifPresent(element -> changeContent(element, HPos.LEFT));
}
@FXML
private void previousAction(ActionEvent event) {
model.previous().ifPresent(element -> changeContent(element, HPos.RIGHT));
}
キー操作でぺーじめくり¶
private final List<KeyCode> previousKeys = Arrays.asList(KeyCode.LEFT, KeyCode.UP, KeyCode.PAGE_UP, KeyCode.BACK_SPACE);
private final List<KeyCode> nextKeys = Arrays.asList(KeyCode.RIGHT, KeyCode.DOWN, KeyCode.PAGE_DOWN, KeyCode.SPACE);
public void initialize(URL url, ResourceBundle rb) {
rootPane.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
if (previousKeys.contains(event.getCode())) {
previousAction(new ActionEvent());
event.consume();
} else if (nextKeys.contains(event.getCode())) {
nextAction(new ActionEvent());
event.consume();
}
});
:
ページを進める・戻すキーを複数リストで保持しておきます。
キー操作のイベントは、画面上の個々のコントロール部品(ラベルなど)に渡される前に取得したいので、背景となるrootPaneにaddEventFilterでイベントハンドラーを登録します。
addEventFilterでキーイベントを拾った後、rootPane上の個々の部品にキーイベントが配信されないようにするには、event.consume()を呼んでおきます。