本文へスキップ
BecomeCoder

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

入力と追加 ― TextFieldとCommandの実行

ブラウザで完結

導入

前回までは表示の準備でした。ここからは実際にタスクを追加できるようにします。JavaFXの画面はブラウザ内では描画できないため、代わりに「Viewが呼ぶことになるViewModel側の振る舞い」をテストで固めます。具体的には、入力中の文字が空白ならAddCommandが実行できないこと、そして実行すると一覧が増え、View(ListView)が再描画されるよう通知が飛ぶことです。

図解

sequenceDiagram
    participant TF as TextField(View)
    participant VM as TaskListViewModel
    participant B as Button(AddCommand)
    TF->>VM: setNewTitle(文字) が呼ばれる
    B->>VM: クリック→AddCommand.execute()
    VM->>VM: service.add(...) / newTitleを空に戻す
    VM-->>TF: "tasks" の変更を通知(一覧が再描画される)

説明

addButton.setOnAction(e -> {
    viewModel.setNewTitle(titleField.getText());
    viewModel.getAddCommand().execute();
});

ViewがこのコードでViewModelを呼ぶとき、期待している振る舞いは次の3つです。

  • setNewTitleで文字を渡すと、getAddCommand().canExecute()が入力の有無で自動的に切り替わる(空白だけならfalse
  • getAddCommand().execute()を呼ぶと、serviceにタスクが追加され、newTitleが空文字に戻る
  • 追加が終わったら、ListViewが再描画されるよう"tasks"の変更が通知される

3つ目が、この章で新しく確認する点です。第6章までは「getTasks()が正しい値を返すか」だけをテストしていましたが、実際にViewへつなぐとなると「値が変わったことをViewに知らせているか」まで確認しないと、ボタンを押しても画面が固まったままになってしまいます。

やってみよう

下のエディタには、これまでの章のクラスがすべてタブで並んでいます。TaskListViewModel.javaadd()メソッドだけがまだありません。まず何も書かずに「▶ 実行」を押し、どのテストが赤くなるか確認してください。

演習

TaskListViewModelに、追加の実処理を行うadd()メソッドを実装してください。newTitleでタスクを追加し、"tasks"の変更を通知し、newTitleを空文字に戻します。

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();
    }

    // TODO: newTitle でタスクを追加し、"tasks" の変更を通知し、
    //       追加が終わったら newTitle を空文字に戻す add() を実装してください
}
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.ArrayList;

public class TaskListViewModelTest {
    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);
    }

    @Test
    void newTitleGaKuuhakuNaraAddCommandHaJikkoDekinai() {
        viewModel.setNewTitle("   ");
        assertFalse(viewModel.getAddCommand().canExecute(), "空白だけのタイトルでは追加できない");
    }

    @Test
    void newTitleGaAreba_AddCommandHaJikkoDekiru() {
        viewModel.setNewTitle("牛乳を買う");
        assertTrue(viewModel.getAddCommand().canExecute(), "タイトルがあれば追加できる");
    }

    @Test
    void addCommandWoJikkoSuruToIchiRanNiFueTsuchiSareru() {
        List<String> notified = new ArrayList<>();
        viewModel.addPropertyChangedListener(name -> notified.add(name));

        viewModel.setNewTitle("牛乳を買う");
        viewModel.getAddCommand().execute();

        assertEquals(1, viewModel.getTasks().size(), "一覧が1件増える");
        assertEquals("", viewModel.getNewTitle(), "追加後は入力欄が空に戻る");
        assertTrue(notified.contains("tasks"), "Viewが一覧を再描画できるよう\"tasks\"の変更が通知される");
    }
}
  • 期待される結果: 3件のテストがすべて成功
ヒント1を見る

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

ヒント2を見る

setNewTitle("")を呼ぶと"newTitle""canAdd"の通知は自動でついてくるので、自分でraisePropertyChanged("tasks")を追加で呼べば十分です

まとめ

  • TextFieldの入力はsetNewTitleで伝え、ButtonのクリックはgetAddCommand().execute()で伝える。これがViewからViewModelを呼ぶときの決まった形
  • canExecutefalseの間、Viewはボタンを自動的に押せなくする(ControllerがsetDisableで反映する)
  • 状態が変わったらraisePropertyChangedで通知する。これを忘れると、処理自体は成功していても画面が更新されない

次回: 一覧から完了操作をつなぎ、CanExecuteでボタンの活性/非活性を制御します。

実際に動かしてみよう

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

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

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