shun_mの日記

技術っぽいことを書いてみる

TreeViewでbindを使ってみる

このエントリーは JavaFX Advent Calendar 2014 の 14 日目です。
昨日は suzukij さんの JavaFXアプリケーションのSceneGraphをScenicViewで確認する - suzukijの日記 でした。
明日は @PG_nokkii さんです。

JavaFxNightで@skrbさんがバインドについて発表した

AdventCalendarの1日目 JavaFX Night でバインドの発表をしてきた がまさにそれです。 この時、@skrbさんが「再描画されないというのはbind使ってないせいの場合が多いです、TableView、TreeViewで使ってます」みたいなことを言ってました。スライドで言うと20枚目です。

TreeViewでモデルをbindというのがピンと来なかったのでやってみる

どう実装するのかその場ですぐに思い浮かばなかったのでやってみようというのがこのエントリです。 なるべくbindで処理するよう心がけました。

ネタかぶりました

AdventCalendarの10日目、toruwestさんの JavaFXのTreeViewでアニメーションしてみる と同じネタです。
替わりのネタがあるわけでもなく、ネタは一緒でも料理の方向性が違うということでご勘弁を。

環境

java version 1.7.0_60
java FX version 2.2.60
です、自分の周辺環境はまだJava8に移行できていません。

どんなのを作るの?

あなたは関東でいくつかのお店を経営している社長さんです。 来客数が30名を下回っている店が無いか監視したいと思っています。 このアプリは刻々と変わる来客数を監視し、30名を下回ったら赤く表示します。 また、エリア毎の客数を表示し、30名を下回った店舗が一つでもあるエリアは赤く表示します。

f:id:shun_m:20141215003024p:plain

まずはModelをPropertyを使って実装します

ステータスの集約がうまくいかなかったので赤く表示するかどうかalertというbooleanプロパティをModelに持っています。

その他にもちょっと変な事をやっています。
childrenが追加されたタイミングで、親の客数を再計算する処理をcustomersPropertyにセットしています。
bindでなんとかやろうとしたのですが、どう実装していいかわからず断念しました。
children.addListenerでセットしていますがコンストラクタでやる方が素直かもしれません。

また、alertPorpertyはreadonlyにしたかったのでgetter,setterを省略しSimpleBooleanPropertyの基底クラスであるReadOnlyBooleanPropertyを返していますがなんか違和感があります。これでいいのかな?

class TreeModel {
        private StringProperty name = new SimpleStringProperty();
        private IntegerProperty customers = new SimpleIntegerProperty();
        private ListProperty<TreeModel> children = new SimpleListProperty<>(
                FXCollections.observableArrayList(new ArrayList<TreeModel>()));
        private BooleanProperty alert = new SimpleBooleanProperty();

        {
            children.addListener(new ListChangeListener<TreeModel>() {
                @Override
                public void onChanged(javafx.collections.ListChangeListener.Change<? extends TreeModel> change) {
                    while (change.next()) {
                        for (final TreeModel treeModel : change.getAddedSubList()) {
                            treeModel.customersProperty().addListener(new InvalidationListener() {
                                @Override
                                public void invalidated(Observable observable) {
                                    if (treeModel.getChildren().isEmpty()) {
                                        treeModel.alert.set(treeModel.customersProperty().get() <= 30);
                                    }
                                    recalc();
                                }
                            });
                        }
                    }
                }
            });
        }
        private void recalc() {
            int sum = 0;
            boolean isAlert = false;
            for (TreeModel model : children) {
                sum += model.getCustomers();
                isAlert |= model.alert.get();
            }
            customersProperty().set(sum); //子のcustomer数を集約
            alert.set(isAlert); //Alert状態の集約
        }

        public TreeModel(String name, TreeModel... children) {
            this.name.set(name);
            this.children.addAll(children);
        }

        public void setName(String name) {
            this.name.set(name);
        }

        public String getName() {
            return name.get();
        }

        public List<TreeModel> getChildren() {
            return children.get();
        }

        public StringProperty nameProperty() {
            return name;
        }

        public ListProperty<TreeModel> childrenProperty() {
            return children;
        }

        public void setCustomers(int customers) {
            this.customers.set(customers);
        }

        public int getCustomers() {
            return customers.get();
        }

        public IntegerProperty customersProperty() {
            return customers;
        }

        // Alertはget() set() されたくないのでこうしたけどいいの?
        public ReadOnlyBooleanProperty alertProperty() {
            return alert;
        }
    }

TreeViewを作る

treeViewにCellFactoryをセットします。
この中のupdateItem()でtext,icon,colorをビューにbindしています。
textはconcatを使用して名称の後ろに客数を追加しています。
iconとcolorはalertプロパティを元にBindings.whenを使用してそれぞれbindしています。 Bindings.whenは使い勝手がいいです。
本当は子の状態を集約するカスタムプロパティというのを作ってbindするつもりだったのですが、どう実装していいかわからず結局あきらめまてモデル側で集約しました。

TreeView<TreeModel> treeView = new TreeView<>();
        final TreeModel model = createaModel();
        TreeItem<TreeModel> root = toTreeItem(model);
        treeView.setRoot(root);
        treeView.setCellFactory(new Callback<TreeView<TreeModel>, TreeCell<TreeModel>>() {
            @Override
            public TreeCell<TreeModel> call(TreeView<TreeModel> tview) {
                TreeCell<TreeModel> treeCell = new TreeCell<TreeModel>() {
                    @Override
                    protected void updateItem(final TreeModel model, boolean empty) {
                        super.updateItem(model, empty);
                        if (!empty) {
                            ImageView blueIcon = new ImageView(blue);
                            ImageView redIcon = new ImageView(red);
                            textProperty().bind(
                                    model.nameProperty().concat("[").concat(model.customersProperty()).concat("]"));
                            textFillProperty().bind(
                                    Bindings.when(model.alertProperty()).then(Color.RED).otherwise(Color.BLACK));
                            graphicProperty().bind(
                                    Bindings.when(model.alertProperty()).then(redIcon).otherwise(blueIcon));
                        }
                    }
                };
                return treeCell;
            }
        });

無事動きました

bindなしで作った時は、再描画されなかったり、動いてもしばらくしてMemoryLeakで止まったりで四苦八苦しました。
再描画に困ったらなるべくBind使いましょう。

できなかった事、できると思ってた事、気づいたこと。

  • Tree構造でchildrenの状態を集約するようなカスタムバインドの作り方がわかりませんでした。
  • MCVとバインドしていくとモデルへの操作もFXThreadでやる必要があります。MとVCの間で分けるとはいきません。
  • Tree要素の増減や移動もclass TreeData extends ObservableValueBase<TreeItem> なんてのをinputにbindすることで一応動いたのですが、モデルの変更のたびにTreeが畳まれてしまってうまい解決方法も浮かばないのであきらめました。

その他

Advent Calendar 大好きです。これを考えた人は頭いいなぁって常々思ってました。
JavaFxNightから帰ってきてJavaFxのAdventCalendarをみたら結構スカスカで、これなら自分が何か書いても許されるかなぁと思って思わず手を挙げてしまいました。(後でスカスカなのは今年のFxNightが昨年より開催が早かったからだと気付きました)
結局ネタはかぶるは、課題は残るはで消化不良のまま終わってしまいましたが勉強になりました。
皆様、良いお年をお過ごしくださいませ。

ソース全体です

HatenaBlogすら今回の為に作ったぐらいで、Gistなんてないのでベタ貼りですみません。

import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.scene.Scene;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import javafx.util.Callback;

public class App extends Application {
    public static void main(String[] args) {
        Application.launch(args);
    }

    @Override
    public void start(Stage stage) throws Exception {
        System.out.println("java version" + System.getProperty("java.version"));
        System.out.println("java FX version " + System.getProperty("javafx.version"));
        final Image blue, red;
        try (FileInputStream blueIs = new FileInputStream("bullet_blue.png");
                FileInputStream redIs = new FileInputStream("bullet_red.png")) {
            blue = new Image(blueIs);
            red = new Image(redIs);
        }
        TreeView<TreeModel> treeView = new TreeView<>();
        final TreeModel model = createaModel();
        TreeItem<TreeModel> root = toTreeItem(model);
        treeView.setRoot(root);
        treeView.setCellFactory(new Callback<TreeView<TreeModel>, TreeCell<TreeModel>>() {
            @Override
            public TreeCell<TreeModel> call(TreeView<TreeModel> tview) {
                TreeCell<TreeModel> treeCell = new TreeCell<TreeModel>() {
                    @Override
                    protected void updateItem(final TreeModel model, boolean empty) {
                        super.updateItem(model, empty);
                        if (!empty) {
                            ImageView blueIcon = new ImageView(blue);
                            ImageView redIcon = new ImageView(red);
                            textProperty().bind(
                                    model.nameProperty().concat("[").concat(model.customersProperty()).concat("]"));
                            textFillProperty().bind(
                                    Bindings.when(model.alertProperty()).then(Color.RED).otherwise(Color.BLACK));
                            graphicProperty().bind(
                                    Bindings.when(model.alertProperty()).then(redIcon).otherwise(blueIcon));
                        }
                    }
                };
                return treeCell;
            }
        });
        // ---
        Thread th = new Thread() {
            @Override
            public void run() {
                // FX threadじゃないと怒られます
                while (!isInterrupted()) {
                    try {
                        setCustomer(model);
                    } catch (InterruptedException e) {
                        return;
                    }
                }
            }

            private void setCustomer(final TreeModel model) throws InterruptedException {
                if (model.getChildren().isEmpty()) {
                    Thread.sleep(100);
                    Platform.runLater(new Runnable() {
                        @Override
                        public void run() {
                            model.customersProperty().set(new Random().nextInt(100));
                        }
                    });
                } else {
                    for (TreeModel sub : model.getChildren()) {
                        setCustomer(sub);
                    }
                }
            }
        };
        th.setDaemon(true);
        th.start();
        // --
        StackPane stackPane = new StackPane();
        stackPane.getChildren().add(treeView);
        Scene scene = new Scene(stackPane);
        stage.setScene(scene);
        stage.show();
    }

    private TreeModel createaModel() {
        TreeModel root = new TreeModel("関東");
        root.getChildren().add(new TreeModel("東京", new TreeModel("新宿"), new TreeModel("渋谷"), new TreeModel("上野")));
        root.getChildren().add(new TreeModel("千葉", new TreeModel("千葉"), new TreeModel("幕張")));
        root.getChildren().add(new TreeModel("神奈川", new TreeModel("横浜"), new TreeModel("川崎")));
        return root;
    }

    private TreeItem<TreeModel> toTreeItem(TreeModel root) {
        TreeItem<TreeModel> rtn = new TreeItem<TreeModel>(root);
        for (TreeModel sts : root.getChildren()) {
            TreeItem<TreeModel> treeItem = toTreeItem(sts);
            rtn.getChildren().add(treeItem);
        }
        return rtn;
    }

    class TreeModel {
        private StringProperty name = new SimpleStringProperty();
        private IntegerProperty customers = new SimpleIntegerProperty();
        private ListProperty<TreeModel> children = new SimpleListProperty<>(
                FXCollections.observableArrayList(new ArrayList<TreeModel>()));
        private BooleanProperty alert = new SimpleBooleanProperty();
        {
            children.addListener(new ListChangeListener<TreeModel>() {
                @Override
                public void onChanged(javafx.collections.ListChangeListener.Change<? extends TreeModel> change) {
                    while (change.next()) {
                        for (final TreeModel treeModel : change.getAddedSubList()) {
                            treeModel.customersProperty().addListener(new InvalidationListener() {
                                @Override
                                public void invalidated(Observable observable) {
                                    if (treeModel.getChildren().isEmpty()) {
                                        treeModel.alert.set(treeModel.customersProperty().get() <= 30);
                                    }
                                    recalc();
                                }
                            });
                        }
                    }
                }
            });
        }

        private void recalc() {
            int sum = 0;
            boolean isAlert = false;
            for (TreeModel model : children) {
                sum += model.getCustomers();
                isAlert |= model.alert.get();
            }
            customersProperty().set(sum); // 子のcustomer数を集約
            alert.set(isAlert); // Alert状態の集約
        }

        public TreeModel(String name, TreeModel... children) {
            this.name.set(name);
            this.children.addAll(children);
        }

        public void setName(String name) {
            this.name.set(name);
        }

        public String getName() {
            return name.get();
        }

        public List<TreeModel> getChildren() {
            return children.get();
        }

        public StringProperty nameProperty() {
            return name;
        }

        public ListProperty<TreeModel> childrenProperty() {
            return children;
        }

        public void setCustomers(int customers) {
            this.customers.set(customers);
        }

        public int getCustomers() {
            return customers.get();
        }

        public IntegerProperty customersProperty() {
            return customers;
        }

        // Alertはget() set() されたくないのでこうしたけどいいの?
        public ReadOnlyBooleanProperty alertProperty() {
            return alert;
        }
    }
}