完整 JavaFX Todo List 应用教程
这是一个从零开始构建一个简单的“Smart Todo List”应用的完整教程,使用 JavaFX 实现。应用功能包括:添加、编辑、删除任务;标记完成;本地文件存储(简单文本格式,可扩展到 JSON)。我们假设您使用 IntelliJ IDEA、JDK 17 和 OpenJFX 17(因为您的环境配置如此)。如果您是初学者,我会一步步解释,包括代码和潜在问题修复。
前提条件
- 安装 JDK 17:从 Oracle 或 Adoptium 下载并安装。设置环境变量
JAVA_HOME和 Path。 - 下载 OpenJFX 17:从 GluonHQ 下载 SDK(您的路径:
D:\Program Files\openjfx-17.0.15_windows-x64_bin-sdk\javafx-sdk-17.0.15)。 - IntelliJ IDEA:社区版或 Ultimate。安装后,创建新 Java 项目。
- Scene Builder(可选但推荐):下载 Gluon Scene Builder 17,用于可视化编辑 FXML 文件。从 GluonHQ 获取。
- 项目设置:
- 创建新项目:File → New → Project → Java → Next → 命名
CAT201W。 - 添加模块:右键 src → New → module-info.java。
- 配置 JavaFX:File → Project Structure → Libraries → + → Java → 选择 OpenJFX lib 目录下的所有 JAR(javafx-base.jar 等)。
- 运行配置:Edit Configurations → Add → Application → Main class:
mytodo.MainApp→ VM options:--module-path "D:\Program Files\openjfx-17.0.15_windows-x64_bin-sdk\javafx-sdk-17.0.15\lib" --add-modules javafx.controls,javafx.fxml。
- 创建新项目:File → New → Project → Java → Next → 命名
如果遇到模块问题(如“程序包不可见”),参考后续修复。
步骤 1: 创建 Task 模型类
在 src/mytodo 包下创建 Task.java。这是任务的数据模型,使用简单 getter/setter。
package mytodo;
import java.time.LocalDate;
public class Task {
private String title;
private String description;
private LocalDate dueDate;
private String category;
private String priority;
private boolean completed;
public Task(String title, String description, LocalDate dueDate, String category, String priority, boolean completed) {
this.title = title;
this.description = description;
this.dueDate = dueDate;
this.category = category;
this.priority = priority;
this.completed = completed;
}
public Task() { }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public LocalDate getDueDate() { return dueDate; }
public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getPriority() { return priority; }
public void setPriority(String priority) { this.priority = priority; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
}
- 解释:使用
LocalDate处理日期。默认构造函数用于反射或空任务。
步骤 2: 创建 FXML 文件(UI 布局)
在 src/mytodo 下创建 main.fxml。使用 Scene Builder 拖拽组件,或直接复制以下代码。
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.DatePicker?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Menu?>
<?import javafx.scene.control.MenuBar?>
<?import javafx.scene.control.MenuItem?>
<?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.RowConstraints?>
<?import javafx.scene.layout.VBox?>
<BorderPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="mytodo.MainController">
<top>
<MenuBar prefHeight="0.0" prefWidth="607.0" BorderPane.alignment="CENTER">
<menus>
<Menu mnemonicParsing="false" text="File">
<items>
<MenuItem mnemonicParsing="false" text="Close" />
</items>
</Menu>
<Menu mnemonicParsing="false" text="Edit">
<items>
<MenuItem mnemonicParsing="false" text="Delete" />
</items>
</Menu>
<Menu mnemonicParsing="false" text="Help">
<items>
<MenuItem mnemonicParsing="false" text="About" />
</items>
</Menu>
</menus>
</MenuBar>
</top>
<right>
<VBox prefHeight="375.0" prefWidth="145.0" BorderPane.alignment="CENTER" spacing="10.0">
<children>
<Button fx:id="addButton" mnemonicParsing="false" onAction="#onAddButtonClick" text="Add" />
<Button fx:id="editButton" mnemonicParsing="false" onAction="#onEditButtonClick" text="Edit" />
<Button fx:id="deleteButton" mnemonicParsing="false" onAction="#onDeleteButtonClick" text="Delete" />
<Button fx:id="markCompletedButton" mnemonicParsing="false" onAction="#onMarkCompletedButtonClick" text="Mark Completed" />
</children>
</VBox>
</right>
<bottom>
<GridPane hgap="10.0" vgap="10.0" BorderPane.alignment="CENTER">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" minWidth="10.0" prefWidth="100.0" />
<ColumnConstraints hgrow="ALWAYS" minWidth="10.0" prefWidth="300.0" />
</columnConstraints>
<rowConstraints>
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
</rowConstraints>
<children>
<Label text="Title:" GridPane.rowIndex="0" />
<Label text="Description:" GridPane.rowIndex="1" />
<Label text="Due Date:" GridPane.rowIndex="2" />
<Label text="Category:" GridPane.rowIndex="3" />
<Label text="Priority:" GridPane.rowIndex="4" />
<TextField fx:id="titleField" GridPane.columnIndex="1" GridPane.rowIndex="0" />
<TextField fx:id="descriptionField" GridPane.columnIndex="1" GridPane.rowIndex="1" />
<DatePicker fx:id="dueDatePicker" GridPane.columnIndex="1" GridPane.rowIndex="2" />
<ComboBox fx:id="categoryCombo" prefWidth="150.0" GridPane.columnIndex="1" GridPane.rowIndex="3" />
<ComboBox fx:id="priorityCombo" prefWidth="150.0" GridPane.columnIndex="1" GridPane.rowIndex="4" />
</children>
</GridPane>
</bottom>
<center>
<TableView fx:id="taskTable" prefHeight="200.0" prefWidth="200.0" BorderPane.alignment="CENTER">
<columns>
<TableColumn fx:id="titleColumn" prefWidth="75.0" text="Title" />
<TableColumn fx:id="descriptionColumn" prefWidth="75.0" text="Description" />
<TableColumn fx:id="dueDateColumn" prefWidth="75.0" text="Due Date" />
<TableColumn fx:id="categoryColumn" prefWidth="75.0" text="Category" />
<TableColumn fx:id="priorityColumn" prefWidth="75.0" text="Priority" />
<TableColumn fx:id="completedColumn" prefWidth="75.0" text="Completed" />
</columns>
</TableView>
</center>
</BorderPane>
- 解释:使用 BorderPane 布局。添加
fx:id用于控制器绑定,onAction链接按钮事件。xmlns 设为 17 以匹配运行时。
步骤 3: 创建 MainController 类
在 src/mytodo 下创建 MainController.java。这是 UI 逻辑,包括事件处理和数据存储。
package mytodo;
import java.time.LocalDate;
import java.util.List;
import java.nio.file.Files;
import java.nio.file.Path;
import javafx.fxml.FXML;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.DatePicker;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Button;
import javafx.scene.control.Alert;
import javafx.scene.control.cell.PropertyValueFactory;
public class MainController {
// ====== TableView + Columns ======
@FXML private TableView<Task> taskTable;
@FXML private TableColumn<Task, String> titleColumn;
@FXML private TableColumn<Task, String> descriptionColumn;
@FXML private TableColumn<Task, LocalDate> dueDateColumn;
@FXML private TableColumn<Task, String> categoryColumn;
@FXML private TableColumn<Task, String> priorityColumn;
@FXML private TableColumn<Task, Boolean> completedColumn;
// ====== Form ======
@FXML private TextField titleField;
@FXML private TextField descriptionField;
@FXML private DatePicker dueDatePicker;
@FXML private ComboBox<String> categoryCombo;
@FXML private ComboBox<String> priorityCombo;
// ====== Buttons ======
@FXML private Button addButton;
@FXML private Button editButton;
@FXML private Button deleteButton;
@FXML private Button markCompletedButton;
// ====== 数据结构 ======
private final ObservableList<Task> taskList = FXCollections.observableArrayList();
private FilteredList<Task> filteredTasks;
private SortedList<Task> sortedTasks;
// 先用一个简单文本文件代替 JSON(你可以自己改成真正 JSON)
private final Path dataPath = Path.of("tasks.txt");
@FXML
private void initialize() {
// 1. 列绑定(必须 + 和 Task 的 getter 名一致)
titleColumn.setCellValueFactory(new PropertyValueFactory<>("title"));
descriptionColumn.setCellValueFactory(new PropertyValueFactory<>("description"));
dueDateColumn.setCellValueFactory(new PropertyValueFactory<>("dueDate"));
categoryColumn.setCellValueFactory(new PropertyValueFactory<>("category"));
priorityColumn.setCellValueFactory(new PropertyValueFactory<>("priority"));
completedColumn.setCellValueFactory(new PropertyValueFactory<>("completed"));
// 2. 下拉框初始值(按你自己喜好改)
categoryCombo.setItems(FXCollections.observableArrayList(
"Work", "Study", "Personal", "Other"
));
priorityCombo.setItems(FXCollections.observableArrayList(
"Low", "Medium", "High"
));
// 3. FilteredList + SortedList
filteredTasks = new FilteredList<>(taskList, task -> true);
sortedTasks = new SortedList<>(filteredTasks);
sortedTasks.comparatorProperty().bind(taskTable.comparatorProperty());
taskTable.setItems(sortedTasks);
// 4. 从本地文件加载一次数据
loadTasksFromFile();
}
// ====== Add 按钮 ======
@FXML
private void onAddButtonClick() {
String title = titleField.getText();
String description = descriptionField.getText();
LocalDate dueDate = dueDatePicker.getValue();
String category = categoryCombo.getValue();
String priority = priorityCombo.getValue();
if (title == null || title.isBlank()) {
showAlert("Validation Error", "Title cannot be empty.");
return;
}
if (dueDate == null) {
showAlert("Validation Error", "Please select a due date.");
return;
}
Task newTask = new Task(title, description, dueDate, category, priority, false);
taskList.add(newTask);
clearForm();
saveTasksToFile(); // 这里简单保存成文本格式
}
// ====== Edit 按钮(你可以在这基础上自己改写/简化) ======
@FXML
private void onEditButtonClick() {
Task selected = taskTable.getSelectionModel().getSelectedItem();
if (selected == null) {
showAlert("No selection", "Select a task to edit.");
return;
}
// 下面几行逻辑你可以自己重新写一遍,保证自己理解
String title = titleField.getText();
String description = descriptionField.getText();
LocalDate dueDate = dueDatePicker.getValue();
String category = categoryCombo.getValue();
String priority = priorityCombo.getValue();
if (title == null || title.isBlank() || dueDate == null) {
showAlert("Validation Error", "Title and due date cannot be empty.");
return;
}
selected.setTitle(title);
selected.setDescription(description);
selected.setDueDate(dueDate);
selected.setCategory(category);
selected.setPriority(priority);
taskTable.refresh();
clearForm();
saveTasksToFile();
}
// ====== Delete 按钮 ======
@FXML
private void onDeleteButtonClick() {
Task selected = taskTable.getSelectionModel().getSelectedItem();
if (selected == null) {
showAlert("No selection", "Select a task to delete.");
return;
}
taskList.remove(selected);
clearForm();
saveTasksToFile();
}
// ====== Mark Completed 按钮 ======
@FXML
private void onMarkCompletedButtonClick() {
Task selected = taskTable.getSelectionModel().getSelectedItem();
if (selected == null) {
showAlert("No selection", "Select a task to mark.");
return;
}
selected.setCompleted(true);
taskTable.refresh();
saveTasksToFile();
}
// ====== 简单 I/O:用“|”分隔的文本,代替 JSON,逻辑你可以自己重写一遍 ======
private void saveTasksToFile() {
try {
List<String> lines = taskList.stream()
.map(task -> String.join("|",
safe(task.getTitle()),
safe(task.getDescription()),
task.getDueDate() == null ? "" : task.getDueDate().toString(),
safe(task.getCategory()),
safe(task.getPriority()),
Boolean.toString(task.isCompleted())
))
.toList();
Files.write(dataPath, lines);
} catch (Exception e) {
e.printStackTrace();
showAlert("Error", "Failed to save tasks.");
}
}
private void loadTasksFromFile() {
try {
if (!Files.exists(dataPath)) {
return;
}
List<String> lines = Files.readAllLines(dataPath);
taskList.clear();
for (String line : lines) {
String[] parts = line.split("\\|", -1);
if (parts.length < 6) continue;
String title = parts[0];
String description = parts[1];
LocalDate dueDate = parts[2].isEmpty() ? null : LocalDate.parse(parts[2]);
String category = parts[3];
String priority = parts[4];
boolean completed = Boolean.parseBoolean(parts[5]);
Task t = new Task(title, description, dueDate, category, priority, completed);
taskList.add(t);
}
} catch (Exception e) {
e.printStackTrace();
showAlert("Error", "Failed to load tasks.");
}
}
private String safe(String s) {
return s == null ? "" : s.replace("|", "/");
}
// ====== 小工具方法 ======
private void clearForm() {
titleField.clear();
descriptionField.clear();
dueDatePicker.setValue(null);
categoryCombo.setValue(null);
priorityCombo.setValue(null);
}
private void showAlert(String title, String message) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
}
- 解释:
@FXML注解注入 UI 元素。initialize():设置表列、ComboBox、加载数据。- 按钮事件:添加/编辑/删除/标记,使用
ObservableList自动更新 UI。 - I/O:简单文本保存(
tasks.txt生成在项目根或 out 目录)。可扩展用 Gson 做 JSON。
步骤 4: 创建 MainApp 入口类
在 src/mytodo 下创建 MainApp.java。
package mytodo;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class MainApp extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
FXMLLoader loader = new FXMLLoader(
getClass().getResource("main.fxml")
);
Scene scene = new Scene(loader.load());
primaryStage.setTitle("Smart Todo List");
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
- 解释:加载 FXML,设置场景。
步骤 5: 配置 module-info.java
在 src 根目录创建 module-info.java。
module CAT201W {
requires javafx.controls;
requires javafx.fxml;
exports mytodo;
opens mytodo to javafx.fxml; // 允许 FXML 反射访问
}
- 解释:
requires声明依赖;exports暴露包;opens解决私有字段注入问题(常见错误:InaccessibleObjectException)。
步骤 6: 运行和调试
- 编译:Build → Build Project。
- 运行:右键 MainApp → Run。
- 测试:
- 添加任务:填写 form,点击 Add → 表更新,文件保存。
- 编辑:选中行,修改 form,点击 Edit。
- 删除/标记:选中后点击。
- 重启:数据从
tasks.txt加载。
- 常见错误修复:
- 程序包不可见:添加
requires javafx.fxml;。 - InaccessibleObjectException:添加
opens mytodo to javafx.fxml;。 - FXML 版本警告:FXML xmlns 设为 17,或升级 JavaFX。
- NullPointerException:检查 fx:id 拼写匹配
@FXML字段。 - 文件 I/O 失败:检查路径权限;
dataPath可改为绝对路径如Path.of(System.getProperty("user.home") + "/tasks.txt");。 - 表不显示:确保 getter 名匹配 PropertyValueFactory。
- 程序包不可见:添加
步骤 7: 扩展建议
- 添加过滤:在 FilteredList 中添加谓词(如基于 category)。
- JSON 存储:添加 Gson 依赖(Maven:
com.google.code.gson:gson),替换 I/O 方法。 - 选中填充 form:在
initialize()添加taskTable.getSelectionModel().selectedItemProperty().addListener((obs, old, new) -> { if (new != null) { titleField.setText(new.getTitle()); ... } });。 - 样式:添加 CSS 文件到 Scene。
- 打包:用 jpackage 或 Maven Shade 打包成可执行 JAR。
这个教程基于您的代码和错误,提供完整、可运行版本。如果遇到具体问题,提供日志,我可以进一步优化!