ページ

2013年5月14日火曜日

JavaFX - Custom Editable ListCell (2)

前回は、ListCellを継承したクラスの作成方法についてみていきました。今回は、実際に以下のようなものを作成してみたいと思います。

まずは、リストに表示するUserModelを以下のように定義します。

interface UserModel extends Cloneable {
    StringProperty nameProperty();
    StringProperty emailProperty();
    ObjectProperty<GenderModel> genderProperty();

    UserModel clone();
    void merge(UserModel user);
}

Userの名前とメールアドレスと性別を表すプロパティを定義しています。cloneメソッドは、編集モードになったときに現在の表示内容をコピーして、編集用のビューにその内容を渡すときに利用し、mergeメソッドは、編集モードでコミットされた内容を現在の表示内容に適用するときに利用します。性別を表すGenderModelについては、以下のようになります。

enum GenderModel {
    MALE {
        @Override
        protected String getImageName() {
            return "Person_Male.png";
        }
    },
    FEMALE {
        @Override
        protected String getImageName() {
            return "Person_Female.png";
        }
    };

    public ReadOnlyObjectProperty<Image> imageProperty() {
        if (image == null) {
            image = new ReadOnlyObjectWrapper<>(this, "image", new Image(getClass().getResourceAsStream(getImageName())));
        }
        return image.getReadOnlyProperty();
    }
    private ReadOnlyObjectWrapper<Image> image;
    public final Image getImage() { return imageProperty().get(); }
    protected final void setImage(Image image) { this.image.set(image); }

    protected abstract String getImageName();
}

性別を表示するときの画像のプロパティを定義しています。

次に、UserModelを表示するViewをコントローラを以下のように定義します。

<fx:root xmlns:fx="http://javafx.com/fxml"
         type="javafx.scene.layout.GridPane">
    <ImageView fx:id="genderImageView"
               GridPane.rowIndex="0" GridPane.columnIndex="0"
               GridPane.rowSpan="2"
               fitWidth="80" fitHeight="80"/>

    <Label fx:id="nameLabel"
           GridPane.rowIndex="0" GridPane.columnIndex="1"/>

    <Label fx:id="emailLabel"
           GridPane.rowIndex="1" GridPane.columnIndex="1"/>
</fx:root>
class UserView extends GridPane {
    public ObjectProperty<UserModel> modelProperty() { return model; }
    private final ObjectProperty<UserModel> model = new SimpleObjectProperty<UserModel>(this, "model") {
        private UserModel currentModel;

        @Override
        protected void invalidated() {
            if (currentModel != null) { UserView.this.unbind(currentModel); }
            currentModel = get();
            if (currentModel != null) { UserView.this.bind(currentModel); }
        }
    };
    public final UserModel getModel() { return model.get(); }
    public final void setModel(UserModel model) { this.model.set(model); }

    @FXML
    private ImageView genderImageView;
    @FXML
    private Label nameLabel;
    @FXML
    private Label emailLabel;

    public UserView() {
        initializeComponent();
        getStyleClass().add("user-view");
    }

    protected void initializeComponent() {
        FXController.of(this).fromDefaultLocation().load();
    }

    protected void bind(UserModel model) {
        genderImageView.imageProperty().bind(Bindings.<Image>select(modelProperty(), "gender", "image"));
        nameLabel.textProperty().bind(model.nameProperty());
        emailLabel.textProperty().bind(model.emailProperty());
    }

    protected void unbind(UserModel model) {
        genderImageView.imageProperty().unbind();
        nameLabel.textProperty().unbind();
        emailLabel.textProperty().unbind();
    }
}

UserModelが設定されたときに、UserModelのプロパティを各コントロールのプロパティにバインドしています。また、StyleClassを設定して、CSSファイルでスタイルを設定できるようにしています。FXControllerについては、こちらを参照してください。また、UserModelを編集するViewとコントローラを以下のように定義します。

<fx:root xmlns:fx="http://javafx.com/fxml"
         type="javafx.scene.layout.GridPane">
    <Label GridPane.rowIndex="0" GridPane.columnIndex="0"
           text="Name:"/>
    <TextField fx:id="nameTextField"
               GridPane.rowIndex="0" GridPane.columnIndex="1"/>

    <Label GridPane.rowIndex="1" GridPane.columnIndex="0"
           text="Email:"/>
    <TextField fx:id="emailTextField"
               GridPane.rowIndex="1" GridPane.columnIndex="1"/>

    <Label GridPane.rowIndex="2" GridPane.columnIndex="0"
           text="Gender:"/>
    <ComboBox fx:id="genderComboBox"
              GridPane.rowIndex="2" GridPane.columnIndex="1"/>

    <HBox id="actionRegion"
          GridPane.rowIndex="3" GridPane.columnSpan="2">
        <Button fx:id="applyButton"
                text="Apply"/>
        <Button fx:id="cancelButton"
                text="Cancel"/>
    </HBox>
</fx:root>
class UserEditView extends GridPane {
    public static final EventType<EditEvent> EDIT_ANY = new EventType<>(Event.ANY, "EDIT");
    public static final EventType<EditEvent> EDIT_CANCEL = new EventType<>(EDIT_ANY, "EDIT_CANCEL");
    public static final EventType<EditEvent> EDIT_COMMIT = new EventType<>(EDIT_ANY, "EDIT_COMMIT");

    public ObjectProperty<UserModel> modelProperty() { return model; }
    private final ObjectProperty<UserModel> model = new SimpleObjectProperty<UserModel>(this, "model") {
        private UserModel currentModel;

        @Override
        protected void invalidated() {
            if (currentModel != null) { UserEditView.this.unbind(currentModel); }
            currentModel = get();
            if (currentModel != null) { UserEditView.this.bind(currentModel); }
        }
    };
    public final UserModel getModel() { return model.get(); }
    public final void setModel(UserModel model) { this.model.set(model); }

    @FXML
    private TextField nameTextField;
    @FXML
    private TextField emailTextField;
    @FXML
    private ComboBox<GenderModel> genderComboBox;
    @FXML
    private Button applyButton;
    @FXML
    private Button cancelButton;

    public UserEditView() {
        initializeComponent();
        getStyleClass().add("user-edit-view");
    }

    protected void initializeComponent() {
        FXController.of(this).fromDefaultLocation().load();
    }

    protected void bind(UserModel model) {
        nameTextField.textProperty().bindBidirectional(model.nameProperty());
        emailTextField.textProperty().bindBidirectional(model.emailProperty());
        genderComboBox.getSelectionModel().select(model.genderProperty().get());
        model.genderProperty().bind(genderComboBox.getSelectionModel().selectedItemProperty());
    }

    protected void unbind(UserModel model) {
        nameTextField.textProperty().unbindBidirectional(model.nameProperty());
        emailTextField.textProperty().unbindBidirectional(model.emailProperty());
        model.genderProperty().unbind();
    }

    @FXML
    protected void initialize() {
        genderComboBox.getItems().addAll(GenderModel.values());

        applyButton.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent actionEvent) {
                fireEvent(new EditEvent(EDIT_COMMIT, getModel()));
            }
        });
        cancelButton.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent actionEvent) {
                fireEvent(new EditEvent(EDIT_CANCEL, null));
            }
        });
    }

    @Override
    public void requestFocus() {
        nameTextField.requestFocus();
        nameTextField.selectAll();
    }

    public static class EditEvent extends Event {
        private final UserModel newValue;

        public EditEvent(EventType<? extends EditEvent> eventType, UserModel newValue) {
            super(eventType);

            this.newValue = newValue;
        }

        public UserModel getNewValue() {
            return newValue;
        }
    }
}

基本的には、表示用のコントローラと同じですが、編集がコミットされた場合やキャンセルされた場合に、イベントを発生するようにしています。

以上で、表示用のViewと編集用のViewができましたので、これらを利用してUserModelを表示するListCellを継承したクラスを作成してみたいと思います。以下のようになります。

class UserListCell extends ListCell<UserModel> {
    private UserView userView;
    private UserEditView userEditView;

    public UserListCell() {
        getStyleClass().add("user-list-cell");
        setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
    }

    public static Callback<ListView<UserModel>, ListCell<UserModel>> forListView() {
        return new Callback<ListView<UserModel>, ListCell<UserModel>>() {
            @Override
            public ListCell<UserModel> call(ListView<UserModel> userModelListView) {
                return new UserListCell();
            }
        };
    }

    @Override
    protected void updateItem(UserModel user, boolean empty) {
        super.updateItem(user, empty);

        if (empty) {
            setGraphic(null);
            return;
        }

        if (userView == null) { userView = new UserView(); }
        userView.setModel(user);
        setGraphic(userView);
    }

    @Override
    public void startEdit() {
        if (!isEditable()) { return; }
        if (!getListView().isEditable()) { return; }

        super.startEdit();

        if (userEditView == null) { userEditView = createUserEditView(); }
        userEditView.setModel(userView.getModel().clone());
        setGraphic(userEditView);
        Platform.runLater(new Runnable() {
            @Override
            public void run() {
                userEditView.requestFocus();
            }
        });
    }

    @Override
    public void cancelEdit() {
        if (!isEditing()) { return; }

        super.cancelEdit();

        setGraphic(userView);
    }

    @Override
    public void commitEdit(UserModel newUser) {
        if (!isEditing()) { return; }

        userView.getModel().merge(newUser);

        super.commitEdit(newUser);
    }

    private UserEditView createUserEditView() {
        UserEditView userEditView = new UserEditView();
        userEditView.addEventHandler(UserEditView.EDIT_COMMIT, new EventHandler<UserEditView.EditEvent>() {
            @Override
            public void handle(UserEditView.EditEvent event) {
                UserListCell.this.commitEdit(event.getNewValue());
            }
        });
        userEditView.addEventHandler(UserEditView.EDIT_CANCEL, new EventHandler<UserEditView.EditEvent>() {
            @Override
            public void handle(UserEditView.EditEvent event) {
                UserListCell.this.cancelEdit();
            }
        });
        return userEditView;
    }
}

updateItemメソッド、startEditメソッド、cancelEditメソッド、commitEditメソッドで、それぞれ必要な処理を実装しています。また、Callbackインターフェイスの実装を取得するファクトリメソッドも用意しています。

以上で、UserModelを表示するListCellの実装が完了しましたので、これらを利用してみます。

まずは、UserModelのデフォルトの実装を以下のように実装します。

class DefaultUserModel implements UserModel {
    @Override
    public StringProperty nameProperty() { return name; }
    private final StringProperty name = new SimpleStringProperty(this, "name");
    public final String getName() { return name.get(); }
    public final void setName(String name) { this.name.set(name); }

    @Override
    public StringProperty emailProperty() { return email; }
    private final StringProperty email = new SimpleStringProperty(this, "email");
    public final String getEmail() { return email.get(); }
    public final void setEmail(String email) { this.email.set(email); }

    @Override
    public ObjectProperty<GenderModel> genderProperty() { return gender; }
    private final ObjectProperty<GenderModel> gender = new SimpleObjectProperty<>(this, "gender");
    public final GenderModel getGender() { return gender.get(); }
    public final void setGender(GenderModel gender) { this.gender.set(gender); }

    protected DefaultUserModel(String name, String email, GenderModel gender) {
        setName(name);
        setEmail(email);
        setGender(gender);
    }

    public static DefaultUserModel asMale(String name, String email) {
        return new DefaultUserModel(name, email, GenderModel.MALE);
    }

    public static DefaultUserModel asFemale(String name, String email) {
        return new DefaultUserModel(name, email, GenderModel.FEMALE);
    }

    @Override
    public UserModel clone() {
        return new DefaultUserModel(getName(), getEmail(), getGender());
    }

    @Override
    public void merge(UserModel user) {
        setName(user.nameProperty().get());
        setEmail(user.emailProperty().get());
        setGender(user.genderProperty().get());
    }
}

次に、表示するシーンを以下のように定義します。

<Scene xmlns:fx="http://javafx.com/fxml"
       fx:id="scene"
       width="640" height="480">
    <stylesheets>
        <URL value="@CustomListCellDemoSceneStyle.css"/>
    </stylesheets>

    <StackPane>
        <ListView fx:id="userListView"
                  editable="true"/>
    </StackPane>
</Scene>

このシーンに対するモデルを以下のように定義します。

interface CustomListCellDemoModel {
    ReadOnlyObjectProperty<ObservableList<UserModel>> usersProperty();

    void loadUsers();
}

このシーンのコントローラを以下のようにします。

class CustomListCellDemoSceneController {
    public ObjectProperty<CustomListCellDemoModel> modelProperty() { return model; }
    private final ObjectProperty<CustomListCellDemoModel> model = new SimpleObjectProperty<CustomListCellDemoModel>(this, "model") {
        private CustomListCellDemoModel currentModel;

        @Override
        protected void invalidated() {
            if (currentModel != null) { CustomListCellDemoSceneController.this.unbind(currentModel); }
            currentModel = get();
            if (currentModel != null) { CustomListCellDemoSceneController.this.bind(currentModel); }
        }
    };
    public final CustomListCellDemoModel getModel() { return model.get(); }
    public final void setModel(CustomListCellDemoModel model) { this.model.set(model); }

    @FXML
    private Scene scene;
    @FXML
    private ListView<UserModel> userListView;

    public void performOn(Stage stage) {
        stage.setScene(scene);
        stage.setTitle("Custom ListCell Demo");
        stage.sizeToScene();
        stage.centerOnScreen();
        stage.show();
    }

    public CustomListCellDemoSceneController with(CustomListCellDemoModel model) {
        setModel(model);
        if (model != null) { model.loadUsers(); }

        return this;
    }

    protected void bind(CustomListCellDemoModel model) {
        userListView.itemsProperty().bind(model.usersProperty());
    }

    protected void unbind(CustomListCellDemoModel model) {
        userListView.itemsProperty().unbind();
    }

    @FXML
    protected void initialize() {
        userListView.setCellFactory(UserListCell.forListView());
    }
}

このシーンのモデルのデフォルトの実装を以下のように実装します。

class DefaultCustomListCellDemoModel implements CustomListCellDemoModel {
    @Override
    public ReadOnlyObjectProperty<ObservableList<UserModel>> usersProperty() { return users.getReadOnlyProperty(); }
    private final ReadOnlyObjectWrapper<ObservableList<UserModel>> users = new ReadOnlyObjectWrapper<>(this, "users");
    public final ObservableList<UserModel> getUsers() { return users.get(); }
    protected final void setUsers(ObservableList<UserModel> users) { this.users.set(users); }

    @Override
    public void loadUsers() {
        setUsers(
            FXCollections.<UserModel>observableArrayList(
                DefaultUserModel.asMale("User 1", "user1@email.com"),
                DefaultUserModel.asFemale("User 2", "user2@email.com"),
                DefaultUserModel.asMale("User 3", "user3@email.com"),
                DefaultUserModel.asMale("User 4", "user4@email.com"),
                DefaultUserModel.asFemale("User 5", "user5@email.com")
            )
        );
    }
}

サンプルデータとして、適当に5件のUserを設定しています。

最後に、アプリケーションクラスを以下のようにします。

public class CustomListCellDemo extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception {
        FXController.of(new CustomListCellDemoSceneController())
            .fromDefaultLocation()
            .load()
            .with(new DefaultCustomListCellDemoModel())
            .performOn(primaryStage);
    }

    public static void main(String... args) {
        launch(args);
    }
}

カスタムListCellの作成には、updateItemメソッドをオーバーライドして、表示内容を実装するようにします。また、編集可能とする場合は、startEditメソッド、cancelEditメソッド、commitEditメソッドをオーバーライドして、編集時の処理を実装するようにします。基本的には、これだけの実装で作成できますので、単なる文字列だけをリストとして表示するのではなく、あるモデルの内容を画像等も含めて一覧として表示したりすることが容易にできそうですので、いろいろなリストの表示ができそうです。

次回は、今回のようにノードをリストのセルとして表示するセルを、再利用できるようなクラスとして作成してみたいと思います。