الربط والأحداث والتنسيق في JavaFX

مشروع: تطبيق JavaFX تفاعلي

18 دقيقة الدرس 10 من 12

مشروع: تطبيق JavaFX تفاعلي

في هذا الدرس الأخير تدمج كل ما تعلمته في البرنامج التعليمي — الخصائص والربط والمجموعات القابلة للملاحظة ومعالجة الأحداث وCSS والعناصر المخصصة — في تطبيق عملي واحد: متتبع المهام. تتحدث الواجهة تلقائيًا مع تغير البيانات؛ لأن ربط JavaFX يتولى التوصيل عنك فلا تحتاج إلى كتابة كود إضافي يدوي تقريبًا.

هدف هذا المشروع ليس بناء أكثر التطبيقات ثراءً بالمميزات. بل إظهار كيف يبدو معمار مدفوع بالربط من البداية إلى النهاية: نموذج يحتوي فقط على حقول Property وObservableList، وواجهة ترتبط بتلك الحقول، ووحدات تحكم تعدّل النموذج — لا الواجهة مباشرةً أبدًا.

هيكل المشروع

اجعل الكود في أربع فئات. هذا الفصل يتيح لك التفكير في كل طبقة بشكل مستقل:

  • Task.java — النموذج؛ كل حقل هو خاصية JavaFX.
  • TaskViewModel.java — يحتوي على ObservableList للمهام وخصائص ملخص مشتقة.
  • TaskTrackerApp.java — الفئة الفرعية من Application؛ تبني المشهد وتعرضه.
  • styles.css — المظهر البصري.

النموذج: Task.java

كل حقل هو Property حتى تستطيع الواجهة ملاحظته مباشرةً. استخدم نمط خاصية JavaFX القياسي: حقل خاص وأداة جلب للقيمة وأداة جلب لكائن الخاصية نفسه.

import javafx.beans.property.*; public class Task { private final StringProperty title = new SimpleStringProperty(); private final BooleanProperty completed = new SimpleBooleanProperty(false); private final IntegerProperty priority = new SimpleIntegerProperty(1); // 1 منخفض → 3 عالٍ public Task(String title, int priority) { this.title.set(title); this.priority.set(priority); } // أدوات جلب القيمة public String getTitle() { return title.get(); } public boolean isCompleted() { return completed.get(); } public int getPriority() { return priority.get(); } // أدوات جلب الخاصية — مطلوبة للربط public StringProperty titleProperty() { return title; } public BooleanProperty completedProperty() { return completed; } public IntegerProperty priorityProperty() { return priority; } }
لماذا نكشف كائن الخاصية؟ إعادة titleProperty() تتيح للمستدعين كتابة label.textProperty().bind(task.titleProperty()). لو اقتصرت على أدوات جلب عادية فلن يكون أي ربط ممكنًا.

نموذج العرض: TaskViewModel.java

نموذج العرض يملك القائمة ويحسب إحصائيات الملخص. لاحظ أن completedCount هو ربط محسوب — يُعيد حساب نفسه تلقائيًا كلما أطلقت أي مهمة في القائمة حدث تغيير.

import javafx.beans.binding.*; import javafx.beans.property.*; import javafx.collections.*; public class TaskViewModel { private final ObservableList<Task> tasks = FXCollections.observableArrayList( task -> new javafx.beans.Observable[] { task.completedProperty() } ); // مشتق: عدد المهام المنجزة private final IntegerBinding completedCount = Bindings.createIntegerBinding( () -> (int) tasks.stream().filter(Task::isCompleted).count(), tasks ); // مشتق: نص ملصق الملخص private final StringBinding summaryText = Bindings.createStringBinding( () -> completedCount.get() + " / " + tasks.size() + " مكتملة", completedCount, tasks ); // مشتق: تعطيل زر "مسح المنجزة" عندما لا يوجد شيء منجز private final BooleanBinding nothingDone = completedCount.isEqualTo(0); public ObservableList<Task> getTasks() { return tasks; } public IntegerBinding completedCountBinding() { return completedCount; } public StringBinding summaryTextBinding() { return summaryText; } public BooleanBinding nothingDoneBinding() { return nothingDone; } public void addTask(String title, int priority) { tasks.add(new Task(title, priority)); } public void clearCompleted() { tasks.removeIf(Task::isCompleted); } }

الحيلة الأساسية هي تمرير لامبدا مستخرج إلى observableArrayList. بدونه تُطلق القائمة حدث تغيير فقط عند إضافة المهام أو حذفها. مع المستخرج تُطلقه أيضًا عند تغيير completedProperty() داخل مهمة موجودة — وهو بالضبط ما يجعل العداد الحي يعمل.

بناء الواجهة: TaskTrackerApp.java

تُوصّل طريقة Application.start() نموذج العرض بشجرة المشهد. كل عنصر ديناميكي يستخدم ربطًا؛ لا يوجد معالج حدث يحدث ملصقًا أو حالة زر يدويًا.

import javafx.application.Application; import javafx.geometry.*; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.layout.*; import javafx.stage.Stage; public class TaskTrackerApp extends Application { private final TaskViewModel vm = new TaskViewModel(); @Override public void start(Stage stage) { // ── صف الإدخال ────────────────────────────────────────────── TextField titleField = new TextField(); titleField.setPromptText("عنوان المهمة الجديدة..."); ComboBox<Integer> priorityBox = new ComboBox<>(); priorityBox.getItems().addAll(1, 2, 3); priorityBox.setValue(1); Button addBtn = new Button("إضافة مهمة"); addBtn.setDefaultButton(true); // تعطيل "إضافة" عندما يكون العنوان فارغًا addBtn.disableProperty().bind( titleField.textProperty().isEmpty() ); addBtn.setOnAction(e -> { vm.addTask(titleField.getText().trim(), priorityBox.getValue()); titleField.clear(); }); HBox inputRow = new HBox(8, titleField, priorityBox, addBtn); HBox.setHgrow(titleField, Priority.ALWAYS); inputRow.setAlignment(Pos.CENTER_LEFT); // ── قائمة المهام ────────────────────────────────────────────── ListView<Task> listView = new ListView<>(vm.getTasks()); listView.setCellFactory(lv -> new TaskCell()); VBox.setVgrow(listView, Priority.ALWAYS); // ── التذييل ───────────────────────────────────────────────── Label summaryLabel = new Label(); summaryLabel.textProperty().bind(vm.summaryTextBinding()); summaryLabel.getStyleClass().add("summary-label"); Button clearBtn = new Button("مسح المنجزة"); clearBtn.disableProperty().bind(vm.nothingDoneBinding()); clearBtn.setOnAction(e -> vm.clearCompleted()); HBox footer = new HBox(8, summaryLabel, new Spacer(), clearBtn); footer.setAlignment(Pos.CENTER_LEFT); // ── الجذر ─────────────────────────────────────────────────── VBox root = new VBox(10, inputRow, listView, footer); root.setPadding(new Insets(12)); root.getStyleClass().add("root-pane"); Scene scene = new Scene(root, 520, 400); scene.getStylesheets().add( getClass().getResource("styles.css").toExternalForm() ); stage.setTitle("متتبع المهام التفاعلي"); stage.setScene(scene); stage.show(); // إضافة بعض المهام النموذجية vm.addTask("قراءة توثيق JavaFX", 2); vm.addTask("بناء المشروع", 3); vm.addTask("كتابة اختبارات الوحدة", 1); } public static void main(String[] args) { launch(args); } }
لاحظ ما هو غائب عن كل معالج حدث. لا معالج يضبط نص ملصق يدويًا أو يُفعّل أو يعطّل زرًا أو يعدّ المهام المنجزة. كل قيمة مشتقة هي ربط — يحسبها الإطار ويُشيعها تلقائيًا.

الخلية المخصصة: TaskCell.java

تُصيّر خلية ListCell المخصصة كل مهمة بخانة اختيار وملصق يتفاعل أسلوبه مع حالة الإنجاز وشارة أولوية. تربط الروابط الخلية بالنموذج حتى يتحدث أسلوب الملصق فور تفعيل خانة الاختيار.

import javafx.geometry.*; import javafx.scene.control.*; import javafx.scene.layout.HBox; public class TaskCell extends ListCell<Task> { private final CheckBox checkBox = new CheckBox(); private final Label titleLabel = new Label(); private final Label priorityBadge = new Label(); private final HBox graphic = new HBox(8, checkBox, titleLabel, priorityBadge); public TaskCell() { graphic.setAlignment(Pos.CENTER_LEFT); HBox.setHgrow(titleLabel, javafx.scene.layout.Priority.ALWAYS); priorityBadge.getStyleClass().add("badge"); } @Override protected void updateItem(Task task, boolean empty) { super.updateItem(task, empty); // فكّ الربط دائمًا قبل إعادة الربط بعنصر جديد titleLabel.textProperty().unbind(); checkBox.selectedProperty().unbindBidirectional( task == null ? null : task.completedProperty() ); if (empty || task == null) { setGraphic(null); return; } titleLabel.textProperty().bind(task.titleProperty()); // ربط ثنائي الاتجاه: تفعيل خانة الاختيار يحدّث النموذج checkBox.selectedProperty().bindBidirectional(task.completedProperty()); // تحديث أسلوب العنوان بناءً على الإنجاز task.completedProperty().addListener((obs, wasCompleted, isNowCompleted) -> updateStyle(isNowCompleted) ); updateStyle(task.isCompleted()); // نص شارة الأولوية وفئة اللون priorityBadge.setText("P" + task.getPriority()); priorityBadge.getStyleClass().removeIf(c -> c.startsWith("p")); priorityBadge.getStyleClass().add("p" + task.getPriority()); setGraphic(graphic); } private void updateStyle(boolean done) { if (done) { titleLabel.setStyle("-fx-strikethrough: true; -fx-text-fill: #999;"); } else { titleLabel.setStyle(""); } } }

CSS: styles.css

يمنح CSS الخاص بـ JavaFX التطبيقَ مظهرًا أنيقًا دون لمس كود Java. تستخدم شارات الأولوية محددات الفئة التي عيّنتها في الخلية.

.root-pane { -fx-background-color: #f5f5f5; -fx-font-family: "Segoe UI", sans-serif; } .summary-label { -fx-font-size: 13px; -fx-text-fill: #555; } .badge { -fx-padding: 2 6 2 6; -fx-background-radius: 4; -fx-font-size: 11px; -fx-text-fill: white; -fx-font-weight: bold; } .p1 { -fx-background-color: #78909c; } /* منخفضة — رمادي أزرق */ .p2 { -fx-background-color: #fb8c00; } /* متوسطة — عنبري */ .p3 { -fx-background-color: #e53935; } /* عالية — أحمر */ .list-view { -fx-background-color: white; -fx-border-color: #ddd; -fx-border-radius: 4; -fx-background-radius: 4; } .list-cell:selected { -fx-background-color: #e3f2fd; }

تشغيل التطبيق

لتشغيل هذا المشروع مع JavaFX SDK على مسار الوحدات استخدم:

javac --module-path $PATH_TO_FX --add-modules javafx.controls \ Task.java TaskViewModel.java TaskCell.java TaskTrackerApp.java java --module-path $PATH_TO_FX --add-modules javafx.controls \ TaskTrackerApp
لم تعد JavaFX مضمّنة مع JDK منذ Java 11. نزّل JavaFX SDK من gluonhq.com/products/javafx واضبط $PATH_TO_FX ليشير إلى مجلد lib/ الخاص به. بديلًا، استخدم Maven/Gradle مع تبعية org.openjfx:javafx-controls التي تتولى ضبط الوحدات تلقائيًا.

ما الذي يجعل هذا المعمار "تفاعليًا"

للمصطلح تفاعلي هنا معنى دقيق: الواجهة تتفاعل مع تغيرات حالة النموذج تلقائيًا دون استدعاءات تحديث أمرية. لاحظ ما يحدث عندما يُعلّم المستخدم مهمة منجزة:

  1. تُطلق CheckBox حدث تغيير.
  2. يُشيع الربط ثنائي الاتجاه ذلك إلى task.completedProperty().
  3. لأن ObservableList يمتلك مستخرجًا فإنه يُطلق حدث تغيير قائمة.
  4. يُعيد completedCount حساب نفسه — مما يُشغّل summaryText لإعادة الحساب.
  5. يتحدث نص summaryLabel على الشاشة.
  6. يُعيد nothingDone حساب نفسه وقد يُفعّل أو يعطّل clearBtn.

تحدث الخطوات الست كلها تلقائيًا نتيجة تغيير خاصية واحدة. لا توجد طريقة تحكم تفحص القائمة، ولا مراقب مكتوب يدويًا يستدعي setText أو setDisable. هذه هي المكافأة الحقيقية لنظام الربط الكامل الذي درسته في هذا البرنامج التعليمي.

الخلاصة

لقد بنيت تطبيق JavaFX مدفوعًا بالربط بالكامل. أبرز الدروس المستفادة: اكشف حالة النموذج كـProperty؛ استخدم قائمة قابلة للملاحظة مع مستخرج عندما تحتاج تغييرات مستوى الخلية إلى الانتشار؛ اشتق كل قيمة محسوبة كـBinding بدلًا من إعادة حسابها يدويًا؛ وصّل الواجهة بالنموذج عبر الروابط حتى لا تفعل المعالجات إلا تعديل النموذج. طبّق هذا النمط على أي مشروع JavaFX وستعكس واجهتك دائمًا الحالة الراهنة لبياناتك.