本文へスキップ
BecomeCoder

Java MVVMコース · 第7章 View ― JavaFXへ接続する · レッスン29

完了・削除 ― ボタンの活性/非活性

ブラウザで完結

導入

一覧のどのタスクにも「完了」ボタンが押せてしまうのはおかしな話です。すでに完了しているタスクや、存在しないタスクに対しては、ボタンが自動的にグレーアウトしてほしいところです。

このコースでの簡略化: 実務では行ごとに「完了」「削除」のボタンを置きますが、このコースの共通のAPI契約にはcomplete(id)しかなく、削除(remove)は含まれていません(学習の範囲を絞るための意図的な簡略化です)。ここでは「完了」のcanExecuteを、一覧の状態(対象のタスクが存在し、まだ完了していないか)から組み立てる練習をします。実務でDelete機能を足すときも、考え方(一覧の状態からCanExecuteを計算する)はまったく同じです。

図解

flowchart TB
    T["viewModel.getTasks()"] -->|"対象idが存在し、未完了か"| CE{"canExecute"}
    CE -->|"true"| ON["ボタンが押せる"]
    CE -->|"false"| OFF["ボタンがグレーアウト"]

説明

実務のJavaFXでは、一覧の各行に完了ボタンを置くとき、ListViewsetCellFactoryの中でその行専用のCommandを組み立てます。

taskListView.setCellFactory(list -> new ListCell<>() {
    private final Button completeButton = new Button("完了");
    {
        completeButton.setOnAction(e -> {
            TaskDto task = getItem();
            if (task != null) viewModel.complete(task.id());
        });
    }
    @Override
    protected void updateItem(TaskDto task, boolean empty) {
        super.updateItem(task, empty);
        if (empty || task == null) {
            setGraphic(null);
        } else {
            completeButton.setDisable(task.completed());
            setGraphic(completeButton);
        }
    }
});

このレッスンでは、上のコードが呼び出すことになる「対象のタスクに対する完了操作」を、TaskListViewModelとは別の小さなクラスTaskRowActionsとして切り出し、canExecuteの組み立て方をテストで固めます。

やってみよう

下のエディタのTaskRowActions.javaには、まだhasPendingTaskの中身がありません。まず「▶ 実行」を押して、どのテストが失敗するか確認してください。

演習

TaskRowActions.hasPendingTaskを実装してください。指定したidのタスクが一覧の中に存在し、かつまだ完了していない場合だけtrueを返します。Stream APIは使えないので拡張forで探します。

public interface IdGenerator { String next(); }
public interface Clock { long today(); }
public class TaskTitle {
    private final String value;

    public TaskTitle(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("タイトルは空にできません");
        }
        if (value.length() > 50) {
            throw new IllegalArgumentException("タイトルは50文字以内です");
        }
        this.value = value;
    }

    public String getValue() { return value; }

    @Override
    public String toString() { return value; }
}

public enum Priority {
    LOW("低"), MEDIUM("中"), HIGH("高");

    private final String label;

    Priority(String label) { this.label = label; }

    public String getLabel() { return label; }
}

public class TaskItem {
    private final String id;
    private TaskTitle title;
    private final Priority priority;
    private boolean completed;

    public TaskItem(String id, TaskTitle title, Priority priority) {
        this.id = id;
        this.title = title;
        this.priority = priority;
    }

    public String getId() { return id; }
    public TaskTitle getTitle() { return title; }
    public Priority getPriority() { return priority; }
    public boolean isCompleted() { return completed; }

    public void complete() { completed = true; }

    public void rename(TaskTitle newTitle) { title = newTitle; }
}

public class TaskFactory {
    private final IdGenerator idGenerator;

    public TaskFactory(IdGenerator idGenerator) {
        this.idGenerator = idGenerator;
    }

    public TaskItem create(String title, Priority priority) {
        return new TaskItem(idGenerator.next(), new TaskTitle(title), priority);
    }

    public TaskItem create(String title) {
        return create(title, Priority.MEDIUM);
    }
}
import java.util.List;
import java.util.ArrayList;
import java.util.Optional;

public interface TaskRepository {
    void add(TaskItem task);
    List<TaskItem> findAll();
    Optional<TaskItem> findById(String id);
}

public class InMemoryTaskRepository implements TaskRepository {
    private final List<TaskItem> tasks = new ArrayList<>();

    public void add(TaskItem task) {
        tasks.add(task);
    }

    public List<TaskItem> findAll() {
        return tasks;
    }

    public Optional<TaskItem> findById(String id) {
        for (TaskItem task : tasks) {
            if (task.getId().equals(id)) return Optional.of(task);
        }
        return Optional.empty();
    }
}
import java.util.List;
import java.util.ArrayList;

public record TaskDto(String id, String title, String priorityLabel, boolean completed) { }

public class TaskMapper {
    public static TaskDto toDto(TaskItem task) {
        return new TaskDto(task.getId(), task.getTitle().getValue(), task.getPriority().getLabel(), task.isCompleted());
    }
}

public class TaskService {
    private final TaskRepository repository;
    private final TaskFactory factory;

    public TaskService(TaskRepository repository, TaskFactory factory) {
        this.repository = repository;
        this.factory = factory;
    }

    public void add(String title, Priority priority) {
        TaskItem task = factory.create(title, priority);
        repository.add(task);
    }

    public void complete(String id) {
        repository.findById(id).ifPresent(task -> task.complete());
    }

    public List<TaskDto> list() {
        List<TaskDto> result = new ArrayList<>();
        for (TaskItem task : repository.findAll()) {
            result.add(TaskMapper.toDto(task));
        }
        return result;
    }

    public int remainingCount() {
        int count = 0;
        for (TaskItem task : repository.findAll()) {
            if (!task.isCompleted()) count = count + 1;
        }
        return count;
    }
}
import java.util.List;
import java.util.ArrayList;

public interface PropertyChangedListener {
    void onPropertyChanged(String propertyName);
}

public abstract class ViewModelBase {
    private final List<PropertyChangedListener> listeners = new ArrayList<>();

    public void addPropertyChangedListener(PropertyChangedListener listener) {
        listeners.add(listener);
    }

    protected void raisePropertyChanged(String propertyName) {
        for (PropertyChangedListener listener : listeners) {
            listener.onPropertyChanged(propertyName);
        }
    }
}
import java.util.function.BooleanSupplier;

public interface Command {
    void execute();
    default boolean canExecute() { return true; }
}

public class RelayCommand implements Command {
    private final Runnable action;
    private final BooleanSupplier canExecute;

    public RelayCommand(Runnable action) {
        this(action, () -> true);
    }

    public RelayCommand(Runnable action, BooleanSupplier canExecute) {
        this.action = action;
        this.canExecute = canExecute;
    }

    public void execute() {
        action.run();
    }

    public boolean canExecute() {
        return canExecute.getAsBoolean();
    }
}
import java.util.List;

public class TaskListViewModel extends ViewModelBase {
    private final TaskService service;
    private String newTitle = "";
    private final Command addCommand = new RelayCommand(() -> add(), () -> canAdd());

    public TaskListViewModel(TaskService service) {
        this.service = service;
    }

    public String getNewTitle() { return newTitle; }

    public void setNewTitle(String value) {
        newTitle = value;
        raisePropertyChanged("newTitle");
        raisePropertyChanged("canAdd");
    }

    public List<TaskDto> getTasks() {
        return service.list();
    }

    public int getRemainingCount() {
        return service.remainingCount();
    }

    public Command getAddCommand() {
        return addCommand;
    }

    public void complete(String id) {
        service.complete(id);
        raisePropertyChanged("tasks");
        raisePropertyChanged("remainingCount");
    }

    private boolean canAdd() {
        return newTitle != null && !newTitle.isBlank();
    }

    private void add() {
        service.add(newTitle, Priority.MEDIUM);
        setNewTitle("");
        raisePropertyChanged("tasks");
        raisePropertyChanged("remainingCount");
    }
}
public class TaskRowActions {

    // TODO: 指定した id のタスクが一覧の中に存在し、かつまだ完了していない場合だけ true を返す
    //       hasPendingTask を実装してください(Stream APIは使えないので拡張forで探します)
    public static boolean hasPendingTask(TaskListViewModel viewModel, String taskId) {
    }

    public static Command completeCommandFor(TaskListViewModel viewModel, String taskId) {
        return new RelayCommand(
            () -> viewModel.complete(taskId),
            () -> hasPendingTask(viewModel, taskId)
        );
    }
}
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class TaskRowActionsTest {
    private TaskListViewModel viewModel;

    @BeforeEach
    void setUp() {
        IdGenerator idGenerator = () -> "id-1";
        TaskRepository repository = new InMemoryTaskRepository();
        TaskFactory factory = new TaskFactory(idGenerator);
        TaskService service = new TaskService(repository, factory);
        viewModel = new TaskListViewModel(service);
        viewModel.setNewTitle("牛乳を買う");
        viewModel.getAddCommand().execute();
    }

    @Test
    void mikanryoTaskHaCanExecuteGaShinDearu() {
        String id = viewModel.getTasks().get(0).id();
        Command completeCommand = TaskRowActions.completeCommandFor(viewModel, id);
        assertTrue(completeCommand.canExecute(), "完了していないタスクは実行できる");
    }

    @Test
    void sonzaiShinaiIdHaCanExecuteGaGiDearu() {
        Command completeCommand = TaskRowActions.completeCommandFor(viewModel, "not-exist");
        assertFalse(completeCommand.canExecute(), "一覧に無いidは実行できない");
    }

    @Test
    void jikkoSuruToKanryoSareCanExecuteGaGiNiKawaru() {
        String id = viewModel.getTasks().get(0).id();
        Command completeCommand = TaskRowActions.completeCommandFor(viewModel, id);

        completeCommand.execute();

        assertFalse(completeCommand.canExecute(), "完了した後は同じボタンが押せなくなる");
    }
}
  • 期待される結果: 3件のテストがすべて成功
ヒント1を見る

for (TaskDto task : viewModel.getTasks()) { if (task.id().equals(taskId) && !task.completed()) return true; } return false;

ヒント2を見る

TaskDtorecordなので、task.id()task.completed()のようにアクセサで値を取り出せます

まとめ

  • 行ごとのボタンのcanExecuteは、一覧の現在の状態(viewModel.getTasks())を見て毎回計算する
  • 対象が存在しない、あるいは既に完了している場合はfalseを返すことで、Viewはボタンを自動的にグレーアウトできる
  • このコースの契約に無い機能(削除)が欲しくなったときは、第5章TaskServiceに対応するメソッドを足すところから始める

次回: getRemainingCount()のような計算プロパティを画面に表示します。

実際に動かしてみよう

このレッスンのサンプルは、実務と同じように役割ごとの .java ファイルへ分けてあります。下のエディタは最初からその複数ファイルが入った状態で、上のタブでファイルを切り替えられます。そのまま「▶ 実行」を押せば全ファイルをまとめて解釈して動かせます。@Test の付いたテストがあるレッスンでは、テストメソッドごとに ✅/❌ の一覧(Red/Green)が出るので、まずテストを赤くしてから実装で緑にする、というTDDの回し方をその場で体験できます(本物のJVMではなく、JUnit・Mockitoの主要な書き方まで再現した学習用シミュレータです)。

Java — ブラウザ内で実行(学習用シミュレータ)

Javaの教材サブセットを動かす学習用シミュレータを読み込みます(本物のJVMではなく、動きを再現した軽量な自作エンジンです)。
スクロールして表示された時点でも自動で読み込まれます。