لوحة المتصدرين
المحتوى الأكثر حصولًا على سمعة جيدة
المحتوى الأعلى تقييمًا في 12/06/22 في كل الموقع
-
السلام عليكم ورحمة الله وبركاته اطاب الله اوقاتكم بالخيرات كيف حالكم ؟؟ سؤالي كيف يمكن اضافة جدول للوصف في المنتجات بالووردبريس بارك الله فيكم http://www.uutvbox.com/pd.jsp?id=12#_jcp=2 مثال هذا الرابط الوصف الخاص بالمنتجات1 نقطة
-
1 نقطة
-
1 نقطة
-
أظن أن هاته التوابع تم التخلي عنها في نسخ متأخرة من PHPunit (اذكر انها كانت موجودة في النسخة الخامسة من لارافيل). فقد تم فصل عملية اختبار الواجهة الامامية أو اتمتة المتصفح عن الـ core الخاص بـ PHPunit فهي الآن تركز على: اختبارات HTTP اختبارات قواعد البيانات اختبارات Console بجانب تزييف الاحداث لغرض الاختبار او ما يسمى Mocking أما في يخص أتمتة المتصفح فيتوفر الآن Laravel dusk كحزمة خارجية والذي يتكامل بشكل جميل مع قوقل كروم. هو يوفر تقريبا نفس التوابع والادوات التي كانت متوفرة سابقا. اعرف أكثر عن اختبارات المتصفح (Laravel Dusk) في Laravel1 نقطة
-
EdgeInsets.all(29.0) هل رح تكون مختلفه عن الي تحت EdgeInsets.all(33.0) و اتنى مزيد من التوضح و شكرا1 نقطة
-
مرحبا Lwr Dcsc، تحقق من تواجد الملف activate داخل المجلد c:\Users\lion\Taskaty\venv\Scripts\ هذا الملف هو المسؤول على تفعيل البيئة الافتراضية في بايثون. في حالة عدم تواجده يرجى حذف المجلد venv وإعادة تطبيق الأمر python -m venv venv من جديد. بالتوفيق.1 نقطة
-
اخي الفاضل رشح لي احد المدربين في اكاديمية حاسوب موقع Hackerrank للتدرب عليه . المشكلة هذا الموقع لا يفتح لدي عندما اقوم بالدخول الى الموقع يظهر لي هذا الخطا You dont have permission to access on this server الرجاء الرجاء الرجاء ما السبيل لحل هذه المشكلة لانه هذه المرة الثاني التي اطرح فيها هذه السؤال ولم احصل على إجابة1 نقطة
-
من جانب السيرفر فإن MQTT هي من أقوى تقنيات الوقت الفعلي والتي تعتمد عليها أغلب شركات العالم مثل أمازون وجوجل وغيرها . حيث توفر أداء عالي وتكلفة أقل والكثير من الميزات1 نقطة
-
Flutter Google Map مع تتبع الموقع المباشر - أسلوب أوبر الإعداد الأولي تأكد من إعداد بيئتك وفقًا لذلك لتمكين تتبع الموقع على كل من IOS و Android باتباع الخطوات الواردة في README الخاص بالحزمة فيما يتعلق بملف بيان Android و iOS Info.plist. بمجرد الإعداد ، تبدو التبعيات مثل .... dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 flutter_polyline_points: ^1.0.0 google_maps_flutter: ^2.1.7 location: ^4.4.0 ... خريطة جوجل 🗺 قم بإنشاء StatefulWidget يسمى OrderTrackingPage مع فئة الحالة المقابلة ، حيث قمت باستيراد الحزم المطلوبة بالإضافة إلى بعض المصدر الثابت وموقع الوجهة import 'dart:async'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; class OrderTrackingPage extends StatefulWidget { const OrderTrackingPage({Key? key}) : super(key: key); @override State<OrderTrackingPage> createState() => OrderTrackingPageState(); } class OrderTrackingPageState extends State<OrderTrackingPage> { final Completer<GoogleMapController> _controller = Completer(); static const LatLng sourceLocation = LatLng(37.33500926, -122.03272188); static const LatLng destination = LatLng(37.33429383, -122.06600055); @override Widget build(BuildContext context) { return Scaffold( body: ... GoogleMap widget will be here ..., ); } } أنشئ أداة GoogleMap وعيّن موقع الكاميرا الأولي على موقع المصدر. تحتاج الخريطة إلى التكبير قليلاً ، لذا اضبطها على 13.5. نحتاج إلى علامة / دبوس لفهم الموقع الدقيق. تحديد علامة وتعيين موضعها إلى موقع المصدر. للوجهة ، أضف علامة / دبوس آخر. GoogleMap( initialCameraPosition: const CameraPosition( target: sourceLocation, zoom: 13.5, ), markers: { const Marker( markerId: MarkerId("source"), position: sourceLocation, ), const Marker( markerId: MarkerId("destination"), position: destination, ), }, onMapCreated: (mapController) { _controller.complete(mapController); }, ), رسم اتجاه الطريق 〰 الشيء التالي الذي أريد القيام به هو رسم خط من الوجهة إلى المصدر. قم بإنشاء قائمة فارغة تسمى polylineCoordinates. قم بإنشاء مثيل من PolylinePoints ووظيفة غير متزامنة تسمى getPolyPoints. تقوم طريقة getRouteBetweenCoordinates بإرجاع قائمة النقاط متعددة الخطوط. كانت مواقع مفتاح Google API والمصدر والوجهة مطلوبة. إذا لم تكن النقاط فارغة ، نقوم بتخزينها في إحداثيات متعددة الخطوط. List<LatLng> polylineCoordinates = []; void getPolyPoints() async { PolylinePoints polylinePoints = PolylinePoints(); PolylineResult result = await polylinePoints.getRouteBetweenCoordinates( google_api_key, // Your Google Map Key PointLatLng(sourceLocation.latitude, sourceLocation.longitude), PointLatLng(destination.latitude, destination.longitude), ); if (result.points.isNotEmpty) { result.points.forEach( (PointLatLng point) => polylineCoordinates.add( LatLng(point.latitude, point.longitude), ), ); setState(() {}); } } على initState استدعاء getPolyPoints @override void initState() { getPolyPoints(); super.initState(); } العودة إلى أداة GoogleMap ، حدد الخطوط المتعددة. GoogleMap( ... polylines: { Polyline( polylineId: const PolylineId("route"), points: polylineCoordinates, color: const Color(0xFF7B61FF), width: 6, ), }, ), تحديثات الموقع في الوقت الحقيقي على الخريطة 🔴 نصل الآن إلى الجزء الأكثر إثارة ، نحن بحاجة إلى موقع الجهاز. إنشاء متغير nullable يسمى currentLocation. ثم تقوم دالة تسمى getCurrentLocation، Inside، بإنشاء مثيل للموقع. بمجرد أن نحصل على الموقع ، قم بتعيين الموقع الحالي ليكون مساويًا للموقع. عند تغيير الموقع ، قم بتحديث الموقع الحالي. اجعلها مرئية للخريطة المسماة setState. LocationData? currentLocation; void getCurrentLocation() async { Location location = Location(); location.getLocation().then( (location) { currentLocation = location; }, ); GoogleMapController googleMapController = await _controller.future; location.onLocationChanged.listen( (newLoc) { currentLocation = newLoc; googleMapController.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( zoom: 13.5, target: LatLng( newLoc.latitude!, newLoc.longitude!, ), ), ), ); setState(() {}); }, ); } تأكد من استدعاء getCurrentLocation على initState. void initState() { getPolyPoints(); getCurrentLocation(); super.initState(); } إذا كان CurrentLocation فارغًا ، فسيتم عرض نص التحميل. أيضًا ، أضف علامة / دبوسًا آخر للموقع الحالي بالإضافة إلى تغيير موضع الكاميرا الأولي إلى الموقع الحالي. body: currentLocation == null ? const Center(child: Text("Loading")) : GoogleMap( initialCameraPosition: CameraPosition( target: LatLng( currentLocation!.latitude!, currentLocation!.longitude!), zoom: 13.5, ), markers: { Marker( markerId: const MarkerId("currentLocation"), position: LatLng( currentLocation!.latitude!, currentLocation!.longitude!), ), const Marker( markerId: MarkerId("source"), position: sourceLocation, ), const Marker( markerId: MarkerId("destination"), position: destination, ), }, onMapCreated: (mapController) { _controller.complete(mapController); }, polylines: { Polyline( polylineId: const PolylineId("route"), points: polylineCoordinates, color: const Color(0xFF7B61FF), width: 6, ), }, ),1 نقطة
-
في سؤالك كنت تقول ان المشكلة في تحديد العنصر الذي تريده : واعتقد انني قد اخبرتك بأن الحل الاسهل لمشكلتك هو ان تقوم بإنشاء state مختلفة لكل عنصر وظهرت لديك مشكلة ثانية وهي انك تريد حساب عدد الاعجابات واخبرتك انه يمكنك فعل ذلك بإنشاء state مختلفة لذلك.1 نقطة
-
يمكنك استخدام ذات التابع السابق لعمل ذات الشيء. $targetID = 'YOUR_ID'; $this->assertDatabaseMissing('courses' ,['id' => $targetID]); ليست العملية معقدة كثيرا، نظم ذلك فقط. دورة الحياة داخل تابع الاختبار تكون دائما بشكل او بآخر كـ: مرحلة لتحضير البيانات مرحلة للقيام بأكشن والخروج بنتيجة مرحلة للقيام بالتوكيدات على اشياء او سلوكات معينة1 نقطة
-
عدل الوظيفة handleClick في الملف app.js إلى الشكل التالي : const handleClick = (id) => { let findProd = data.find(item => item.id === id) // التعديل حدث هنا console.log(findProd); // console.log(selectedProduct); setSelectedProduct(findProd.id) setToggleLike(!toggleLike) }1 نقطة
-
تتكوَّن جميع برامج واجهات المُستخدِم الرسومية GUI التي تعرَّضنا إليها حتى الآن من نافذةٍ واحدة، ولكن غالبية البرامج تتكوَّن في الواقع من عدة نوافذ. ولذلك سنناقش في هذا المقال طريقة إدارة التطبيقات متعددة النوافذ؛ كما سنتناول صناديق النافذة dialog boxes، وهي نوافذٌ صغيرة تظهر وتختفي، وتُستخدَم عادةًً للحصول على مُدْخَلٍ من المُستخدِم. بالإضافة إلى ذلك، سنتعرَّض للصنف WebView، وهو أداة تحكُّم بمكتبة JavaFX، وتدعم كثيرًا وظائف متصفح الإنترنت. صناديق النافذة Dialog Boxes أيّ صندوق نافذة هو ببساطة نافذة تعتمد على نافذةٍ أخرى تَعمَل بمثابة أبٍ أو مالكٍ لصندوق النافذة؛ ويَعنِي ذلك، أنه لو أغلقنا النافذة المالكة، فسيُغلَق صندوق النافذة أوتوماتيكيًا. قد يكون صندوق النافذة شرطيًا modal أو غير شرطي modeless؛ فعند فتح صندوق نافذة شرطي، ستُعطَّل النافذة المالكة له، ولا يستطيع المُستخدِم التفاعل معها حتى يُغلِق صندوق النافذة. هناك أيضًا صناديق نافذة شرطية على مستوى التطبيق application modal؛ أي أنها تُعطِّل التطبيق بالكامل وليس فقط النافذة المالكة لها، وتُعدّ غالبية صناديق النافذة بمكتبة JavaFX من هذا النوع. تظهر صناديق النافذة الشرطية عادةً أثناء تنفيذ البرنامج، وذلك لكي تطلُب من المُستخدِم مُدْخَلًا معينًا، أو فقط لعرض رسالةٍ للمُستخدِم أحيانًا. في المقابل، لا تُعطِّل صناديق النافذة غير الشرطية تفاعل المُستخدِم مع نوافذها المالكة، ولكنها ما تزال تُغلَق أوتوماتيكيًا عند غلق النافذة المالكة. يُستخدَم هذا النوع عادةً لإظهار عرضٍ view مختلفٍ للبيانات الموجودة بالنافذة المالكة، أو لعرض أدوات تحكُّم إضافية تؤثر على النافذة المالكة. يُمكِننا ضبط مرحلةٍ من الصنف Stage لتَعمَل مثل صندوق نافذة، ولكن غالبية صناديق النافذة ببرامج JavaFX هي كائناتٌ مُنتمية إلى الصنف Dialog المُعرَّف بحزمة javafx.scene.control، أو أي من أصنافه الفرعية subclasses. فإذا كان dlg كائن صندوق نافذة من النوع Dialog، فسيتضمَّن توابع النسخ instance methods التالية لعرض صندوق النافذة: dlg.show() و dlg.showAndWait(). إذا استخدمنا التابع dlg.showAndWait() لعرض الصندوق، فسيكون صندوق النافذة شرطيًا، حتى على مستوى التطبيق. لا يعود التابع showAndWait() إلى أن يُغلَق صندوق النافذة، وتكون بالتالي قيمة مُدْخَل المُستخدِم متاحةً بعد استدعاء showAndWait() مباشرةً. في المقابل، إذا استخدمنا التابع dlg.show() لعرض الصندوق، فسيكون غير شرطي، إذ يعود التابع show() فورًا، ويستطيع المُستخدِم بالتالي التعامل مع النافذة والصندوق، والتبديل بينهما. وفي الواقع، يتشابه استخدام صناديق النافذة غير الشرطية مع البرمجة على التوازي parallel programming نوعًا ما؛ فهناك شيئان قيد التنفيذ بنفس الوقت. سنُركّز هنا على صناديق النافذة الشرطية فقط. يُعدّ الصنف Dialog<T> نوعًا ذا معاملاتٍ غير مُحدّدة النوع parameterized، إذ يُمثِّل معامل النوع type parameter نوع القيمة التي سيعيدها التابع showAndWait()، والتي تكون تحديدًا من النوع Optional<T>؛ ويَعنِي ذلك أنها قيمةٌ من النوع T، ولكنها قد تكون موجودةً أو غير موجودة. يتضمَّن الصنف Optional -المُعرَّف بحزمة java.util- التابع isPresent()، والذي يُعيد قيمةً من النوع boolean، التي تشير إلى ما إذا كانت القيمة موجودةً أم لا، كما يتضمَّن التابع get() الذي يعيد تلك القيمة إذا كانت موجودة. إذا لم تكن القيمة موجودةً، فسيؤدي استدعاء التابع get() إلى حدوث استثناءٍ exception؛ ويَعنِي ذلك أنه إذا أردنا استخدام القيمة المعادة من التابع showAndWait()، فعلينا أولًا استدعاء التابع isPresent() لنتأكّد من أن التابع قد أعاد قيمةً فعلًا. يحتوي أي صندوق نافذة عادةً على زرٍ واحدٍ أو أكثر لغلق الصندوق على الأقل، وتكون أسماء تلك الأزرار غالبًا هي: "OK" أو "Cancel" أو "Yes" أو "No". كما يُستخدَم نوع التعداد ButtonType لتمثيل الأزرار الأكثر شيوعًا، ويتضمَّن القيم ButtonType.OK و ButtonType.CANCEL و ButtonType.YES و ButtonType.NO؛ إذ يُعدّ النوع ButtonType القيمة المعادة الأكثر شيوعًا من صناديق النافذة المُمثَلة بالصنف Dialog، وذلك للإشارة إلى الزر الذي نقر عليه المُستخدِم لغلق الصندوق، ويكون صندوق النافذة من النوع Dialog<ButtonType> في تلك الحالة. يُعدّ Alert صنفًا فرعيًا من الصنف Dialog<ButtonType>، إذ يُسهِّل إنشاء صناديق النافذة التقليدية التي تَعرِض رسالةً نصيةً للمُستخدِم مصحوبةً بزرٍ واحدٍ أو اثنين، وتَعمَل بمثابة تنبيهٍ للمُستخدِم. كنا قد استخدمنا هذا الصنف فعليًا في مقال مدخل إلى التعامل مع الملفات في جافا لكي نَعرِض بعض رسائل الخطأ للمُستخدِم، ولكن دون أن نتعرَّض لطريقة عمله. يُمكِننا إنشاء كائنٍ من النوع Alert على النحو التالي: Alert alert = new Alert( alertType, message ); ينتمي المعامل الأول إلى نوع التعداد Alert.AlertType الذي يُمكِن لقيمته أن تكون واحدةً مما يلي: Alert.AlertType.INFORMATION. Alert.AlertType.WARNING. Alert.AlertType.ERROR. Alert.AlertType.CONFIRMATION. وعند تخصيص أي من القيم الثلاثة الأولى، سيحتوي الصندوق المعروض على زر "OK" وحيد ولا يفعل أكثر من مجرد عرض رسالةٍ نصيةٍ للمُستخدِم، وفي تلك الحالة، لا حاجة لفحص أو استرجاع القيمة المعادة من التابع alert.showAndWait(). عند تمرير القيمة الأخيرة، سيتضمَّن الصندوق زر "OK" و زر "Cancel"، ويُستخدَم عادةً لسؤال المُستخدِم عما إذا كان يريد الاستمرار بتنفيذ عملية يُحتمَل أن تكون خطيرةً، مثل حذف ملف؛ إذ يكون من الضروري فحص القيمة المعادة من التابع في تلك الحالة، وهذا هو ما تفعله الشيفرة التالية: Alert confirm = new Alert( Alert.AlertType.CONFIRMATION, "Do you really want to delete " + file.getName() ); Optional<ButtonType> response = confirm.showAndWait(); if ( response.isPresent() && response.get() == ButtonType.OK ) { file.delete(); } إضافةً إلى الأزرار، قد يحتوي صندوق النافذة على مساحةٍ للمحتوى وعنوانٍ رئيسي يَظهَر أعلى تلك المساحة، ورسمةٍ تَظهَر إلى جانب العنوان الرئيسي إن وجد، أو إلى جانب المحتوى؛ وبالتأكيد عنوان يَظهَر بشريط عنوان صندوق النافذة. تكون الرسمة عادةً أيقونةً صغيرةً؛ فبالنسبة لصناديق النافذة من النوع Alert، تُوضَع الرسالة بمساحة المحتوى، وتُضبَط الخاصيات الأخرى أوتوماتيكيًا بما يتناسب مع نوع التنبيه المُستخدَم، ويُمكِن مع ذلك تعديلها باستدعاء التوابع التالية المُعرَّفة بالصنف Dialog قبل عرض التنبيه: alert.setTitle( windowTitle ); alert.setGraphic( node ); alert.setHeaderText( headerText ); يُمكِن لأي من تلك القيم المُمرَّرة أن تكون قيمةً فارغةً null، كما يُمكِننا ضبط المحتوى إلى أي عقدة مبيان مشهد عشوائية لتحلّ محل الرسالة النصية، وذلك باستدعاء التابع التالي: alert.getDialogPane().setContent( node ); ولكننا نُطبِّق ذلك عادةً على صندوق نافذة عادي من النوع Dialog، لا على تنبيهٍ من النوع Alert. تُوضِّح تنبيهات التأكيد بالصورة التالية المكونات المختلفة الموجودة بأي صندوق نافذة، إذ يُمكِّننا ملاحِظة أن العنوان الرئيسي الخاص بالصندوق الموجود على يمين الصورة فارغ؛ وإذا أردت عَرض نصٍ متعدد الأسطر ضمن تنبيه، فلا بُدّ من إضافة محرف السطر الجديد ("\n") إلى النص: تُوفِّرمكتبة JavaFX الصنف الفرعي TextInputDialog المُشتَق من الصنف Dialog<String> لتمثيل صناديق النافذة التي تقرأ مُدْخَلًا من المُستخدِم. ويعيد التابع showAndWait() في تلك الحالة قيمةً من النوع Optional<String>، كما يحتوي صندوق النافذة المُستخدِم لذلك الصنف على حقلٍ نصي من النوع TextField، مما يُمكِّن المُستخدِم من إدخال سطر نصي؛ كذلك، يحتوي على زر "OK" و زر "Cancel". يَستقبِل الباني معاملًا من النوع String، والذي يُمثِّل المحتوى المبدئي للحقل النصي؛ فإذا أردنا أن نطرح سؤالًا على المُستخدِم أو أن نَعرِض رسالةً معينةً عليه، فيُمكِننا وضعها بالعنوان الرئيسي للصندوق. يعيد صندوق النافذة محتويات الحقل النصي إن وُجدت، والتي قد تكون مجرد سلسلةٍ نصيةٍ فارغة، وإذا نقر المُستخدِم على زر "Cancel" أو أغلق صندوق النافذة ببساطة، فستكون القيمة المعادة غير موجودة. ألقِ نظرةً على الشيفرة التالية: TextInputDialog getNameDialog = new TextInputBox("Fred"); getNameDialog.setHeaderText("Please enter your name."); Optional<String> response = getNameDialog.showAndWait(); if (response.isPresent() && response.get().trim().length() > 0) { name = response.get().trim(); } else { Alert error = new Alert( Alert.AlertType.ERROR, "Anonymous users are not allowed!" ); error.showAndWait(); System.exit(1): } إلى جانب الصنفين Alert و TextInputDialog، يُعرِّف الصنف SimpleDialogs.java -كتبه المؤلف- التوابع الساكنة static التالية التي يُمكِن استخدامها لعرض أكثر أنواع صناديق النافذة شيوعًا: SimpleDialogs.message(text): يعرِض صندوق نافذة يحتوي على رسالة نصية وزر"OK"، ولا يًعيد أي قيمة. لاحِظ أنك لا تحتاج إلى كتابة محرف السطر الجديد ضمن الرسالة إذا أردتها أن تكون متعددة الأسطر؛ لأن ذلك يحدث تلقائيًا مع الرسائل الطويلة. يَستقبِل التابع أيضًا معاملًا ثانيًا اختياريًا لتخصيص عنوان صندوق النافذة. SimpleDialogs.prompt(text): يعرِض صندوق نافذة يحتوي على رسالة نصية وحقل إدخال نصي مع زر "OK" و زر "Cancel"، ويُعيد قيمةً من النوع String تُمثِّل محتويات الحقل النصي إذا كان المُستخدِم قد نقر على "OK"؛ أو على القيمة الفارغة null إذا كان المُستخدِم قد أغلق الصندوق. يَستقبِل التابع أيضًا معاملين اختياريين آخرين لتخصيص عنوان صندوق النافذة، والمحتوى المبدئي للحقل النصي على الترتيب. SimpleDialogs.confirm(text): يَعرِض صندوق نافذة يحتوي على رسالة نصية مع زر "Yes" و زر "No" و زر "Cancel"، ويُعيد قيمةً من النوع String، والتي لا بُدّ أن تكون واحدةً من القيم التالية "yes" أو "no" أو "cancel". يَستقبِل التابع معاملًا ثانيًا اختياريًا لتخصيص عنوان صندوق النافذة مثل التابعين السابقين. يحتوي الصنف SimpleDialogs على بعض الخيارات الأخرى، مثل صندوق نافذة بسيط لاختيار لون، والتي يُمكِنك الإطلاع عليها بقراءة شيفرة الملف SimpleDialogs.java؛ كما يَسمَح أيضًا البرنامج TestDialogs.java للمُستخدِم بتجربة صناديق النافذة المختلفة المُعرَّفة بالصنف SimpleDialogs. الصنفان WebView و WebEngine سنناقش ببقية هذا المقال برنامج متصفح إنترنت مُتعدّد النوافذ، إذ تبدو كتابة متصفح إنترنت عمليةً معقدة، وهي كذلك فعلًا، ولكن مكتبة JavaFX تُسهِّل ذلك كثيرًا بتنفيذ غالبية العمل المطلوب ضمن مجموعةٍ من الأصناف القياسية؛ إذ يُمثِّل الصنف WebView المُعرَّف بحزمة javafx.scene.control أداة تحكُّم يُمكِنها تحميل صفحة إنترنت وعرضها، كما تستطيع تلك الأداة معالجة معظم صفحات الإنترنت جيدًا بما في ذلك تنفيذ شيفرة JavaScript، إذ تُستخدَم لغة البرمجة جافاسكربت JavaScript لبرمجة صفحات إنترنت ديناميكية، ولا علاقة لها بلغة Java). إضافةً لما سبق، يُمثِّل الصنف WebView العرض view ضمن نمط نموذج-عرض-مُتحكَّم Model-View-Controller الذي ناقشناه بمقال أمثلة عن رسوميات فاخرة باستعمال جافا؛ إذ يُنفَّذ غالبية العمل المطلوب لتحميل صفحات الإنترنت وإدارتها من خلال كائنٍ من النوع WebEngine، والذي يُمثِّل جزءًا من المُتحكِّم controller؛ أما النموذج، فهو هيكل بياني data structure يتضمَّن محتويات صفحة الإنترنت. ويُنشِئ الصنف WebEngine النموذج عند تحميل الصفحة، ثم يَعرِض الصنف WebView محتوياتها. يجب أن نَعرِض كائن الصنف WebView ضمن نافذة؛ إذ يُمثِّل الصنف الفرعي BrowserWindow.java -المُشتَق من صنف النافذة القياسي Stage- نافذة متصفح إنترنت كاملة، وهو يحتوي على كائنٍ ينتمي إلى الصنف WebView، بالإضافة إلى شريط قوائم وبعض أدوات التحكُّم الأخرى، مثل صندوق إدخال نصي يُمكِّن المُستخدِم من كتابة محدّد موارد مُوحد URL لصفحة إنترنت معينة، وزر "Load" ينقر عليه المُستخدِم لتحميل صفحة الإنترنت من محدّد الموارد الموحد إلى كائن الصنف WebView. بالإضافة إلى ذلك، يُمكِن لباني الصنف BrowserWindow أن يَستقبِل معاملًا إضافيًا لتخصيص محدِّد موارد موحد مبدئي يُحمَّل تلقائيًا عند فتح النافذة. يتضمَّن كل كائن من النوع WebView كائنًا آخرًا من النوع WebEngine، والذي يُمكِننا استرجاعه باستدعاء التابع webEngine = webview.getEngine(). يمكننا الآن تحميل load صفحة إنترنت باستدعاء ما يلي: webEngine.load( urlString ); إذ أن urlString سلسلةٌ نصيةٌ تُمثِّل محدِّد موارد موحد -ألقِ نظرةً على مقال تواصل تطبيقات جافا عبر الشبكة-. ويجب أن تبدأ تلك السلسلة ببروتوكول، مثل "http:" أو "https:"، ولهذا يضيف البرنامج كلمة "http://" إلى مقدمة السلسلة النصية المُتضمِّنة لمُحدّد الموارد الموحد، إذا لم تكن تحتوي على بروتوكول فعليًا. إضافةً لما سبق، يُحمِّل البرنامج صفحة إنترنت جديدة أوتوماتيكيًا، وذلك إذا نقر المُستخدِم على رابط ضمن الصفحة المعروضة حاليًا. تُحمَّل صفحات الإنترنت بصورةٍ غير متزامنة asynchronous؛ أي أن التابع webEngine.load() يعود فورًا، في حين تُحمَّل صفحة الإنترنت ضمن خيط thread مُشغَّلٍ بالخلفية. وعند اكتمال عملية التحميل، تُعرَض صفحة الإنترنت داخل كائن الصنف WebView؛ أما في حالة فشل التحميل لسببٍ ما، فلا يحدث أي تنبيه تلقائي، ولكن ما يزال بإمكاننا الحصول على بعض المعلومات بإضافة مستمعي أحداث إلى الخاصيتين location و title -من النوع String- القابلتين للمراقبة observable، والمُعرَّفتين بالصنف WebEngine؛ إذ تُمثِّل الخاصية location محدد الموارد الموحد لصفحة الإنترنت المعروضة حاليًا، أو التي يُجرَى تحميلها؛ بينما تُمثِّل الخاصية title عنوان صفحة الإنترنت الحالية، والتي تَظهَر بشريط عنوان النافذة التي تَعرِض صفحة الإنترنت. على سبيل المثال، يستمع الصنف BrowserWindow إلى الخاصية title ويَضبُط عنوان النافذة ليتوافق مع محتوياتها كما يلي: webEngine.titleProperty().addListener( (o,oldVal,newVal) -> { if (newVal == null) setTitle("Untitled " + owner.getNextUntitledCount()); else setTitle(newVal); }); يستمع البرنامج إلى الخاصية location أيضًا، ويَعرِض قيمتها داخل عنوان من النوع Label أسفل النافذة؛ في حين سنناقش owner لاحقًا بالأسفل. يُمكِننا أيضًا أن نضيف مستمعًا إلى خاصية webEngine.getLoadWorker().stateProperty() لمراقبة تقدّم تحميل الصفحة. ألقِ نظرةً على شيفرة الصنف BrowserWindow.java لترى مثالًا على ذلك. ذكرنا بالأعلى أن كائن الصنف WebView (مع كائن الصنف WebEngine الخاص به) قادرٌ على تشغيل شيفرة JavaScript المُضمَّنة بصفحات الإنترنت؛ ولكن هذا ليس دقيقًا تمامًا، إذ تحتوي JavaScript على بعض البرامج الفرعية subroutines المسؤولة عن إظهار صناديق نوافذ بسيطة؛ فعلى سبيل المثال، يَعرِض صندوق النافذة من النوع "alert" رسالةً نصيةً للمُستخدِم؛ بينما يطرح صندوق النافذة من النوع "prompt" سؤالًا على المُستخدِم ويتلقى ردَّه النصي عليها؛ أما صندوق النافذة من النوع "confirm"، فيَعرِض رسالةً للمُستخدِم مع زر "OK" و زر "Cancel"، ويتلقى قيمةً مُعادةً من النوع boolean تشير إلى ما إذا كان المُستخدِم قد أغلق الصندوق بالنقر على زر "OK". في الواقع، يتجاهل الصنف WebEngine طلبات JavaScript لعرض تلك الصناديق افتراضيًا، ولكن يُمكِننا إضافة مستمعي أحداث للاستجابة إلى تلك الطلبات، إذ يَستخدِم الصنف BrowserWindow صناديق نافذة من تلك المُعرَّفة بالصنف SimpleDialogs للرد على تلك الأحداث؛ فعندما تحاول JavaScript أن تَعرِض صندوق نافذة تنبيهي مثلًا، فسيُولِّد كائن الصنف WebEngine حدثًا من النوع AlertEvent، يتضمَّن الرسالة التي تريد JavaScript أن تَعرِضها، وسيستجيب الصنف BrowserWindow باستدعاء SimpleDialogs.message() لكي يَعرِض الرسالة للمُستخدِم. ألقِ نظرةً على الشيفرة التالية: webEngine.setOnAlert( evt -> SimpleDialogs.message(evt.getData(), "Alert from web page") ); تختلف معالجة صناديق النافذة من النوعين prompt و confirm بعض الشيء؛ لكونهما يعيدان قيمةً، وتبين الشيفرة التالية الطريقة التي اتبعها البرنامج لمعالجتهما: webEngine.setPromptHandler( promptData -> SimpleDialogs.prompt( promptData.getMessage(), "Query from web page", promptData.getDefaultValue() ) ); webEngine.setConfirmHandler( str -> SimpleDialogs.confirm(str, "Confirmation Needed").equals("yes") ); لم نناقش بعد شريط القوائم الذي يدعمه الصنف BrowserWindow؛ إذ يحتوي ذلك الشريط على قائمةٍ واحدةٍ اسمها "Window"، وتحتوي بدورها على مجموعةٍ من الأوامر لأغراضٍ معينة، مثل فتح نافذة متصفح جديدة أو غلْق النافذة الحالية، كما تحتوي على قائمةٍ مكوّنة من نوافذ المتصفح المفتوحة حاليًا، ويستطيع المُستخدِم أن يختار أيًا منها ليُحضره إلى مقدمة الشاشة؛ ولكي تفهم طريقة تنفيذ ذلك، عليك أولًا أن تفهم طريقة استخدام الصنف BrowserWindow ضمن برنامجٍ متُعدّد النوافذ. إدارة عدة نوافذ لا يُعدّ الصنف BrowserWindow تطبيقًا، أي لا يُمكِن تشغيله على أنه برنامجٌ بحد ذاته، وإنما يُمثِّل نافذةً واحدةً ضمن برنامجٍ متعدد النوافذ. ستجِد النسخة القابلة للتشغيل من هذا الصنف بالملف WebBrowser.java. يمتد الصنف WebBrowser من الصنف Application مثل أي برنامجٍ مُصمَّم بمكتبة JavaFX، كما يعتمد على الصنفين BrowserWindow.java و SimpleDialogs.java؛ ولذلك ستحتاج إلى الأصناف الثلاثة لكي تتمكَّن من تشغيل البرنامج. يحتوي أي صنفٍ ينتمي للنوع Application على التابع start() الذي يستدعيه النظام عند بدء تشغيل التطبيق، إذ يستقبل هذا التابع معاملًا من النوع Stage يُخصِّص النافذة الرئيسية للبرنامج، ولكن ليس من الضروري للبرنامج أن يَستخدِم تلك النافذة فعليًا؛ إذ يتجاهل التابع start() المُعرَّف بالصنف WebBrowser النافذة الرئيسية المُخصَّصة، ويُنشِئ بدلًا منها نافذةً من النوع BrowserWindow، لتكون هي أول ما يَظهَر عند تشغيل البرنامج، وقد ضُبطَت تلك النافذة لتُحمِّل الصفحة الرئيسية لصفحة الإنترنت التي تحتوي على النسخة المُتاحة عبر الإنترنت من هذا الكتاب. يُمثِّل ما سبق كل ما ينبغي أن يفعله الصنف WebBrowser.java لتنفيذ البرنامج باستثناء القائمة "Window" التي تحتوي على قائمةٍ بكل النوافذ المفتوحة. ونظرًا لعدم كون تلك القائمة جزءًا من بيانات أي نافذة على حدة، فقد كان من الضروري الاحتفاظ بها بمكانٍ آخر، مثل كائن الصنف WebBrowser، ويمكن بدلًا من ذلك تخزين قائمة النوافذ مثل متغير عضو ساكن static بالصنف BrowserWindow، مما يَعنِي تشاركُه بين جميع النسخ المنشأة من ذلك الصنف؛ إذ يُعرِّف الصنف WebBrowser تابعه newBrowserWindow() بغرض فتح نافذةٍ جديدة؛ بينما يحتوي الصنف BrowserWindow على متغير النسخة owner للإشارة إلى كائن الصنف WebBrowser الذي فتح النافذة. وبالتالي إذا أراد البرنامج فتح نافذة جديدة، فإنه يفعل ذلك باستدعاء التابع owner.newBrowserWindow(url)، إذ يشير المعامل url إلى مُحدِّد الموارد الموحد الخاص بصفحة الإنترنت المطلوب تحميلها بالنافذة الجديدة. وفي المقابل، قد يحتوي المعامل على القيمة الفارغة null بهدف فتح نافذة متصفحٍ فارغة. يتحدََد حجم أي نافذة بمكتبة JavaFX وفقًا لحجم المشهد -من النوع Scene- المعروض داخلها افتراضيًا، كما تظهر النافذة بمنتصف الشاشة افتراضيًا؛ ويُمكِننا بدلًا من ذلك ضبط حجم النافذة ومكانها قبل فتحها. بالنسبة للبرامج متعددة النوافذ، لا يُحبَّذ عرض جميع النوافذ بنفس المكان بالضبط، كما يبدو أن الحجم الافتراضي لكائنات الصنف BrowserWindow صغيرٌ جدًا بمعظم شاشات الحاسوب؛ ولذلك يَضبُط التطبيق WebBrowser موضع جميع النوافذ التي يفتحها ليَبعُد مكان كل نافذةٍ مسافةً قصيرةً عن مكان النافذة التي فتحها التطبيق بالمرة السابقة؛ كما يضبُط التطبيق حجم النافذة بما يتناسب مع حجم الشاشة. يحتوي الصنف Screen المُعرَّف بحزمة javafx.stage على التابع الساكن Screen.getPrimary() الذي يعيد كائنًا يتضمَّن معلوماتٍ عن الشاشة الرئيسية للحاسوب؛ كما ويحتوي ذلك الكائن بدوره على كل من التابعين Screen.getPrimary().getVisualBounds()، الذي يُعيد كائنًا من النوع Rectangle2D ويُمثِّل المساحة القابلة للاستخدام من الشاشة الرئيسية. يَستدعِي تابع البرنامج start() ذلك التابع لكي يَحسِب كُلًا من حجم أول نافذة ومكانها على النحو التالي: public void start(Stage stage) { // (stage is not used) openWindows = new ArrayList<BrowserWindow>(); // List of open windows. screenRect = Screen.getPrimary().getVisualBounds(); // 1 locationX = screenRect.getMinX() + 30; locationY = screenRect.getMinY() + 20; // 2 windowHeight = screenRect.getHeight() - 160; windowWidth = screenRect.getWidth() - 130; if (windowWidth > windowHeight*1.6) windowWidth = windowHeight*1.6; // 3 newBrowserWindow("http://math.hws.edu/javanotes/index.html"); } // end start() حيث أن: [1]: يشير (locationX,locationY) إلى مكان الركن الأيسر العلوي للنافذة التي ستُفتَح بالمرة القادمة، وتتحرك النافذة الأولى إلى الأسفل قليلًا من الركن الأيسر العلوي للجوانب المرئية للشاشة الرئيسية. [2]: تعني أن حجم النافذة يعتمد على طول وعرض جوانب الشاشة المرئية بما يَسمَح ببعض المسافات الإضافية حتى يكون من الممكن وضع النوافذ فوق بعضها، مع إزاحة كل واحدة عن سابقتها قليلًا. قيِّد العرض ليكون على الأكثر 1.6 مرة من الطول لأسباب جمالية. [3]: تعني افتح النافذة الأولى لتَعرِض الصفحة الأمامية للكتاب. عندما يَفتح التابع newBrowserWindow() نافذةً جديدةً، سيعتمد حجمها ومكانها على قيم المتغيرات windowWidth و windowHeight و locationX و locationY؛ ولهذا ينبغي أن نُعدِّل قيم المتغيرين locationX و locationY لكي تَظهَر النافذة التالية بمكانٍ مختلف؛ وعلينا أيضًا أن نضيف النافذة الجديدة إلى قائمة النوافذ المفتوحة؛ كما ينبغي أن نتأكَّد من حذف النافذة من القائمة عند غلقها. لحسن الحظ، تُولِّد أي نافذة حدثًا عند غلقها، وبالتالي يُمكِننا أن نُضيف مستمعًا إلى ذلك الحدث، ليَحذِف معالج الحدث النافذة من قائمة النوافذ المفتوحة. ألقِ نظرةً على شيفرة التابع newBrowserWindow(): void newBrowserWindow(String url) { BrowserWindow window = new BrowserWindow(this,url); openWindows.add(window); // Add new window to open window list. window.setOnHidden( e -> { // 1 openWindows.remove( window ); System.out.println("Number of open windows is " + openWindows.size()); if (openWindows.size() == 0) { // 2 System.out.println("Program ends because all windows are closed"); } }); if (url == null) { window.setTitle("Untitled " + getNextUntitledCount()); } window.setX(locationX); // set location and size of the window window.setY(locationY); window.setWidth(windowWidth); window.setHeight(windowHeight); window.show(); locationX += 30; // set up location for NEXT window locationY += 20; if (locationX + windowWidth + 10 > screenRect.getMaxX()) { // 3 locationX = screenRect.getMinX() + 30; } if (locationY + windowHeight + 10 > screenRect.getMaxY()) { // 4 locationY = screenRect.getMinY() + 20; } } وتعني كل من: [1]: يُستدعَى عند غلق النافذة، وذلك ليحذف النافذة من قائمة النوافذ المفتوحة. [2]: ينتهي البرنامج أوتوماتيكيًا عند غلق جميع النوافذ. [3]: إذا كانت النافذة ستمتد إلى ما بعد طرف الشاشة الأيمن، فأعِد ضبط المتغير locationX إلى قيمته الأصلية. [4]: إذا كانت النافذة ستمتد إلى ما بعد طرف الشاشة السفلي، فأعِد ضبط المتغير locationY إلى قيمته الأصلية. يتضمَّن الصنف WebBrowser التابع getOpenWindowList()، والذي يعيد قائمةً بكل النوافذ المفتوحة؛ إذ يستدعِي كائن الصنف BrowserWindow ذلك التابع أثناء إنشائه للقائمة "Window". وفي الواقع، لا يحدث ذلك بأفضل كفاءةٍ ممكنة، ويُعاد بناء القائمة بكل مرة تُعرَض خلالها؛ إذ تُولِّد القائمة حدثًا عندما ينقُر المُستخدِم على اسم القائمة، وأيضًا قبل ظهورها مباشرةً. يُسجِّل الصنف BrowserWindow مستمعًا إلى ذلك الحدث، ويسترجِع معالج هذا الحدث قائمة النوافذ المفتوحة باستدعاء التابع owner.getOpenWindowList()، ويَستخدِمها لإعادة بناء القائمة قبل أن يُظهِرها على الشاشة. ألقِ نظرةً على شيفرة التابع المُعرَّف بالصنف BrowserWindow: private void populateWindowMenu() { ArrayList<BrowserWindow> windows = owner.getOpenWindowList(); while (windowMenu.getItems().size() > 4) { // 1 windowMenu.getItems().remove(windowMenu.getItems().size() - 1); } if (windows.size() > 1) { // 2 MenuItem item = new MenuItem("Close All and Exit"); item.setOnAction( e -> Platform.exit() ); windowMenu.getItems().add(item); windowMenu.getItems().add( new SeparatorMenuItem() ); } for (BrowserWindow window : windows) { String title = window.getTitle(); // Menu item text is the window title. if (title.length() > 60) { // 3 title = title.substring(0,57) + ". . ."; } MenuItem item = new MenuItem(title); final BrowserWindow win = window; // (for use in a lambda expression) // 4 item.setOnAction( e -> win.requestFocus() ); windowMenu.getItems().add(item); if (window == this) { // 5 item.setDisable(true); } } } حيث تعني كل من: [1]: تتكون القائمة من 4 عناصر دائمة. اِحذِف العناصر الأخرى المقابلة للنوافذ المفتوحة التي تُركت من آخر مرة عُرِضَت خلالها القائمة. [2]: أضف الأمر "Close All" فقط إذا لم تكن تلك هي النافذة الوحيدة. [3]: لا تَستخدِم نصوصًا طويلةً جدًا لعناصر القائمة. [4]: سيُحضِر معالج الحدث لعنصر القائمة ذاك النافذة المقابلة إلى المقدمة باستدعاء التابع requestFocus() الخاص بها. [5]: نظرًا لأن النافذة موجودة بالمقدمة فعليًا، فعطِّل العنصر المقابل لتلك النافذة. كما ترى، ليس من الصعب إدارة تطبيق متعدد النوافذ، كما أنه من السهل كتابة متصفح إنترنت بوظائف معقولة نوعًا ما باستخدام مكتبة JavaFX. كان هذا مثالًا جيدًا على استخدام أصناف موجودة مثل قاعدة نبني عليها أصنافًا أخرى. رأينا أيضًا أمثلةً جديدةً جيدةً للتعامل مع الأحداث، وبذلك نكون قد وصلنا تقريبًا إلى نهاية هذه السلسلة. سنناقش في المقال الأخير بعض الأشياء المتعلقة ببرمجة واجهات المُستخدِم الرسومية. ترجمة -بتصرّف- للقسم Section 4: Mostly Windows and Dialogs من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مكونات الواجهة المركبة ونمط MVC في جافا واجهة المستخدم الحديثة في جافا مدخل إلى التعامل مع الملفات في جافا1 نقطة
-
تتضمَّن واجهة تطوير التطبيقات JavaFX تعقيداتٍ أكبر بكثير مما درسناه إلى الآن، ولكن كل هذا التعقيد يعمل لصالح المبرمج عمومًا؛ فهو على الأغلب يكون مخفيًا بالاستخدامات الأكثر شيوعًا لمكتبة JavaFX، أي أنك لست مضطّرًا إلى معرفة تفاصيل أدوات التحكُّم الأكثر تعقيدًا لكي تتمكَّن من استخدامها بفعالية بغالبية البرامج. تُعرِّف مكتبة JavaFX مجموعةً من الأصناف التي تُمثِّل مكونات أكثر تعقيدًا بكثير من تلك التي رأيناها، ولكن حتى أكثر تلك المكونات تعقيدًا ليس صعب الاستخدام في غالبية الأحوال. سنناقش خلال هذا المقال بعض مكونات الواجهة التي تدعم عرض القوائم والجداول ومعالجتها؛ ولكي تتمكَّن من استخدام تلك المكونات المعقدة بفعالية، عليك أن تتعلم القليل عن نمط "نموذج-عرض-متحكِّم Model-View-Controller"، والذي يُعد أساس لكثيرٍ من مكونات واجهة المُستخدِم الرسومية. سنناقش هذا النمط لاحقًا ضمن هذا المقال. تُوفِّر مكتبة JavaFX عددًا من أدوات التحكُّم التي لن نتعرَّض لها بهذا الكتاب -على الرغم من أن بعضها مفيدٌ نوعًا ما وقد ترغب بالإطلاع عليه-، مثل TabbedPane و SplitPane و Tree و ProgressBar، وكذلك بعض أدوات التحكُّم لقراءة أنواع خاصة من المُدْخَلات، مثل ColorPicker و DatePicker و PasswordField و Spinner. سنبدأ هذا المقال بمثالٍ قصير على كتابة أداة تحكُّم مُخصَّصة، وهو أمرٌ ستحتاج إليه إذا لم تَجِد المُكوِّن الذي تريده ضمن التشكيلة الضخمة من مكونات الواجهة المُعرَّفة مُسبقًا بمكتبة JavaFX، أو من الممكن أن تجده فعلًا ولكنه معقدٌ جدًا بالموازنة مع متطلبات برنامجك، وقد ترغب مثلًا بشيء أبسط. مكون واجهة مخصص بسيط ستَجِد عادةً كل ما تحتاجه لإنشاء واجهة مُستخدِم رسومية بأصناف المكونات القياسية الموجودة بمكتبة JavaFX، ومع ذلك قد ترغب أحيانًا بشيءٍ مختلفٍ بعض الشيء. في تلك الحالة، قد تفكر بكتابة مُكوِّنك الخاص بالاعتماد على واحدة من المكونات التي تُوفِّرها المكتبة، أو بالاعتماد على الصنف البسيط Control الذي يَعمَل صنفًا أساسيًا base class لجميع أدوات التحكُّم. لنفترض مثلًا أننا نريد أداة تحكُّم تمثِّل "ساعة إيقاف"؛ فعندما ينقُر المُستخدِم على الساعة، ينبغي أن تُشغِّل الوقت؛ وعندما ينقر المُستخدِم عليها مرةً أخرى، ينبغي أن تَعرِض الزمن المُنقضِي منذ النقرة الأولى. يُمكِننا استخدام الصنف Label لعرض النص، ولكننا نريده أن يكون قادرًا على الاستجابة إلى حدث النقر على الفأرة، ويُمكِننا تحقيق ذلك بتعريف صنف مُكوِّن واجهة، وليكن اسمه هو StopWatchLabel صنفًا فرعيًا subclass مُشتقًا من الصنف Label، إذ سيَستمِع كائن الصنف StopWatchLabel لحدث نقر الفأرة عليه، ويُغيّر النص المعروض إلى "Timing…" عندما ينقر المُستخدِم عليه لأول مرة، كما سيتذكّر توقيت نقر المُستخدِم عليه. وعندما ينقر المُستخدِم عليه مرةً أخرى، سيفحص التوقيت مرةً أخرى، وسيحسِب الزمن المُنقضِي ويعرضه. في الواقع، لسنا في حاجة بالضرورة لتعريف صنفٍ فرعي جديد، إذ يمكننا استخدام عنوانٍ عادي بالبرنامج، وتهيئة مُستمعٍ ليَستجيب لحدث النقر على العنوان، ونَسمَح للبرنامج بإنجاز العمل اللازم للاحتفاظ بالزمن وتعديل النص المعروض بالعنوان. ومع ذلك، فبكتابة صنف جديد، سنتمكَّن من إعادة استخدامه بمشروعات أخرى، كما أن كل الشيفرة المُتعلقة بساعة الإيقاف مُجمعّةٌ معًا بمكانٍ واحد. يكون ذلك أكثر أهميةً عند التعامل مع المكونات الأكثر تعقيدًا. ليست كتابة الصنف StopWatchLabel بهذه الصعوبة، إذ سنحتاج إلى متغير نسخة instance variable لتخزين توقيت بدء تشغيل ساعة الإيقاف، وسنُهيئ تابعًا لمعالجة حدث النقر على ساعة الإيقاف. ينبغي أن يُحدِّد ذلك التابع ما إذا كانت ساعة الإيقاف مُشغَّلةً أم مُتوقفِة؛ ولهذا سنحتاج إلى متغير نسخة من النوع boolean، وليكن اسمه هو running للاحتفاظ بهذا الجانب من حالة المكوّن؛ كما سنَستخدِم التابع System.currentTimeMillis() للحصول على التوقيت الحالي بوحدة الميلي ثانية مثل قيمةٍ من النوع long. عند بدء تشغيل ساعة الإيقاف، سنخزِّن التوقيت الحالي بمتغير نسخة اسمه startTime؛ وعند إيقافها، سنَستخدِم التوقيت الحالي لحساب الزمن المنقضِي الذي ظلت خلاله ساعة الإيقاف قيد التشغيل. ألقِ نظرةً على شيفرة الصنف StopWatch: import javafx.scene.control.Label; // 1 public class StopWatchLabel extends Label { private long startTime; // Start time of timer. // (Time is measured in milliseconds.) private boolean running; // True when the timer is running. // 2 public StopWatchLabel() { super(" Click to start timer. "); setOnMousePressed( e -> setRunning( !running ) ); } // 3 public boolean isRunning() { return running; } // 4 public void setRunning( boolean running ) { if (this.running == running) return; this.running = running; if (running == true) { // Record the time and start the timer. startTime = System.currentTimeMillis(); setText("Timing...."); } else { // 5 long endTime = System.currentTimeMillis(); double seconds = (endTime - startTime) / 1000.0; setText( String.format("Time: %1.3f seconds", seconds) ); } } } // end StopWatchLabel حيث تشير كل من: [1] إلى مكوِّن واجهة مُخصَّص يمثِّل ساعة إيقاف بسيطة، فعندما ينقر المُستخدِم عليه، سيبدأ المؤقت بالعمل؛ وعندما ينقر المُستخدِم عليه مجددًا، يَعرِض الزمن بين النقرتين. يؤدي النقر لمرة ثالثة إلى بدء المؤقت من جديد، وهكذا. وبينما يكون المؤقت قيد التشغيل، يَعرِض العنوان الرسالة النصية "Timing…." فقط. [2] إلى أنه يضبط الباني النص المبدئي للعنوان إلى "Click to start timer"، ويُهيئ معالجًا لحدث النقر على الفأرة لكي يتمكَّن العنوان من الاستجابة إلى نقرات الفأرة. [3] يُشير إلى ما إذا المؤقت قيد التشغيل حاليًا. [4] أن المؤقت يُضبط ليَعمَل أو ليتوقف، ويُعدِّل النص المعروض بالعنوان، إذ ينبغي أن يُستدعى هذا التابع ضمن خيط تطبيق مكتبة JavaFX. يُحدِّد المعامل running ما إذا كان ينبغي أن يكون المؤقت قيد التشغيل؛ وإذا كانت قيمة المعامل تساوي حالته الحالية، لا يحدث أي شيء. [5] أنه قد أوقِف المؤقت، واحسب الزمن المنقضِي منذ لحظة بدء المؤقت واعرضه. نظرًا لأن الصنف StopWatchLabel هو صنفٌ فرعيٌ من الصنف Label، يُمكِننا تطبيق أيٍّ مما يُمكِننا فعله بكائنات الصنف Label على كائنات هذا الصنف؛ إذ يُمكِننا مثلًا إضافته إلى حاوية، أو أن نضبُط نوع الخط المُستخدَم، أو لونه، أو حجمه الأقصى، أو المُفضَّل، أو أن نضبُط تنسيق CSS الخاص به؛ كما يُمكِننا أيضًا أن نضبُط النص المعروض بداخله، مع أن ذلك يتعارض مع وظيفة ساعة الإيقاف. لاحِظ أن الصنف StopWatchLabel.java ليس تطبيقًا، ولا يمكن تشغيله بمفرده. يَستخدِم البرنامج القصير TestStopWatch.java كائنًا من ذلك الصنف، ويَضبُط مجموعةً من خاصياته لتحسين مظهره. نمط MVC يُعدّ تقسيم المسؤوليات والمهام واحدًا من أهم مبادئ التصميم كائني التوجه object-oriented design؛ إذ ينبغي أن يكون لكل كائن دورًا وحيدًا محدّدًا بوضوح ومُقيدًّا بمسؤولية معينة؛ ويُعدّ نمط نموذج-عرض-مُتحكِّم Model-View-Controller -أو اختصارًا MVC- تطبيقًا جيدًا لهذا المبدأ على تصميم واجهات المُستخدِم الرسومية، إذ يشير كُل من النموذج والعرض والمُتحكِّم، إلى واحدةٍ من المسؤوليات الثلاث الضرورية لتصميم واجهات المُستخدِم الرسومية. إذا طبقنا نمط MVC على مكون، فسيتكوَّن النموذج من البيانات المُمثِّلة للحالة الحالية للمكوِّن؛ أما العرض فسيكون ببساطةٍ هو التمثيل المرئي للمكون على الشاشة؛ بينما سيشير المُتحكِّم إلى ذلك الجزء من المكون المسؤول عن فعل ما هو ضروري نتيجةً للأحداث الصادرة عن أفعال المُستخدِم، أو عن مصادر أخرى مثل المؤقتات. يُمكِن تلخيص فكرة ذلك النمط في إسناد مسؤولية كلٍ من النموذج والعرض والمُتحكِّم إلى كائناتٍ مختلفة. من السهل فهم دور العرض view بنمط MVC، وهو يُمثَّل عادةً باستخدام كائن المكوِّن ذاته، وتتلخص مسؤوليته في رسم المكون على الشاشة؛ ولكي يتمكَّن من إنجاز ذلك، فإنه يعتمد على النموذج، وذلك لاحتواءه على حالة المكوِّن الحالية التي تؤثر بلا شك على طريقة عرض المكوِّن على الشاشة. نظرًا لأن بيانات النموذج مُخزَّنةٌ بكائنٍ منفصل طبقًا لما ينص عليه نمط MVC، فينبغي للكائن المُمثِّل للمكوِّن الاحتفاظ بمرجع reference إلى الكائن المُمثِّل للنموذج. وعندما يتغير ذلك الكائن، تكون إعادة رسم العرض ضروريةً في العادة، وذلك لتعكس الحالة الجديدة، وبالتالي يحتاج المكوِّن إلى طريقةٍ لتحديد توقيت حدوث مثل تلك التغييرات؛ وهو ما يُمكِن تحقيقه بالاستعانة بالأحداث events ومستمعي الأحداث. يُهيَأ كائن النموذج، فيُولِّد أحداثًا عند تغيُّر البيانات، ويُسجِّل كائن العرض نفسه مستمعًا لتلك الأحداث؛ وعندما يتغير النموذج، يقع حدث، ويُبلَّغ العرض بوقوعه؛ وبالتالي يكون بإمكانه الاستجابة بتحديث محتويات المكوِّن على الشاشة. عند استخدام نمط MVC مع مكونات مكتبة JavaFX، لا يكون المُتحكِّم مُنفصلًا بوضوح عن كلٍ من العرض والنموذج، إذ تُوزَّع وظيفته عادةً بين عدة كائنات. في العموم، قد يتضمَّن المُتحكِّم مستمعي أحداث الفأرة ولوحة المفاتيح المسؤولين عن الاستجابة لما يفعله المُستخدِم بالعرض؛ كما قد يتضمَّن مستمعي بعض الأحداث الأخرى عالية المستوى، مثل تلك الناتجة عن زر أو مزلاج، والتي تؤثر على حالة المكوِّن. ويَستجيب المُتحكِّم عادةً على الأحداث بإجراء تعديلات على النموذج، مما يؤدي إلى تعديل العرض مباشرةً استجابةً لتلك التغييرات التي أُجريَت على النموذج. تَستخدِم مكتبة JavaFX نمط MVC بأماكن كثيرة حتى لو لم تكن تَستخدِم مصطلحات "النموذج" و"العرض"؛ وتُعدّ الخاصيات القابلة للمراقبة observale -ألقِ نظرةً على مقال الخاصيات والارتباطات في جافا- أسلوبًا لتنفيذ فكرة النموذج المنفصل عن العرض، على الرغم من أن النموذج قد يكون موزَّعًا على كائناتٍ مختلفة كثيرة عند استخدام الخاصيات. في الواقع، ستلاحِظ وضوح دور كُلٍ من النموذج والعرض أكثر بأداتي القائمة والجدول اللتين سنناقشهما فيما يلي. صنفا القائمة ListView والجدول ComboBox يُمثِّل الصنف ListView قائمةً من العناصر التي يستطيع المُستخدِم أن يختار من بينها، كما بإمكانه أن يُعدِّل العناصر الموجودة بالقائمة. يَسمَح البرنامج SillyStamper.java للمُستخدِم باختيار أيقونة (صورة صغيرة) من قائمة أيقونات مُمثَّلةٍ بكائنٍ من النوع ListView؛ بحيث يختار المُستخدِم الأيقونة التي يرغب بها بالنقر عليها، ثم يُمكِنه أن يَطبَعها داخل الحاوية من خلال النقر على الحاوية. وفي المقابل، يُضيف النقر مع الضغط على زر Shift نسخةً أكبر من الصورة إلى الحاوية (الأيقونات المُستخدَمة بهذا البرنامج مأخوذة من مشروع سطح المكتب KDE). تَعرِض الصورة التالية نافذة البرنامج بعد أن طَبعَ المُستخدِم مجموعةً من الأيقونات فعليًا داخل مساحة الرسم، واختارَ أيقونة "star" من القائمة: ستَجِد الصنف ListView مُعرَّفًا بحزمة javafx.scene.control؛ وهو في الحقيقة صنفٌ ذو معاملات غير محددة النوع parameterized، إذ يشير معامل النوع إلى نوع الكائن المعروض بالقائمة، ويُعدّ النوع ListView<String> هو الأكثر شيوعًا؛ ولكننا استخدمنا بهذا البرنامج النوع ListView<ImageView>، إذ تستطيع كائنات النوع ListView أن عرض السلاسل النصية من النوع String والعقد من النوع Node مباشرةً؛ وعند استخدامه مع كائنات من أنواع أخرى، فإنه يَعرِض التمثيل النصي للكائن الذي يعيده التابع toString() افتراضيًا، والذي لا يكون مفيدًا في غالب الأحيان. تُخزَّن عناصر القائمة من النوع ListView<T> بكائنٍ من النوع ObservableList<T>، إذ تُعدّ قائمة العناصر جزءًا من النموذج الخاص بالمكون، ويُمكِننا استخدام التابع listView.getItems() لاسترجاع عناصر القائمة؛ وعند إضافة العناصر إلى تلك القائمة أو حذفها منها، تُحدَّث القائمة تلقائيًا لتَعكِس ذلك التغيير. يُعرِّف البرنامج SillyStamper القائمة باستخدام المُعدِّل static؛ مما يَعنِي أنه من غير الممكن تعديل القائمة بعد إنشائها، وبالتالي لا يستطيع المُستخدِم أن يُعدِّل القائمة. يقرأ البرنامج صور الأيقونات من ملفات موراد، ويحيط كل صورةٍ منها ضمن كائنٍ من النوع ImageView -ألقِ نظرةً على مقال مكونات التحكم البسيطة في واجهة المستخدم في مكتبة جافا إف إكس JavaFX-، ويُضيفه إلى قائمة العناصر بكائن الصنف ListView. تَعرِض الشيفرة التالية التابع المسؤول عن إنشاء القائمة، والذي يستدعِيه التابع start() الخاص بهذا البرنامج: private ListView<ImageView> createIconList() { String[] iconNames = new String[] { // أسماء ملفات الموارد بالمجلد stamper_icons "icon5.png", "icon7.png", "icon8.png", "icon9.png", "icon10.png", "icon11.png", "icon24.png", "icon25.png", "icon26.png", "icon31.png", "icon33.png", "icon34.png" }; iconImages = new Image[iconNames.length]; // لرسم الأيقونات ListView<ImageView> list = new ListView<>(); list.setPrefWidth(80); list.setPrefHeight(100); for (int i = 0; i < iconNames.length; i++) { Image icon = new Image("stamper_icons/" + iconNames[i]); iconImages[i] = icon; list.getItems().add( new ImageView(icon) ); } list.getSelectionModel().select(0); // اختر العنصر الأول بالقائمة return list; } يبدو أن الحجم المُفضَّل الافتراضي لأي قائمة هو 200 في 400 بغض النظر عن مكوناتها. ويضبُط التابع السابق العرض والطول المُفضَّلين للقائمة؛ فقائمة الأيقونات تحتاج عرضًا أصغر بكثير، كما أن الطول المُفضَّل الافتراضي يؤدي إلى زيادة طول الحاوية بقدرٍ أكبر مما هو مرغوب به، ولذلك يَضبُط التابع الطول المُفضَّل إلى قيمةٍ أصغر، مع أنها ستمتد ضمن هذا البرنامج لتملأ المساحة المُتاحة. يبدو استخدام التابع "للنموذج المُختار" ضمن القائمة مثيرًا بعض الشيء؛ ويُقصَد بذلك جزء النموذج الذي يحتوي على قائمة العناصر التي اختارها المُستخدِم من القائمة، إذ يستطيع المُستخدِم أن يختار عنصرًا واحدًا فقط على الأكثر افتراضيًا، وهو السلوك المناسب لهذا البرنامج؛ ولكن يُمكِننا عمومًا ضبطه ليسمح باختيار عدة عناصر بنفس الوقت، وذلك باستدعاء ما يلي: list.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); في حالة تطبيق وضع الاختيار الأحادي الافتراضي، يُلغَى اختيار العنصر المُختار حاليًا -إن وجد-، إذا اختار المُستخدِم عنصًرا آخرًا من القائمة؛ إذ يستطيع المُستخدِم اختيار عنصرٍ بالنقر عليه؛ كما يستطيع البرنامج ضبط الاختيار إلى عنصرٍ معينٍ باستدعاء التابع list.getSelectionModel().select(index)، إذ يشير المعامل index إلى فهرس العنصر المطلوب اختياره. يبدأ هنا ترقيم العناصر من الصفر، وفي حالة كان index يُساوِي -1، فسيُلغَى الاختيار من جميع العناصر. يُسلًط عرض القائمة الضوء على العنصر المُختار حاليًا، كما يستمع إلى التغييرات الحادثة بالنموذج المختار؛ فعندما ينقر المُستخدِم على عنصر، سيُعدِّل مستمع أحداث الفأرة (يُمثِل جزءًا من النموذج) النموذج المختار، ويُبلَّغ العرض بحدوث ذلك التغيير؛ وبناءً على ذلك، يُحدِّث العرض مظهره ليَعكِس حقيقة اختيار عنصرٍ آخر. يستطيع البرنامج استرجاع العنصر الواقع عليه الاختيار حاليًا باستدعاء التابع التالي: list.getSelectionModel().getSelectedIndex() يَستخدِم البرنامج SillyStamper.java التابع السابق عندما ينقر المُستخدِم على الحاوية؛ ولكي يُحدِّد أي أيقونةٍ ينبغي عليه أن يطبعها بالصورة. لاحِظ أن القائمة بالبرنامج SillyStamper.java غير قابلةٍ للتعديل، لكن يحتوي البرنامج التوضيحي الثاني EditListDemo.java على قائمتين بإمكان المُستخدِم تعديلهما: الأولى قائمة سلاسلٍ نصية، والأخرى قائمة أعداد؛ إذ يستطيع المُستخدِم أن يبدأ بتعديل عنصرٍ معينٍ ضمن القائمة بالنقر المزدوج عليه، أو بالنقر عليه مرةً واحدةً إذا كان قيد الاختيار أساسًا؛ كما يُمكِنه إنهاء عملية التعديل من خلال الضغط على مفتاح "return" أو مفتاح "escape" لإلغاء عملية التعديل. ويؤدي اختيار عنصرٍ آخر ضمن القائمة إلى إلغاء عملية التعديل. هناك بعض الأشياء التي ينبغي فعلها إذا أردنا السماح للمُستخدِم بتعديل عناصر قائمةٍ من النوع ListView. أولًا، ينبغي أن نجعل القائمة قابلةً للتعديل باستدعاء التعليمة التالية: list.setEditable(true); ولكن هذا ليس كافيًا، إذ ينبغي أن تكون كل خلية ضمن القائمة قابلةً للتعديل أيضًا؛ ويُقصَد بالخلية هنا تلك المساحة الموجودة بالقائمة، والتي يُعرَض من خلالها عنصرٌ وحيد. في العموم، كل خلية هي كائنٌ مسؤولٌ عن عرض العنصر، وبإمكانه أيضًا تعديله وفق ما يفعله المُستخدِم، ولكن لاحِظ أن تلك الخلايا لا تكون قابلةً للتعديل بالوضع الافتراضي. تَستخدِم القوائم من النوع ListView مصنع factory خلايا لإنشاء الكائنات التي تُمثِّل تلك الخلايا. ففي الواقع، يُعَد مصنع الخلايا كائنًا آخر، ووظيفته هي إنشاء الخلايا؛ ولكي نَحصل على نوعٍ مختلفٍ من الخلايا، ينبغي أن نُوفِّر للقائمة مصنع خلايا مختلف. يتبِع ذلك ما يُعرَف باسم نمط المصنع factory pattern؛ فمن خلال استخدام كائن مصنع لإنشاء الخلايا، يُمكِننا أن نُخصِّص الخلايا بسهولة دون تغيير الشيفرة المصدرية للصنف ListView، فكل ما نحتاج إليه هو مصنع خلايا جديد. في الواقع، ليس من السهل كتابة مصانع الخلايا، ولكن تُوفِّر مكتبة JavaFX لحسن الحظ مجموعةً من مصانع الخلايا القياسية. فإذا كان listView من النوع ListView<String>، فيُمكِننا أن نُهيِئ مصنع خلايا بإمكانه إنشاء خلايا قابلةٍ للتعديل باستدعاء ما يلي: listView.setCellFactory( TextFieldListCell.forListView() ); يُعيد التابع TextFieldListCell.forListView() مصنعًا بإمكانه إنشاء خلايا تَعرِض سلاسلًا نصيةً وتُعدِّلها؛ إذ تَستخدِم الخلية عنوانًا من النوع Label أثناء عرض السلسلة النصية، وتَستخدِم حقلًا نصيًا من النوع TextField أثناء تعديل العنصر. هذا هو كل ما ينبغي أن تعرفه لكي تتمكَّن من إنشاء قائمة سلاسل نصية قابلةٍ للتعديل. وعلاوةً على ذلك، تتوفَّر أنواع عناصر أخرى يتناسب معها أيضًا عرض العنصر، مثل سلسلةٍ نصية واستخدام حقلٍ نصي من النوع TextField أثناء تعديل العنصر، مثل الأعداد والقيم المكونة من محرف واحد والتواريخ والأزمنة. ومع ذلك، إذا لم يكن العنصر سلسلةً نصيةً، فلا بُدّ من وجود طريقةٍ ما للتحويل بينه وبين تمثيله النصي. تُسهِّل مكتبة JavaFX لحسن الحظ تحقيق ذلك بالحالات الشائعة إلى حدٍ كبير، فهي تُوفِّر مُحوِّلات قياسية لجميع الأنواع المذكورة بالأعلى. على سبيل المثال، إذا كان intList قائمةً قابلةً للتعديل من النوع ListType<Integer>، فيُمكِننا أن نُهيِئ له مصنع خلايا مناسب باستخدام التعليمة التالية: intView.setCellFactory( TextFieldListCell.forListView( new IntegerStringConverter() ) ); إذ أن المعامل المُمرَّر للتابع forListView هو كائنٌ يُحوِّل بين الأعداد الصحيحة وتمثيلاتها النصية. ونظرًا لأن ذلك المُحوِّل القياسي لا يُعالِج المُدْخَلات غير الصالحة بطريقةٍ جيدة، فقد اِستخدَمنا بالبرنامج التوضيحي EditListDemo.java مُحوِّلًا مُخصَّصًا آخرًا -كتبه المؤلف- لمصنع الخلايا المُستخدَم بقائمة الأعداد الصحيحة ضمن البرنامج. ألقِ نظرةً على الشيفرة التالية: StringConverter<Integer> myConverter = new StringConverter<Integer>() { // 1 public Integer fromString(String s) { // حوِّل سلسلةً نصيةً إلى عدد صحيح if (s == null || s.trim().length() == 0) return 0; try { return Integer.parseInt(s); } catch (NumberFormatException e) { return null; } } public String toString(Integer n) { // حوِّل عددًا صحيحًا إلى سلسلة نصية if (n == null) return "Bad Value"; return n.toString(); } }; listView.setCellFactory( TextFieldListCell.forListView( myConverter ) ); إذ تشير [1] إلى أن مُحوِّل السلاسل النصية المُخصَّص يحوّل قيمةً نصيةً مُدخَلةً غير صالحة إلى القيمة الفارغة null بدلًا من الفشل، ويَعرِض تلك القيمة الفارغة null على هيئة "Bad Value"؛ بينما يَعرِض السلسلة النصية الفارغة على هيئة صفر. يُعرِّف الصنف StringConverter تابعين فقط، هما toString() و fromString(). ستَجِد محولات السلاسل النصية القياسية مُعرَّفةً ضمن حزمة javafx.util.converters؛ كما ستَجِد الصنف TextFieldListCell مُعرَّفًا ضمن حزمة javafx.scene.control.cell؛ كما تتوفَّر أيضًا أصنافٌ أخرى مشابهة من أجل الخلايا المُستخدَمة مع الجداول. إلى جانب القوائم، يُنفِّذ البرنامج التوضيحي بعض الأشياء الأخرى الشيّقة المُتعلّقة بالأزرار والعناوين باستخدامه لبعض الخاصيات القابلة للمراقبة observable المُعرَّفة بنموذج القائمة -كما ناقشنا بمقال الخاصيات والارتباطات في جافا-؛ إذ يحتوي البرنامج مثلًا على عناوين تَعرِض العنصر المُختار ورقمه، وقد نفَّذ البرنامج ذلك بربط خاصية text الموجودة بالعنوان مع خاصيةٍ مُعرَّفةٍ بالنموذج المُختار الموجود بالقائمة. ألقِ نظرةً على الشيفرة التالية: Label selectedIndexLabel = new Label(); selectedIndexLabel.textProperty().bind( listView.getSelectionModel() .selectedIndexProperty() .asString("Selected Index: %d") ); Label selectedNumberLabel = new Label(); selectedNumberLabel.textProperty().bind( listView.getSelectionModel() .selectedItemProperty() .asString("SelectedItem: %s") ); لا بُدّ أن يكون الزر المسؤول عن حذف عنصر القائمة المختار حاليًا مُفعَّلًا فقط في حالة وجود عنصرٍ مُختارٍ فعلًا، إذ يُنفِّذ البرنامج ذلك بربط خاصية الزر disable على النحو التالي: deleteNumberButton.disableProperty().bind( listView.getSelectionModel() .selectedIndexProperty() .isEqualTo(-1) ); وبالتالي، تُمثِّل العناوين والأزرار بدائلًا لنفس النموذج المختار الذي تعتمد عليه القائمة، إذ يُعدّ ذلك واحدًا من أهم خاصيات نمط MVC، ألا وهو: قد تتواجد عدة عروض views لنفس الكائن المُمثِّل لنموذجٍ معين. تستمع العروض للتغييرات الحادثة بالنموذج؛ وفي حالة حدوث تعديل، تُبلَّغ العروض بالتغيير الحادث، وتُحدِّث نفسها لتعكِس الحالة الجديدة للنموذج. وبالإضافة إلى ما سبق، يحتوي البرنامج على الزر "Add" المسؤول عن إضافة عنصرٍ جديدٍ إلى القائمة، إذ يَستخدِم ذلك الزر جزءًا آخرًا من نموذج كائن الصنف ListView المُمثِّل للقائمة؛ وذلك بإضافة العنصر إلى كائنٍ قابلٍ للمراقبة من النوع ObservableList يَحمِل جميع عناصر القائمة. ونظرًا لأن كائن الصنف ListView يستمع إلى التغييرات الواقعة بتلك القائمة القابلة للمراقبة، فإنه يُبلَّغ بحدوث ذلك التغيير، وبالتالي يُمكِنه أن يُحدِّث نفسه ليعرِض العنصر الجديد ضمن القائمة. وبخلاف إضافة العنصر إلى القائمة القابلة للمراقبة، فلا حاجة لفعل أي شيءٍ آخر لإظهار العنصر على الشاشة. والآن، سنناقش أداة تحكُّم أخرى مُمثَّلةٍ بالصنف ComboBox، إذ تشبه تلك الأداة أداة التحكُّم التي يُمثِّلها الصنف ListView إلى حدٍ كبير، بل هي أساسًا نفس أداة ListView، ولكنها تُظهِر العنصر المُختار فقط؛ فعندما ينقر المُستخدِم على تلك الأداة، ستَظهَر قائمةٌ بجميع العناصر المتاحة، ويستطيع المُستخدِم أن يختار أي عنصرٍ منها. في الواقع، تَستخدِم أداة التحكُّم ComboBox كائنًا من الصنف ListView لعرض القائمة التي تظهر عند نَقْر المُستخدِم على الأداة. ولقد رأينا تلك الأداة مُستخدَمةً فعلًا بهيئة قائمة ببعض الأمثلة السابقة، مثل البرنامج GUIDemo.java بمقال واجهة المستخدم الحديثة في جافا. بالمثل من الصنف ListView، إذ يُعدّ الصنف ComboBox نوعًا ذا معاملات غير محدَّدة النوع، ويُعدّ نوع العنصر String هو الأكثر اِستخدامًا معها، على الرغم من دعمها لأنواع عناصر أخرى (باستخدام مصانع الخلايا ومحوّلات السلاسل النصية). يُمكِننا إنشاء أداة تحكُّم من النوع ComboBox وإدارتها بنفس طريقة إنشاء وإدارة أداة التحكُّم ListView. ألقِ نظرةً على الشيفرة التالية على سبيل المثال: ComboBox<String> flavors = new ComboBox<>(); flavors.getItems().addAll("Vanilla", "Chocolate", "Strawberry", "Pistachio"); flavors.getSelectionModel().select(0); يُمكِننا ضبط تلك الأداة لتُصبِح قابلةً للتعديل، ولسنا بحاجةٍ إلى مصنع خلايا مُخصَّص لذلك الغرض طالما كانت العناصر المُستخدَمة من النوع String، وتكون الأداة في هذه الحالة أشبه بتركيبةٍ غريبة تجمع بين الحقل النصي والقائمة؛ إذ تَستخدِم حقلًا نصيًا لعرض العنصر المُختار بدلًا من استخدام عنوان. إلى جانب ذلك، يمكن للمُستخدِم تعديل قيمة الحقل النصي، وستُصبِح القيمة المُعدَّلة هي القيمة المختارة، ولكن لاحظ أن القيمة الأصلية للعنصر المُعدَّل لا تُحذَّف من القائمة، وإنما يُضاف العنصر الجديد فقط، كما أن العنصر الجديد لا يُصبِح جزءًا دائمًا من القائمة. يؤدي استدعاء التابع flavors.setEditable(true) في المثال السابق مثلًا، إلى السماح للمُستخدِم بكتابة "Rum Raisin," أو أي شيء آخر على أنها نكهةٌ مُفضَّلة، ولكنه لا يحلّ محل العنصر "Vanilla"، أو "Chocolate"، أو "Strawberry"، أو "Pistachio" الموجودين بالقائمة. بخلاف كائنات الصنف ListView، تُولِّد كائنات الصنف ComboBox حدثًا من النوع ActionEvent عندما يختار المُستخدِم عنصرًا جديدًا سواءٌ فَعَلَ ذلك باختيار العنصر من القائمة، أو بكتابة العنصر على أنه قيمةٌ جديدةٌ بالصندوق القابل للتعديل، ثم الضغط على "return". الصنف TableView بالمثل من أداة التحكُّم بالقائمة المُمثَّلة بالصنف ListView، تَعرِض أداة تحكُّم "الجدول" المُمثَّلة بالصنف TableView تجميعةً من العناصر للمُستخدِم، ولكنها أكثر تعقيدًا، إذ تُرتَّب عناصر الجدول ضمن شبكةٍ من الصفوف والأعمدة، ويُمثِّل كل موضعٍ بالشبكة "خليةً" ضمن الجدول. يحتوي كل عمودٍ هنا على متتاليةٍ من العناصر، ويملُك رأسًا يقع أعلى العمود ويحتوي على اسمه. وفي العموم، يتشابه العمل مع عمودٍ واحد ضمن كائن الصنف TableView مع العمل مع كائن الصنف ListView من جوانب كثيرة. يُعدّ الصنف TableView<T> نوعًا ذا معاملات غير مُحدَّدة النوع، إذ يحتوي كائن معامل النوع T على جميع البيانات المتعلقة بصف واحد ضمن الجدول، ويُمكِنه أن يتضمَّن بيانات إضافية أيضًا؛ فيمكن للجدول أن يَكون "عرضًا view " لبعض البيانات المُتاحة فقط. ينتمي نموذج البيانات الخاص بجدول من النوع TableView<T> إلى الصنف ObservableList<T>، ويُمكِننا استرجاعه باستدعاء التابع table.getItems()، كما يُمكِننا أيضًا إضافة الصفوف إلى الجدول وحذفها منه بإضافة العناصر وحذفها من تلك القائمة. لكي نُعرِّف جدولًا: لا يكون تحديد الصنف المُمثِّل لصفوف الجدول كافيًا، فعلينا أيضًا أن نحدِّد نوع البيانات التي ننوي تخزينها بكل عمود ضمن الجدول؛ لذلك سنَستخدِم كائنًا من النوع TableColumn<T,S> لوصف كل عمود ضمن الجدول، إذ يشير معامل النوع الأول T إلى نفس نوع معامل النوع الخاص بالجدول، بينما يشير معامل النوع الثاني S إلى نوع العناصر التي ننوي تخزينها بخلايا ذلك العمود. يشير النوع TableColumn<T,S> إلى كون العمود يَعرِض عناصرًا من النوع S مشتقةً من صفوفٍ من النوع T. لا تحتوي الكائنات المُمثِّلة للعمود على العناصر المعروضة بالعمود، فهم موجودون بكائنات النوع T التي تُمثِّل الصفوف، ومع ذلك تحتاج كائنات الأعمدة إلى طريقةٍ لاسترجاع العنصر المعروض بالعمود من الكائن المُمثِّل للصف؛ إذ يُمكِننا إجراء ذلك بتخصيص ما يُعرَف باسم "مصنع قيم الخلايا"، فيُمكِننا مثلًا أن نكتب مصنعًا لتطبيق أي دالةٍ function على كائنٍ مُمثِّل لصف، ولكن الصنف PropertyValueFactory يُعدّ هنا الخيار الأكثر شيوعًا، والذي يسترجِع ببساطة قيمة إحدى خاصيات كائن الصف. والآن لنفحص مثالًا. يَعرِض البرنامج التوضيحي SimpleTableDemo.java جدولًا غير قابلٍ للتعديل، ويحتوي الجدول على أسماء الولايات الخمسين الموجودة بالولايات المتحدة الأمريكية مع عواصمها وتعدادها السكاني. ألقِ نظرةً على الصورة التالية: يَستخدِم البرنامج كائناتٍ تنتمي إلى الصنف StateData المُعرَّف على أنه صنفٌ متداخلٌ ساكنٌ عام لحَمْل بيانات كل صف. لا بُدّ أن يكون الصنف عامًا لكي نتمكَّن من اِستخدَامه مع الصنف PropertyValueFactory، ولكن ليس من الضروري أن يكون متداخلًا أو ساكنًا. سنُعرِّف قيم بيانات كل صف على أنها خاصيات ضمن ذلك الصنف، أي سيكون هنالك تابع جَلْب getter لكل قيمةٍ منها. في الحقيقة، يُعدّ تعريف الخاصيات باستخدام توابع جلب كافيًا لاستخدامها مثل قيمٍ بجدولٍ غير قابل للتعديل، كما سنحتاج إلى شيء مختلف بالنسبة لأعمدة الجداول القابلة للتعديل كما سنرى لاحقًا. ألقِ نظرةً على تعريف الصنف: public static class StateData { private String state; private String capital; private int population; public String getState() { return state; } public String getCapital() { return capital; } public int getPopulation() { return population; } public StateData(String s, String c, int p) { state = s; capital = c; population = p; } } سنُنشِئ الجدول المسؤول عن عرض بيانات الولايات على النحو التالي: TableView<StateData> table = new TableView<>(); بعد ذلك سنضيف إلى نموذج بيانات الجدول عنصرًا لكل ولاية، والذي يُمكِننا استرجاعه باستدعاء التابع table.getItems()؛ ثم سنُنشِئ الكائنات المُمثِّلة للأعمدة ونُهيئها ونضيفها إلى نموذج أعمدة الجدول، والذي يُمكِننا استرجاعه باستدعاء التابع table.getColumns(). ألقِ نظرةً على الشيفرة التالية: TableColumn<StateData, String> stateCol = new TableColumn<>("State"); stateCol.setCellValueFactory( new PropertyValueFactory<StateData, String>("state") ); table.getColumns().add(stateCol); TableColumn<StateData, String> capitalCol = new TableColumn<>("Capital City"); capitalCol.setCellValueFactory( new PropertyValueFactory<StateData, String>("capital") ); table.getColumns().add(capitalCol); TableColumn<StateData, Integer> populationCol = new TableColumn<>("Population"); populationCol.setCellValueFactory( new PropertyValueFactory<StateData, Integer>("population") ); table.getColumns().add(populationCol); يُمثِّل المعامل المُمرَّر لباني الصنف TableColumn النص المعروض برأس العمود. وبالنسبة لمصانع قيم الخلايا، يحتاج أي مصنعٍ منها إلى قراءة قيمة الخلية من كائن صفٍ ينتمي إلى النوع StateData؛ أما بالنسبة للعمود الأول، فنوع البيانات هو String، وبالتالي ينبغي أن يَستقبِل المصنع مُدْخَلًا من النوع StateDate ويُخرِج قيمة خاصية من النوع String. بالتحديد، الخرج هو قيمة الخاصية state المُعرَّفة ضمن كائن الصنف StateData. بالتالي، يُمكِننا كتابة الباني على النحو التالي: new PropertyValueFactory<StateData, String>("state") يُنشِئ الاستدعاء السابق مصنع قيم خلايا يَحصُل على القيمة التي سيَعرِضها بالخلية باستدعاء obj.getState()، إذ أن obj هو الكائن المُمثِّل لصف الجدول المُتضمِّن للخلية. وقد خصَّصنا العمودين الآخرين بنفس الطريقة. هذا هو كل ما تحتاج إلى معرفته لكي تتمكَّن من إنشاء جدولٍ لا يستطيع المُستخدِم أن يُعدِّل محتويات خلاياه. يمكن للمُستخدِم افتراضيًا تعديل طول عَرْض العمود من خلال سحب الفاصل الموجود بين رأسي أي عمودين؛ كما بإمكانه أن ينقر على رأس أي عمود لكي يُرتِّب صفوف الجدول ترتيبًا تصاعديًا أو تنازليًا وفقًا لقيم ذلك العمود؛ وبإمكاننا مع ذلك تعطيل هاتين الخاصيتين بضبط بعض الخاصيات المُعرَّفة بكائن الصنف TableColumn -وهو ما سنفعله بالمثال التالي-؛ كما يستطيع المُستخدِم أيضًا إعادة ترتيب الأعمدة بسحب رأس العمود إلى اليمين أو اليسار. يتضمَّن البرنامج التوضيحي ScatterPlotTableDemo.java مثالًا على جدول قابل للتعديل، إذ يُمثِّل كل صفٍ ضمن الجدول نقطةً على سطح المستوى، ويحتوي العمودين على الإحداثي الأفقي والرأسي للنقاط. يَعرِض البرنامج تلك النقاط ضمن مخطط انتشار بياني scatter plot داخل حاوية، إذ يَرسِم تقاطعًا صغيرًا عند موضع كل نقطة. وتُوضِّح الصورة التالية لقطة شاشة من البرنامج أثناء تعديل الإحداثي الأفقي لإحدى النقاط: سنحتاج إلى نوع بيانات لتمثيل صفوف الجدول، والذي قد يكون صنفًا بسيطًا يحتوي على خاصيتين x و y لتمثيل إحداثيات النقطة؛ ولكن نظرًا لأننا نريد عمودًا قابلًا للتعديل، فلا نستطيع استخدام خاصياتٍ بسيطة مُعرَّفة بتوابع جَلْب وضبط، وإنما لا بُدّ أن تكون الخاصيات قابلةً للمراقبة. بالتحديد، لا بُدّ أن يَتبِّع الصنف نمط مكتبة JavaFX للخاصيات القابلة للمراقبة والتي تنص على مايلي: ينبغي أن تُخزَّن قيم الخاصيتين x و y بكائنات خاصيات قابلة للمراقبة، كما ينبغي أن يتضمَّن الكائن المُمثِّل للنقطة، وليكن اسمه pt، توابع النسخ pt.xProperty() و pt.yProperty()؛ إذ تعيد تلك التوابع كائنات الخاصيات القابلة للمراقبة، لكي تُستخدَم بضبط قيم الخاصيات واسترجاعها. وبما أن تلك الخاصيات تُخزِّن قيمًا من النوع double، فإن تلك الكائنات ستكون من النوع DoubleProperty. يُمكِننا تعريف صنف البيانات للجدول على النحو التالي: public static class Point { private DoubleProperty x, y; public Point(double xVal, double yVal) { x = new SimpleDoubleProperty(this,"x",xVal); y = new SimpleDoubleProperty(this,"y",yVal); } public DoubleProperty xProperty() { return x; } public DoubleProperty yProperty() { return y; } } يُعَد الصنف DoubleProperty صنفًا مجرَّدًا abstract؛ أما الصنف SimpleDoubleProperty، فهو صنفٌ فرعيٌ حقيقي concrete يتطلَّب بانيه constructor كُلًا من الكائن المُتضمِّن للخاصية واسم الخاصية والقيمة المبدئية لتلك الخاصية؛ وفي المقابل، يُوفِّر الصنف أوتوماتيكيًا مستمعي أحداث التغيير وانعدام الصلاحية invalidation الخاصين بتلك الخاصية. بعد تعريفنا للصنف Point، يُمكِننا إنشاء الجدول وإضافة بعض النقاط العشوائية إليه على النحو التالي: table = new TableView<Point>(); points = table.getItems(); for (int i = 0; i < 5; i++) { // أضف خمس نقاط عشوائية إلى الجدول points.add( new Point(5*Math.random(), 5*Math.random()) ); } عند إضافة نقطة إلى الجدول أو حذف نقطة منه، فلا بُدّ من إعادة رسم الحاوية، ولذلك سنضيف مستمعًا إلى القائمة points، التي تَعمَل مثل نموذج بيانات للجدول: points.addListener( (Observable e) -> redrawDisplay() ); لاحِظ تصريحنا عن كون المعامل e بتعبير لامدا السابق lambda expression من النوع Observable؛ وذلك لأن القائمة القابلة للمراقبة تتضمَّن نسختين من التابع addListener()، وكلاهما يَستقبِل مُعاملًا واحدًا بتعبير لامدا. وبالتالي يُمكِّن التصريح عن نوع e المُصرِّف من معرفة النسخة التي نريد استدعاءها، فنحن نضيف مستمعًا من النوع InvalidationListener لا من النوع ListChangeListener. وبذلك نكون قد ضبطنا الحاوية لكي تُعيد رسم نفسها بمجرد إضافة نقطةٍ إلى الجدول أو حذف نقطةٍ منه، ولكننا لم نضبطها بعد لتفعل ذلك عند تعديل إحدى النقاط الموجودة بالجدول؛ لأن ذلك لا يُمثِّل تغييرًا ببنية القائمة، وإنما يُمثِّل تغييرًا ضمن إحدى الكائنات الموجودة بالقائمة. لكي نتمكَّن من الإستجابة لتلك التغييرات أيضًا، يُمكِننا مثلًا إضافة مستمعين إلى الخاصيتين القابلتين للمراقبة المُعرَّفتين بكل كائنٍ من النوع Point. في الواقع، هذا هو ما يفعله الجدول أساسًا لكي يستجيب إلى التغييرات الحادثة بأي نقطة ضمن الجدول، ولكننا لن نتبِع هذا الأسلوب؛ إذ سنضبُط البرنامج بدلًا من ذلك ليستمع إلى نوعٍ آخر من الأحداث، التي ستمكِّنه أيضًا من معالجة تعديلات خلايا الجدول. يتضمَّن كل جدول خاصيةً قابلةً للمراقبة اسمها editingCell، والتي تحتوي على الخلية التي يُجرَى تعديلها حاليًا أو القيمة الفارغة null، إذا لم تكن هناك أي خليةٍ قيد التعديل. عندما تتغير قيمة تلك الخاصية إلى القيمة الفارغة null، يَعنِي ذلك أن هناك عملية تعديل لخليةٍ ما ضمن الجدول قد اكتملت، وبالتالي سنضبُط الحاوية لكي تعيد رسم نفسها بعد كل عملية تعديل من خلال تسجيل مستمعٍ إلى حدث التغيير بالخاصية editingCell على النحو التالي: table.editingCellProperty().addListener( (o,oldVal,newVal) -> { if (newVal == null) { redrawDisplay(); } }); والآن، لكي نُنهِي تعريف الجدول، ينبغي أن نُعرِّف العواميد؛ إذ سنحتاج إلى مصنع قيم خلايا لكل عمود، شرط أن يُنشَأ باستخدام مصنع قيم خاصيات. يتبِّع ذلك نفس النمط الذي اِستخدَمناه بالمثال السابق، ونظرًا لأن العمود هنا قابلٌ للتعديل، فسنحتاج إلى مصنع خلايا أيضًا كما فعلنا تمامًا بمثال القوائم القابلة للتعديل بالأعلى. يُمكِننا إذًا إنشاء مصنع خلايا باستخدام التعليمة التالية: TextFieldTableCell.forTableColumn(myConverter) إذ أن المعامل myConverter من النوع StringConverter<Double>؛ وسيكون من الأفضل بهذا البرنامج لو منعنا المُستخدِم من تغيير حجم الأعمدة أو تغيير ترتيبها. تتضمَّن الشيفرة التالية كل ما هو مطلوب لضبط إحدى العواميد: TableColumn<Point, Double> xColumn = new TableColumn<>("X Coord"); xColumn.setCellValueFactory( new PropertyValueFactory<Point, Double>("x") ); xColumn.setCellFactory( TextFieldTableCell.forTableColumn(myConverter) ); xColumn.setSortable(false); xColumn.setResizable(false); xColumn.setPrefWidth(100); // الحجم الافتراضي صغير للغاية table.getColumns().add(xColumn); بقي لنا الآن ضبط الجدول ليكون قابلًا للتعديل، وذلك باستدعاء التابع table.setEditable(true). ربما ترى أننا قد اضطررنا لفعل كثيرٍ من الأشياء لمجرد إنشاء جدول خصوصًا إذا كان قابلًا للتعديل؛ ولكن الجداول أكثر تعقيدًا من ذلك بكثير، والشيفرة التي تتطلَّبها مكتبة JavaFX لتهيئة جدول أقل بكثير مما يتطلَّبه تنفيذ جدولٍ من الصفر مباشرةً. بالمناسبة، عليك أن تنتبه للطريقة التي استخدمنا بها نمط MVC ضمن هذا البرنامج، إذ يُعَد مخطط الانتشار البياني عرضًا view بديلًا لنفس نموذج البيانات المعروض بالجدول؛ كما تُستخدَم البيانات من النموذج عند إعادة رسم الحاوية، ويَحدُث ذلك استجابةً للأحداث النابعة عن أي تعديلات بالنموذج. قد يُفاجئك ذلك، ولكننا لا نحتاج إلى إضافة ما هو أكثر من ذلك لكي نضمَّن استمرار عرض مخطط الانتشار البياني لنفس البيانات على نحو صحيح. سيكون أيضًا من الأفضل لو ألقيت نظرةً على شيفرة البرنامج ScatterPlotTableDemo.java، وستجدها موثقةً جيدًا. بالإضافة إلى فَحْص الصنف TableView؛ كما يُمكِنك كذلك إلقاء نظرةٍ على الطريقة التي اِستخدَمنا بها التحويلات transforms لرسم مخطط الانتشار البياني. ترجمة -بتصرّف- للقسم Section 3: Complex Components and MVC من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: أمثلة عن رسوميات فاخرة باستعمال جافا التخطيط الأساسي لواجهة المستخدم في مكتبة جافا إف إكس JavaFX1 نقطة
-
لقد رأينا أمثلةً كثيرةً على طريقة استخدام كائن سياق رسومي من النوع GraphicsContext للرسم ضمن حاوية، ولكنه يمتلك في الحقيقة ميزات أخرى كثيرة إلى جانب تلك التي تناولناها من قبل. سنناقش خلال هذا القسم رسوم الحاوية، وسنبدأ بفحص تابعين مفيدين لإدارة حالة كائن سياق رسومي. يمتلك كائنٌ g من النوع GraphicsContext خاصيات متعددة، مثل لون المِلء وعرض الخط، وتؤثر تلك الخاصيات على جميع ما يرسمه الكائن. من المهم أن تتذكر أن أي حاوية تملك كائن سياق رسومي وحيد، وأن أي تغيير يُجرَى على إحدى خاصيات ذلك الكائن، سيُطبَّق على جميع رسومه المُستقبلية إلى أن تتغير قيمة خاصياته مرةً أخرى؛ أي أن تأثير أي تغيير على خاصياته يتجاوز حدود البرنامج الفرعي subroutine الذي نُفَّذت خلاله. ومع ذلك، يحتاج المبرمجون عادةً إلى تغيير قيمة بعض الخاصيات تغييرًا مؤقتًا، بحيث تُعاد إلى قيمها السابقة بعد انتهائهم؛ ولهذا، تتضمَّن واجهة برمجة التطبيقات الخاصة بالرسوميات التابعين g.save() و g.restore() لتنفيذ ذلك بسهولة. يُخزِّن التابع g.save() عند تنفيذه حالة كائن السياق الرسومي، والتي تتضمَّن جميع الخاصيات التي تؤثر على الرسوم تقريبًا. في الواقع، يَملُك كائن السياق الرسومي مكدسًا stack.) للحالات -ألقِ نظرةً على مقال المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT-، بحيث يُخزِّن التابع g.save() حالة الكائن الحالية بالمكدس؛ وفي المقابل، يَسحَب التابع g.restore() عند استدعائه الحالة الموجودة أعلى المكدس، ويَضبُط قيم جميع خاصيات الكائن لتتوافق مع القيم المخزَّنة بالحالة المسحوبة. نظرًا لاستخدام كائن السياق الرسومي مكدس حالات، فمن الممكن استدعاء التابع save() عدة مرات قبل استدعاء التابع restore()، ولكن لا بُدّ أن يُقابِل كل استدعاءٍ للتابع save() استدعاءً للتابع ()restore. ومع ذلك، لا يؤدي استدعاء restore() بدون استدعاء سابق مقابل للتابع save() إلى حدوث خطأ؛ بل يحدث فقط تجاهُلٌ لتلك الاستدعاءات الإضافية. يُعدّ استدعاء التابع save() ببداية البرنامج الفرعي والتابع restore() بنهايته، الطريقة الأسهل عمومًا لضمان عدم تجاوز التغييرات المُجراة على كائن السياق الرسومي ضمن برنامج فرعي معين ما يليه من استدعاءات. تبرز أهمية حفظ حالة السياق الرسومي واستعادتها لاحقًا أثناء التعامل مع التحويلات transforms، والتي سنناقشها لاحقًا ضمن هذا القسم. رسم حواف الأشكال بطريقة فاخرة لقد رأينا طريقة رسم حواف الخطوط والمنحنيات وحتى خطوط المحارف النصية، وكذلك طريقة ضبط كُلٍ من لون وحجم الخط المُستخدَم لرسم تلك الحواف strokes. في الواقع، تتوفَّر خاصيات أخرى تؤثر على طريقة رسم الحواف؛ فيُمكِننا مثلًا أن نرسمها بخطوط مُنقطّة أو متقطعة، كما يُمكننا أن نتحكَّم بمظهر نهاية الحواف، وبمظهر نقطة التلاقي بين خيطين أو منحنين، مثل تقابُل جانبي مستطيل بإحدى أركانه. يحتوي كائن السياق الرسومي على خاصيات للتحكُّم بجميع تلك الخاصيات، ولكن من الضروري استخدام خطوطٍ عريضة بما يكفي حتى نتمكَّن من ملاحظة تاثير خاصيات، مثل تلك التي تتحكَّم بمظهر نهاية الحواف ونقط التلاقي. تَعرِض الصورة التالية بعض الخيارات المتاحة: تُبيّن نهايتي القطع المستقيمة الثلاثة على يسار الصورة الأنماط الثلاثة المحتملة للخط "cap."، كما ستَجِد الحافة باللون الأسود؛ أما الخط الهندسي، فهو بلون أصفر داخل الحافة. عند استخدام النمط BUTT، تُقطَّع نهايتي الخط الهندسي؛ أما عند استخدام النمط الدائري ROUND، يُضاف قرصٌ بكل نهاية قطره يساوي عرض الخط؛ بينما عند استخدام النمط المربع SQUARE، يُضاف مربعٌ بدلًا من القرص. وما تحصل عليه عند استخدام النمط الدائري أو المربع هو نفس ما تحصل عليه عند رسم حافة بقلم رأسه دائري أو مربع على الترتيب. إذا كان g كائن سياق رسومي، فسيضبُط التابع g.setLineCap(cap) نمط الخط cap المُستخدَم لرسَم الحواف، إذ يَستقبِل التابع معاملًا من نوع التعداد StrokeLineCap المُعرَّف بحزمة javafx.scene.shape، والتي قيمه المحتملة هي StrokeLineCap.BUTT و StrokeLineCap.ROUND والقيمة الافتراضية StrokeLineCap.SQUARE. تَعمَل نقط التلاقي بنفس الطريقة، إذ يَضبُط التابع g.setLineJoin(join) مظهر النقطة التي يلتقي عندها خيطان أو منحنيان، ويَستقبِل التابع في تلك الحالة كائنًا من النوع StrokeLineJoin، وقيمه المحتملة هي القيمة الافتراضية StrokeLineJoin.MITER و StrokeLineJoin.ROUND و StrokeLineJoin.BEVEL؛ إذ تعرِض الصورة السابقة هذه الأنماط الثلاثة بالمنتصف. عند اِستخدام النمط MITER، تمتد القطعتان المستقيمتان لتكوين نقطة حادة؛ أما عند اِستخدَام النمطين الآخرين، فسيُقطّع الركن. يكون التقطيع بالنسبة للنمط BEVEL باستخدام قطعة مستقيمة؛ أما بالنسبة للنمط ROUND، فسيكون التقطيع باستخدام قوس أو دائرة. تبدو نقط التلاقي الدائرية أفضل إذا رسمت منحنيًا كبيرًا بهيئة سلسلةٍ من القطع المستقيمة القصيرة. يُمكِننا استخدام التابع g.setLineDashes() لتطبيق نمط تقطيع يُظهِر الحواف على نحوٍ مُنقّط أو متقطِّع، إذ تُمثِّل معاملات هذا التابع أطوال القطع والمسافات الفاصلة بينها: g.setLineDashes( dash1, gap1, dash2, gap2, . . . ); لاحِظ أن معاملات التابع هي من النوع double، ويُمكِن تمريرها أيضًا مثل مصفوفةٍ من النوع double[]. وإذا رسمنا حافةً معينةً بعد اختيار أيٍّ من أنماط التقطيع، فسيتكوّن الشكل من خطٍ أو منحنًى طوله يُساوِي dash1، متبوعًا بمسافةٍ فارغة طولها gap1، والتي يتبعها خط أو منحنى طوله dash2، وهكذا، وسيُعاد تكرار نفس نمط الخطوط والفراغات بما يكفي لرسم طول الحافة بالكامل. على سبيل المثال، يرسِم الاستدعاء g.setLineDashes(5,5) الحافة مثل متتاليةٍ من القطع القصيرة التي يبلُغ طول كل منها 5 ويَفصِل بينها مسافةً فارغةً طولها يُساوِي 5؛ بينما يَرسِم الاستدعاء g.setLineDashes(10,2) متتاليةً من القطع المستقيمة الطويلة، بحيث يَفصِل بينها مسافات قصيرة. يُمكِن تخصيص نمطٍ مُكوَّن من قطعٍ مستقيمة ونقط باستدعاء التابع g.setLineDashes(10,2,2,2)، ويتكوَّن النمط المتقطع الافتراضي من خطٍ بدون أي نقاط أو فواصل. يَسمح البرنامج التوضيحي StrokeDemo.java للمُستخدِم برسم خطوط ومستطيلات باستخدام تشكيلةٍ مختلفة من أنماط الخطوط. ألقِ نظرةً على الشيفرة المصدرية لمزيدٍ من التفاصيل. تلوين فاخر لقد أصبح بإمكاننا رسم حوافٍ فاخرة الآن. ربما لاحظت أن كل عمليات الرسم كانت مقيدةً بلونٍ واحدٍ فقط، ولكن يُمكِننا في الواقع تجاوز ذلك باستخدام الصنف Paint؛ إذ تُستخدَم كائنات هذا الصنف لإسناد لونٍ لكل بكسل نمرُ عليه أثناء الرسم. وفي الواقع، يُعدّ الصنف Paint صنفًا مجرّدًا abstract، وهو مُعرَّف بحزمة javafx.scene.paint. يُعدّ الصنف Color واحدًا فقط من ضمن الأصناف الفرعية الحقيقية المشتقة من الصنف Paint، أي يُمكِننا أن نُمرِّر أي كائن من النوع Paint مثل معاملٍ للتابعين g.setFill() و g.setStroke(). وعندما يكون الكائن المُمرَّر من النوع Color، سيُطبَّق نفس اللون على جميع البكسلات التي تَمُرّ عبرها عملية الرسم، ولكن هنالك بالطبع أنواع أخرى يَعتمِد فيها اللون المُطبَّق على بكسلٍ معين على إحداثياته. تُوفِّر مكتبة JavaFX عدة أصناف بهذه الخاصية، مثل ImagePattern، ونوعين آخرين للتلوين المُتدرِج؛ فبالنسبة للصنف الأول ImagePattern، يُستخرَج لون البكسل من صورة مكررة -إن اقتضت الضرورة- مثل ورق حائط حتى تُغطِي سطح المستوى xy بالكامل؛ أما بالنسبة للتلوين المُتدرِج، فيتغير اللون المُطبَّق على البكسلات تدريجيًا من لونٍ لآخر بينما ننتقل من نقطة لأخرى. تُوفِّر جافا صنفين من هذا النوع، هما LinearGradient و RadialGradient. سيكون من المفيد لو اطلعنا على بعض الأمثلة، إذ تعرض الرسمة التالية مضلعًا ملونًا بأسلوبين مختلفين؛ ويَستخدِم المضلع الموجود على اليسار الصنف LinearGradient، بينما يَستخدِم المضلع الموجود على اليمين الصنف ImagePattern. لاحِظ أن اللون قد اُستخدَم هنا لملء المضلع ذاته فقط، أما حواف المُضلع فقد رُسمَت بلونٍ أسود عادي. ومع ذلك، يُمكِننا استخدام كائنات الصنف Paint لرسم حواف الأشكال وملئها أيضًا. ستَجِد شيفرة رسم المضلعين بالبرنامج PaintDemo.java، إذ يُمكِّنك هذا البرنامج من الاختيار بين عدة أنماط تلوين مختلفة، إلى جانب التحكًّم ببعض من خاصيات تلك الأنماط. إذا اخترنا استخدام الصنف ImagePattern، فسنحتاج إلى صورة أولًا. لقد تعرَّضنا للصنف Image بالقسم الفرعي 6.2.3، وتعلمنا طريقة إنشاء كائن من النوع Image من ملف مورد. وبفرض أن pict هو كائنٌ يُمثِّل صورةً من النوع Image، يُمكِننا إنشاء كائنٍ من النوع ImagePattern باستخدام باني الكائن على النحو التالي: patternPaint = new ImagePattern( pict, x, y, width, height, proportional ); تُمثِّل المعاملات x و y و width و height قيمًا من النوع double، تتحكَّم بكُلٍ من حجم الصورة وموضعها بالحاوية؛ إذ تُوضَع نسخةٌ واحدةٌ من الصورة بالحاوية، بحيث يقع ركنها الأيسر العلوي بنقطة الإحداثيات (x,y)، وتمتد وفقًا للطول والعرض المُخصَّصين. بعد ذلك، تتكرر الصورة أفقيًا ورأسيًا عدة مرات بما يكفي لملء الحاوية بالكامل، ولكنك ترى فقط الجزء الظاهر عبر الشكل المطلوب تطبيق نمط التلوين عليه. يُمثِّل المعامل الأخير للباني proportional قيمةً من النوع boolean، وتُخصِّص طريقة تفسير المعاملات الأخرى x و y و width و height؛ فإذا كانت قيمة proportional تُساوِي false، فسيُقاس كُلٌ من width و height باستخدام نظام الإحداثيات المعتاد؛ أما إذا كانت قيمته تساوي true، فسيُقاسان باستخدام مضاعفاتٍ من حجم الشكل المطلوب تطبيق نمط التلوين عليه، وستكون (x,y) مساويةً (0,0) في الركن الأيسر العلوي للشكل (بتعبير أدق، المستطيل المُتضمِّن للشكل). انظر ما يلي على سبيل المثال: patternPaint = new ImagePattern( pict, 0, 0, 1, 1, true ); يُنشِيء هذا الباني كائنًا من النوع ImagePattern، بحيث تُغطِي نسخةٌ واحدةٌ من الصورة الشكل بالكامل. وإذا طبقنا نمط التلوين ذاك على عدة أشكال بأحجام مختلفة، فستمتد الصورة بما يتناسب مع كل شكل. وفي المقابل، إذا أردنا أن يحتوي الشكل على أربع نسخ من الصورة أفقيًا ونسختين رأسيًا، فيُمكِننا استخدام ما يلي: patternPaint = new ImagePattern( pict, 0, 0, 0.25, 0.5, true ); في المقابل، يُحدَّد نمط التلوين المُتدرِج الخطي من خلال تخصيص قطعةٍ مستقيمة ولون عدة نقاط على طول تلك القطعة؛ إذ يُطلَق على تلك النقاط وألوانها اسم وقفات الألوان color stops، وتُضاف الألوان بينها، بحيث يُسنَد لونٌ معينٌ لكل نقطة على الخط، كما تمتد الألوان عموديًا على القطعة المستقيمة لتنتج شريطًا ملونًا لا نهائي. ينبغي أيضًا أن نُخصِّص ما يحدث خارج ذلك الشريط، وهو ما يُمكِننا فعله بتخصيص ما يُعرَف باسم "أسلوب التكرار cycle method" بمكتبة JavaFX؛ إذ تشتمل قيمه المحتملة على مايلي: الثابت CycleMethod.REPEAT، الذي يُكرِّر الشريط الملون بما يكفي لتغطية سطح المستوى بالكامل. الثابت CycleMethod.MIRROR الذي يكرِّر أيضًا الشريط الملون، ولكنه يَعكِس كل تكرارٍ منه لتتوافق الألوان الموجودة على أطراف كل تكرار مع بعضها. الثابت CycleMethod.NO_REPEAT، الذي يَمِدّ اللون الموجود على كل طرف لا نهائيًا. تَعرِض الصورة التالية ثلاثة أنماط تلوين مُتدرِج تستخدِم جميعها نفس خاصيات القطعة المستقيمة ووقفات الألوان، إذ تَستخدِم تلك الموجودة على يسار الصورة أسلوب التكرار MIRROR؛ بينما تَستخدِم الموجودة بمنتصف الصورة أسلوب التكرار REPEAT؛ في حين تَستخدِم تلك الموجودة على اليمين أسلوب التكرار NO_REPEAT. رسمنا القطعة المستقيمة ووضعنا علامات على مواضع وقفات الألوان على طول تلك القطعة، كما هو موضح في الشكل التالي: يُنشِئ الباني التالي نمط تلوين متدرج: linearGradient = new LinearGradient( x1,y1, x2,y2, proportional, cycleMethod, stop1, stop2, . . . ); تُمثِّل المعاملات الأربعة الأولى قيمًا من النوع double، إذ تُخصِّص نقطتي البداية والنهاية للقطعة المستقيمة (x1,y1) و (x2,y2)؛ أما المعامل الخامس proportional، فهو من النوع boolean؛ فإذا كانت قيمته تساوي false، فستُفسَّر نقطتي البداية والنهاية باستخدام نظام الإحداثيات المعتاد؛ أما إذا كانت قيمته تساوي true، فإنها تُفسَّر باستخدام نظام إحداثيات تقع نقطته (0,0) في الركن الأيسر العلوي للشكل المطلوب تطبيق نمط التلوين عليه، بينما تقع نقطته (1,1) في الركن الأيمن السفلي لنفس الشكل. يُمثِّل المعامل السادس cycleMethod إحدى الثوابت CycleMethod.REPEAT و CycleMethod.MIRROR و CycleMethod.NO_REPEAT؛ بينما تشير المعاملات المتبقية إلى وقفات الألوان، ويُمثَل كل وقفةٍ منها كائنًا من النوع Stop. يستقبل باني الصنف Stop معاملين من النوع double و Color؛ إذ يُخصِّص المعامل الأول مكان الوقفة على طول القطعة المستقيمة، وتكون قيمته نسبةً من المسافة بين نقطتي البداية والنهاية. في العموم، لا بُدّ أن يكون مكان الوقفة الأولى عند 0 ومكان الوقفة الأخيرة عند 1؛ كما لا بُدّ أن تكون قيمة مكان كل وقفة أكبر من قيمة مكان الوقفة التي تَسبِقها، إذ يُمكِننا مثلًا إنشاء نمط التلوين المُتدرِج المُستخدَم لتلوين الشكل الموجود على يسار الصورة السابقة على النحو التالي: grad = new LinearGradient( 120,120, 200,180, false, CycleMethod.MIRROR, new Stop( 0, Color.color(1, 0.3, 0.3) ), new Stop( 0.5, Color.color(0.3, 0.3, 1) ), new Stop( 1, Color.color(1, 1, 0.3) ) ); بالنسبة لنمط التلوين المُتدرِج الخطي، سيكون اللون ثابتًا على طول عدة خطوط معينة؛ أما بالنسبة لنمط التلوين الدائري، فسيكون اللون ثابتًا بالنسبة لدوائر معينة؛ إذ تُخصَّص وقفات الألوان على طول نصف قطر دائرة بأبسط حالات التلوين المتدرج الدائري، ويكون اللون ثابتًا للدوائر التي تتشارك نفس المركز. وبالمثل في نمط التلوين الخطي، إذ يُحدِّد أسلوب التكرار اللون المُستخدَم خارج الدائرة. في الحقيقة، الأمر أكثر تعقيدًا من ذلك، فقد يتضمن نمط التلوين الدائري أيضًا نقطةً محورية، والتي تكون هي نفسها مركز الدائرة في الحالة الأبسط، ولكنها في العموم قد تكون أي نقطة أخرى داخل الدائرة. تحدِّد وقفة اللون بالموضع 0 اللون المُستخدَم عند النقطة المحورية، بينما تُحدِّد وقفة اللون بالموضع 1 اللون المُستخدَم على طول الدائرة، وتَستخدِم جميع الرسوم بالصورة التالية نفس نمط التلوين المُتدرِّج، ولكنها تختلف بمكان النقطة المحورية. ستلاحظ وجود علامتين بالرسوم الموجودة بالصف الأول من الصورة، إذ تُمثِّل العلامتان الدائرة والنقطة المحورية. لاحِظ أن اللون المُستخدَم عند النقطة المحورية هو الأحمر، بينما اللون المُستخدَم على طول الدائرة هو الأصفر: يَستقبِل باني الكائن كثيرًا من المعاملات: radialGradient = new RadialGradient( focalAngle,focalDistance, centerX,centerY,radius, proportional, cycleMethod, stop1, stop2, . . . ); يُخصِّص المعاملان الأول والثاني موضع النقطة المحورية، إذ يشير focalDistance إلى المسافة التي تبعدها النقطة المحورية عن مركز الدائرة، وتُخصَّص على أنها نسبةٌ من نصف القطر، أي لا بُدّ أن تكون أصغر من 1؛ بينما يشير focalAngle إلى الاتجاه الذي تتحرك إليه النقطة المحورية بعيدًا عن المركز. في الحالة الأبسط من نمط التلوين المتدرج الدائري، تكون المسافة مساويةً للصفر ولا يكون للاتجاه أي معنًى. تُخصِّص المعاملات الثلاثة التالية مركز الدائرة وطول نصف قطرها؛ في حين تعمل المعاملات المتبقية على نحوٍ مشابه للمعاملات المُستخدَمة بنمط التلوين المتدرج الخطي. التحويلات Transforms تشير النقطة (0,0) تبعًا لنظام الإحداثيات القياسي الخاص بمكون الحاوية إلى ركنها الأيسر العلوي؛ بينما تشير النقطة (x,y) إلى تلك النقطة التي تبعد مسافة طولها x بكسل من الجانب الأيسر للحاوية ومسافة y بكسل من جانبها العلوي، ومع ذلك ليس هناك أي تقييد بخصوص ضرورة استخدام نظام الاحداثيات ذاك، ففي الحقيقة، يُمكِننا أن نضبُط كائن السياق الرسومي لكي يَستخدِم أنظمة إحداثيات أخرى تعتمد على وحدات طول مختلفة، بل ومحاور إحداثيات مختلفة. والهدف من ذلك هو اختيار نظام الإحداثيات الذي يتناسب أكثر مع ما نرغب برسمه. على سبيل المثال، إذا كنا نرسم مخططات معمارية، يُمكِننا عندها استخدام إحداثيات يُمثِّل طول كل وحدةٍ منها القيمة الفعلية لواحد قدم. يُطلَق اسم "التحويلات transforms" على التغييرات الحادثة بنظام الإحداثيات. وهناك ثلاثة أنواع بسيطة من التحويلات يمكن توضيحها في الآتي: أولًا، يُعدِّل الانتقال translate موضع نقطة الأصل (0,0). ثانيًا، يُعدِّل التحجيم scale المقياس المُستخدَم أي وحدة المسافة. ثالثًا، يُطبِّق الدوران rotation دورانًا حول نقطة. تتوفَّر تحويلات أخرى أقل شيوعًا، مثل الإمالة shear التي تُميِل الصورة بعض الشيء. تعرِض الرسمة التوضيحية التالية نفس الصورة بعد إجراء تحويلات مختلفة عليها: لاحظِ أن كل محتويات الصورة بما في ذلك النصوص، قد تأثرت بالتحويلات المُجراة. يُمكِننا أن نُجرِي تحويلًا أكثر تعقيدًا بدمج مجموعةٍ من تلك التحويلات الثلاثة البسيطة، إذ يمكننا مثلًا، تطبيق دورانٍ متبوعٍ بتحجيم، ثم بانتقال، ثم بدورانٍ مرةً أخرى. عندما نُطبِّق عدة تحويلات بالتتابع سيتراكم تأثيرها، ولهذا يكون من الصعب عادةً فهم تأثير التحويلات المعقدة فهمًا كاملًا. تُعدّ التحويلات عمومًا موضوعًا ضخمًا يُمكِن تغطيته بدورة عن الرسوم الحاسوبية computer graphics، ولكننا نناقش هنا فقط بعض الحالات البسيطة لكي نحظى بفكرةٍ عما يُمكِن لتلك التحويلات أن تفعله. يُعدّ التحويل الحالي خاصيةً مُعرَّفةً بكائن السياق الرسومي؛ فهو يُمثِّل جزءًا من الحالة التي يُخزِّنها التابع save() ويستعيدها التابع restore(). من المهم جدًا اِستخدَام التابعين save() و restore() عند التعامل مع التحويلات؛ لكي نمنع تأثير التحويلات التي يجريها برنامجٌ فرعيٌ معين على ما يتبعه من استدعاءات لبرامج فرعية أخرى. وبالمثل من بقية الخاصيات الأخرى المُعرَّفة بكائن السياق الرسومي، يمتد تأثير التحويلات على الأشياء المرسومة لاحقًا بعد تطبيق التحويلات على كائن السياق الرسومي. لنفترض أن g كائن سياق رسومي من النوع GraphicsContext، عندها يُمكِننا أن نُطبِّق انتقالًا على g باستدعاء g.translate(x,y)؛ إذ تمثِّل x و y قيمًا من النوع double، ويتلخص تأثيرها حسابيًا في إضافة (x,y) على الإحداثيات بعمليات الرسم التالية. فإذا استخدمنا مثلًا النقطة (0,0) بعد تطبيق هذا الانتقال، فإننا فعليًا نشير إلى النقطة التي إحداثياتها تساوي (x,y) وفقَا لنظام الإحداثيات القياسي، ويَعنِي ذلك أن جميع أزواج الإحداثيات قد تحركت بمقدارٍ معين. ألقِ نظرةً على التعليمتين التاليتين: g.translate(x,y); g.strokeLine( 0, 0, 100, 200 ); ترسم التعليمتان السابقتان نفس الخط الذي ترسمه التعليمة التالية: g.strokeLine( x, y, 100+x, 200+y ); تؤدي النسخة الثانية من الشيفرة نفس عملية الانتقال ولكن يدويًا، وبدلًا من محاولة التفكير بعملية الانتقال باستخدام أنظمة الإحداثيات، قد يكون من الأسهل لنا لو فكرنا بما يَحدُث للأشكال التي ستُرسَم لاحقًا، فمثلًا، بعد استدعاء التابع g.translate(x,y)، ستتحرك جميع الكائنات التي نرسمها بمسافة x من الوحدات أفقيًا وبمسافة y من الوحدات رأسيًا. وفي مثال آخر، قد يُفضِّل البعض أن تُمثِّل النقطة (0,0) منتصف مكون الحاوية بدلًا من ركنها الأيسر العلوي، ويُمكِننا ذلك باستدعاء الأمر التالي قبل رسم أي شيء: g.translate( canvas.getWidth()/2, canvas.getHeight()/2 ); يُمكِننا أن نُطبِّق تحجيمًا على g باستدعاء التابع g.scale(sx,sy)، إذ تشير المعاملات إلى معامل التحجيم بالاتجاهين x و y. وبعد تنفيذ هذا الأمر، ستُضرَّب إحداثيات x بمقدار يُساوِي sx؛ في حين تُضرَّب إحداثيات y بمقدار يُساوِي sy، ويكون تأثير ذلك على الأشياء المرسومة هو بجعلها أكبر أو أصغر. بالتحديد، تؤدي معاملات التحجيم التي تتجاوز قيمتها العدد 1 إلى تكبير حجم الأشكال؛ في حين تؤدي معاملات التحجيم التي قيمتها أقل من 1 إلى تصغير حجمها. وتُستخدَم عادةً نفس قيمة معامل التحجيم للمحورين، ويُطلَق عليه في تلك الحالة اسم "التحجيم المنتظم uniform scaling". تُعدّ النقطة (0,0) مركز التحجيم، أي النقطة التي لا تتأثر بعملية التحجيم؛ بينما تنتقل النقاط الأخرى باتجاه أو بعكس اتجاه النقطة (0,0)، وإذا لم يكن الشكل موجودًا بالنقطة (0,0)، فلا يقتصر تأثير التحجيم على تغيير حجم الشكل، وإنما نقله أيضًا بعيدًا عن النقطة (0,0) لمعاملات التحجيم الأكبر من 1 وقريبًا من النقطة (0,0) لمعاملات التحجيم الأقل من 1. يُمكِننا أيضًا أن نَستخدِم معاملات تحجيم سالبة، والتي يَنتُج عنها حدوث انعكاس، إذ تنعكس الأشكال أفقيًا مثلًا حول الخط x=0، وذلك بعد استدعاء التابع g.scale(-1,1). يُعدّ الدوران هو النوع الثالث من التحويلات البسيطة، إذ يتسبَّب استدعاء التابع g.rotate(r) بدوران جميع الأشكال التي نرسمها لاحقًا بزاوية r حول النقطة (0,0). تُقاس الزوايا بوحدة الدرجات، وتُمثِّل الزوايا الموجبة دورانًا باتجاه عقارب الساعة؛ بينما تُمثِل الزوايا السالبة دورانًا بعكس اتجاه عقارب الساعة، إلا لو كنا قد طبقنا مُسبقًا معامل تحجيم سالب، إذ يؤدي إلى عَكْس الإتجاه. لا تُعدّ الإمالة shearing عملية تحويل بسيطة، وذلك لأنه من الممكن تنفيذها (مع بعض الصعوبة) عبر متتالية من عمليات الدوران والتحجيم؛ إذ يتمثَّل تأثير الإمالة الأفقية بنقل الخطوط الأفقية إلى اليسار أو إلى اليمين بمقدار يتناسب مع المسافة من المحور الأفقي x، فتنتقل النقطة (x,y) إلى النقطة (x+a*y,y)، لأن a هو مقدار الإمالة ذاك. لا تتضمّن مكتبة JavaFX تابعًا لتطبيق عملية الإمالة، ولكنها تملُك طريقةً لتطبيق عملية تحويل عشوائي. إذا كان لديك معرفةٌ بالجبر الخطي linear algebra، فلربما تعرف أن المصفوفات تُستخدَم لتمثيل التحويلات، وأنه من الممكن تخصيص أي عملية تحويل بتحديد الأعداد الموجودة بالمصفوفة مباشرةً. تَستخدِم مكتبة JavaFX كائنات من النوع Affine لتمثيل التحويلات، ويُطبِّق التابع g.transform(t) تحويلًا t من النوع Affine على كائن السياق الرسومي. لن نتطرّق للرياضيات هنا، ولكن تُجرِي الشيفرة التالية عملية إمالة أفقية بمقدار يساوي a: g.transform( new Affine(1, a, 0, 0, 1, 0) ); قد نحتاج في بعض الأحيان إلى تطبيق عدة تحويلات للحصول على التأثير المطلوب. لنفترض مثلًا أننا نريد أن نعرض كلمة "hello world" بعد إمالتها بزاوية 30 درجة، بحيث تقع نقطتها الأصلية بالنقطة (x,y). في الواقع، لن تتمكَّن الشيفرة التالية من تنفيذ ذلك: g.rotate(-30); g.fillText("hello world", x, y); يكمُن سبب المشكلة في أن عملية الدوران تُطبَّق على كُلٍ من النقطة (x,y) والنص، وبالتالي لا تظِلّ النقطة الأصلية بالموضع (x,y) بعد تطبيق عملية الدوران؛ فكل ما نحتاج إليه هو أن نُنشِئ سلسلةً نصيةً مدارةً بنقطة أصلية واقعة عند (0,0)، ثم يُمكِننا أن ننقلها مسافة (x,y)، وهو ما سيؤدي إلى تحريك النقطة الأصلية من (0,0) إلى (x,y). تُنفِّذ الشيفرة التالية ذلك: g.translate(x,y); g.rotate(-30); g.fillText("hello world", 0, 0); ما ينبغي ملاحظته هنا هو الترتيب الذي تُطبَّق به عمليات التحويل؛ إذ تُطبَّق عملية الانتقال على جميع الأشياء التي تلي الأمر translate، والتي هي ببساطة بعض الشيفرة المسؤولة عن رسم سلسلة نصية مدارة بنقطة أصلية واقعة عند (0,0). بعد ذلك، تنتقل تلك السلسلة النصية المدارة بمسافة قدرها (x,y). يَعنِي ذلك أن السلسلة النصية تُدار أولًا ثم تُنقَل. يُمكِننا أن نُلخِص ذلك في أن عمليات الانتقال تُطبَّق بترتيبٍ معاكس لترتيب ظهور أوامر الانتقال بالشيفرة. بإمكان البرنامج التوضيحي TransformDemo.java تطبيق تشكيلةٍ مختلفة من التحويلات على صورة، كما يُمكِّن المُستخدِم من التحكُّم بمقدار كُلٍ من عمليات التحجيم والإمالة الأفقية والدوران والانتقال. قد يساعدك تشغيل البرنامج على فهم التحويلات أكثر، كما يُمكِّنك من فحص الشيفرة المصدرية للبرنامج لترى طريقة تنفيذ تلك العمليات. تُطبَّق التحويلات ضمن هذا البرنامج وفقًا للترتيب التالي: تحجيم، ثم إمالة، ثم دوران، ثم إنتقال. وإذا فحصت الشيفرة، سترى أن التوابع المسؤولة عن عمليات التحويل مُستدعاة بالترتيب المعاكس: انتقال، ثم دوران، ثم إمالة، ثم تحجيم. هنالك أيضًا عملية انتقال إضافية لتحريك نقطة الأصل إلى مركز الحاوية لكي يصبح مركز عمليات التحجيم والدوران والإمالة هو منتصف الحاوية. الحاويات المكدسة Stacked يُعدّ برنامج الرسم البسيط ToolPaint.java المثال الأخير الذي سنتعرض له بهذا القسم. كنا قد تعرضنا لبرامج رسم في جزئيات سابقة من هذه السلسلة، ولكنها كانت مقتصرةً على رسم المنحنيات فقط، ولكن سيُمكِّن البرنامج الجديد المُستخدِم من اختيار أداةٍ للرسم، إضافةً إلى أداة رسم المنحنيات؛ إذ سيتضمَّن البرنامج أدوات لرسم خمسة أنواع من الأشكال موضحة في الآتي: خطوط مستقيمة. مستطيلات. أشكال بيضاوية . مستطيلات ملونة. أشكال بيضاوية ملونة. عندما يَرسِم المُستخدِم شكلًا بأي من أدوات الرسم الخمسة، فعليه سحَب الفأرة ورسم الشكل من النقطة التي بدأت عندها عملية السحب إلى الموضع الحالي للفأرة. وبكل مرة تتحرك خلالها الفأرة، يُحذَف الشكل السابق ويُرسَم شكلٌ جديد. بالنسبة لأداة رسم الخط مثلًا، يكون التأثير هو رؤية خطٍ مُمتدٍ من نقطة بدء عملية السحب إلى الموضع الحالي لمؤشر الفأرة، ويتحرك هذا الخط مع حركة الفأرة. سيكون من الأفضل لو شغّلت البرنامج وجرَّبت هذا التأثير بنفسك. تَكْمُن صعوبة برمجة ذلك في أن بعض أجزاء الرسمة تُغطَّى وتُكشَف باستمرار، بينما يُحرِّك المُستخدِم مؤشر الفأرة؛ وعندما يُغطَّى جزءٌ من الرسمة الحالية ثم يُكشَف عنه مرةً أخرى، فلا بُدّ أن تَظلّ الرسمة ظاهرة. ويَعنِي ذلك أن البرنامج لا يستطيع أن يرسم الشكل على نفس الحاوية المُتضمِّنة للرسمة؛ لأن ذلك سيَمحِي ما كان موجودًا بذلك الجزء من الرسمة. يتلخص الحل بمكتبة JavaFX باستخدام مكوني حاوية واحدًا فوق الآخر؛ بحيث يحتوي مكون الحاوية السفلي على الرسمة الفعلية؛ بينما يُستخدَم مكون الحاوية العلوي لتنفيذ أدوات رسم الأشكال. ينبغي أن تكون الحاوية العلوية شفافة، أي أن تُملأ بلونٍ درجة شفافيته تُساوِي صفر. تُرسَم الأشكال بالحاوية العلوية بينما يسَحب المُستخدِم مؤشر الفأرة بعد اختياره لأداة رسم معينة، وبالتالي لا تتأثر الحاوية السفلية مع إمكانية اختفاء بعض أجزائها خلف الشكل المرسوم بالحاوية العلوية. في كل مرة يتحرك خلالها مؤشر الفأرة، ستُحذَف مكونات الحاوية العلوية وسيُعاد رسم الشكل الجديد بها؛ وعندما يُحرِر المُستخدِم زر الفأرة بنهاية عملية السحب، ستُحذَف مكونات الحاوية العلوية وسيُرسَم الشكل هذه المرة بالحاوية السفلية ليُصبِح جزءًا من الرسمة الفعلية، وبالتالي تُصبِح الحاوية العلوية شفافةً مجددًا بينما تُصبِح مكونات الحاوية السفلية مرئيةً بالكامل. يُمكِننا حذف محتويات حاوية لتصبح بعدها شفافةً تمامًا باستدعاء التعليمة التالية: g.clearRect( 0, 0, canvas.getWidth(), canvas.getHeight() ); إذ أن g هو كائن السياق الرسومي من النوع GraphicsContext المقابل للحاوية، وتكون الحاويات شفافةً بالكامل بمجرد إنشائها. يُمكننا استخدام كائنٍ من النوع StackPane لوضع حاويةٍ فوق حاوية أخرى، إذ تُرتِّب كائنات الصنف StackPane عُقدها nodes الأبناء بعضها فوق بعض بنفس ترتيب إضافتها إليها؛ وعليه، يُمكِننا إنشاء الحاويتين المُستخدمتين بهذا البرنامج على النحو التالي: canvas = new Canvas(width,height); // حاوية الرسم الرئيسية canvasGraphics = canvas.getGraphicsContext2D(); canvasGraphics.setFill(backgroundColor); canvasGraphics.fillRect(0,0,width,height); overlay = new Canvas(width,height); // الحاوية الشفافة العلوية overlayGraphics = overlay.getGraphicsContext2D(); overlay.setOnMousePressed( e -> mousePressed(e) ); overlay.setOnMouseDragged( e -> mouseDragged(e) ); overlay.setOnMouseReleased( e -> mouseReleased(e) ); StackPane canvasHolder = new StackPane(canvas,overlay); ملاحظة: أُضيفت معالجات أحداث الفأرة إلى الحاوية العلوية لا السفلية، لأنها تُغطِي الحاوية السفلية؛ فعندما يَنقُر المُستخدِم على الرسمة الموجودة بالحاوية السفلية، فإنه فعليًا ينقر على الحاوية العلوية. بالمناسبة، لا تَستخدِم أداة رسم المنحنيات الحاوية العلوية تحديدًا، وإنما تُرسَم المنحنيات مباشرةً بالحاوية السفلية. ونظرًا لأن المُستخدِم لا يستطيع حذف أي جزء من المنحنى بعد رسمه، لا حاجة لوضع نسخةٍ مؤقتةٍ منه بالحاوية العلوية. العمليات على البكسلات يتضمَّن البرنامج ToolPaint.java أداتين إضافيتين، هما "الحذف Erase" و "التلطيخ Smudge"، إذ تَعمَل كلتاهما بالحاوية السفلية مباشرةً. تَعمَل أداة الحذف على النحو التالي: بينما يَسحَب المُستخدِم مؤشر الفأرة بعد اختيار تلك الأداة، يُملأ مربعٌ صغير حول موضع المؤشر باللون الأسود لكي يحذف جزء الرسمة الموجود بهذا الموضع. وفي المقابل، تَعمَل أداة التلطيخ على النحو التالي: يؤدي السَحْب بعد اختيار تلك الأداة إلى تلطيخ اللون أسفل الأداة، تمامًا كما لو سحبت إصبعك عبر لون مبلل. تُبيِّن الصورة التالية لقطة شاشة من البرنامج بعد سَحْب أداة التلطيخ حول مركز مستطيل أحمر: لا تتضمَّن مكتبة JavaFX برنامجًا فرعيًا مبنيًا مُسبقًا لإجراء تلطيخ على صورة، فهو أمرٌ يتطلّب معالجةً مباشرةً لألوان البكسلات كُلٌ على حدى. تتلخص الفكرة الأساسية لتلك الأداة فيما يلي: يَستخدِم البرنامج ثلاث مصفوفات ثنائية البعد بحجم 9 x 9، واحدةٌ لكل مُكوِّن لون؛ أي واحدةٌ تَحمِل المكون الأحمر؛ وواحدة للمكون الأخضر؛ والأخيرة للمكون الأزرق. عندما ينقر المُستخدِم على الفأرة أثناء استخدام أداة التلطيخ، تُنسَخ مكونات لون البكسلات الموجودة داخل المربع المحيط بمؤشر الفأرة من الصورة إلى المصفوفات الثلاثة، وعندما يُحرِّك المُستخدِم الفأرة، تمتزج بعضًا من ألوانها بألوان البكسلات الموجودة بالمكان الجديد لمؤشر الفأرة. وبنفس الوقت، يُنسَخ بعضٌ من ألوان الصورة إلى المصفوفات، أي أن المصفوفات تُسقِط بعضًا من الألوان التي تحملها وتلتقط بعض الألوان من المكان الجديد. إذا فكرت بالأمر قليلًا، سترى أن ذلك يشبه تمامًا ما يحدث عندما تُمرِّر إصبعك بدهانٍ حديث. ينبغي إذًا أن نتمكَّن من قراءة ألوان البكسلات الموجودة بالصورة لكي نُنفِّذ تلك العملية، كما علينا أن نكون قادرين على كتابة ألوان جديدة بتلك البكسلات. في الواقع، من السهل كتابة الألوان بالبكسلات باستخدام الصنف PixelWriter؛ فإذا كان g كائن سياق رسومي من النوع GraphicsContext، وكان هذا الكائن مُرتبطًا بمكون حاوية، عندها يُمكِننا إنشاء كائنٍ من النوع PixelWriter لتلك الحاوية باستدعاء ما يلي: PixelWriter pixWriter = g.getPixelWriter(); ولكي نتمكَّن من ضبط لون البكسل الموجود بالنقطة (x,y) من الحاوية، يُمكِننا استدعاء التابع التالي: pixWriter.setColor( x, y, color ); إذ أن color هو كائنٌ من النوع Color، وتمثِّل x و y إحداثيات بكسل، وهم ليسوا عُرضَةً لأي عملية تحويل قد تُطبَّق على كائن السياق الرسومي. لو كانت مكتبة JavaFX توفِّر طريقةً سهلةً لقراءة ألوان البكسلات من الحاوية، فسنكون قد انتهينا فعلًا، ولكن لسوء الحظ، ليس الأمر بهذه البساطة؛ إذ يبدو أن عمليات الرسم لا تُطبَّق على الحاوية على الفور، وإنما تُخزَّن مجموعةٌ من عمليات الرسم، ثم تُرسَل إلى عتاد جهاز الرسوم دفعةً واحدة، وذلك لرفع الكفاءة. بالتحديد، تُرسَل مجموعة العمليات فقط عندما تُصبِح عملية إعادة رسم الحاوية على الشاشة ضرورية. ويَعنِي ذلك، أننا لو قرأنا لون بكسل من الحاوية، فإننا لا نَضمَن أن تكون القيمة التي نحصل عليها متضمّنةً لكل أوامر الرسم التي طبقناها على الحاوية. وبناءً على ذلك، إذا أردنا أن نقرأ ألوان البكسلات، فسنضطّر لفعل شيءٍ ما لضمان اكتمال جميع عمليات الرسم، مثل أخذ لقطة شاشة للحاوية. يُمكِننا أن نأخذ لقطة شاشة لأي عقدة بمبيان المشهد، وتعيد تلك العملية قيمةً من النوع WritableImage تحتوي على صورة للعقدة بعد تطبيق جميع العمليات قيد الانتظار. تلتقط التعليمة التالية صورةً لعقدة بأكملها: WritableImage nodePic = node.snapshot(null,null); يحتوي كائن الصنف WritableImage على صورةٍ للعقدة كما هي ظاهرةٌ على الشاشة، ويُمكِننا أن نَستخدِمه مثل أي كائن صورة عادي ينتمي إلى الصنف Image. يَستقبِل التابع من التعليمة السابقة معاملات من النوع SnapshotParameter و WriteableImage؛ فإذا مررنا صورةً غير فارغة قابلة للكتابة للمعامل الثاني، فسيَستخدِمها التابع مثل صورة طالما كانت كبيرةً كفاية لتَحمِل صورة العقدة، وربما يكون ذلك أكثر كفاءةً من إنشاء صورةٍ جديدة قابلة للكتابة. بالنسبة للمعامل الأول، يُمكِن اِستخدَامه للتحكُّم بالصورة الناتجة على نحوٍ أكبر، إذ يمكنه تحديدًا الطلب بأن تكون لقطة الشاشة مقتصرةً على مستطيلٍ معين من العقدة فقط. والآن، لكي نُنفِّذ أداة التلطيخ، ينبغي أن نقرأ البكسلات من مستطيلٍ صغيرٍ بالحاوية، إذ تبلغ مساحة ذلك المستطيل 9*9؛ ولإجراء ذلك بكفاءة، سنُمرِّر كائنًا من النوع SnapshotParameter لكي يُخصِّص رغبتنا بلقطة شاشة لذلك المستطيل لا الحاوية بالكامل، وبذلك نكون قد حصلنا على ذلك المستطيل ضمن كائن صورة من النوع WritableImage. وبعد ذلك، سنَستخدِم كائنًا من النوع PixelReader لقراءة ألوان البكسلات من الكائن الذي حصلنا عليه. في الواقع، يتضمَّن تنفيذ ذلك كثيرًا من التفاصيل، ولذلك سنعرض فقط طريقة التنفيذ. يُنشِئ البرنامج كائنًا واحدًا من كل نوعٍ من الأنواع التالية WritableImage و PixelReader و SnapshotParameter لاستخدامها مع جميع لقطات الشاشة، وقد تقع بعض البكسلات خارج الحاوية لبعض لقطات الشاشة، وهو ما يُعقدّ الأمور قليلًا. ألقِ نظرةً على الشيفرة التالية: pixels = new WritableImage(9,9); // a 9-by-9 writable image pixelReader = pixels.getPixelReader(); // a PixelReader for the writable image snapshotParams = new SnapshotParameters(); عندما ينقر المُستخدِم على زر الفأرة، ينبغي أن تُؤخذ لقطة شاشة مربعة لجزء الحاوية المحيط بمكان مؤشر الفأرة الحالي (startX,startY)، ثم تُنسخ بيانات الألوان من تلك اللقطة إلى مصفوفات مكونات الألوان smudgeRed و smudgeGreen و smudgeBlue. ألقِ نظرةً على الشيفرة التالية: snapshotParams.setViewport( new Rectangle2D(startX - 4, startY - 4, 9, 9) ); // 1 canvas.snapshot(snapshotParams, pixels); int h = (int)canvas.getHeight(); int w = (int)canvas.getWidth(); for (int j = 0; j < 9; j++) { // صفٌ في لقطة الشاشة int r = startY + j - 4; // الصف المقابل بالحاوية for (int i = 0; i < 9; i++) { // عمودٌ في لقطة الشاشة int c = startX + i - 4; // العمود المقابل بالحاوية if (r < 0 || r >= h || c < 0 || c >= w) { // 2 smudgeRed[j][i] = -1; } else { Color color = pixelReader.getColor(i, j); // pixelReader gets color from the snapshot smudgeRed[j][i] = color.getRed(); smudgeGreen[j][i] = color.getGreen(); smudgeBlue[j][i] = color.getBlue(); } } } حيث أن: [1] يُمثِّل viewport المستطيل الموجود بالحاوية والذي سيُضمَّن بلقطة الشاشة. [2] تعني أن النقطة (c,r) تقع خارج الحاوية، كما تشير قيمة -1 بالمصفوفة smudgeRed إلى أن البكسل كان خارج الحاوية. والآن، علينا مزج اللون الموجود بمصفوفات مكونات الألوان بمربع البكسلات المحيط بالنقطة (x,y)؛ ولكي نُنفِّذ ذلك، سنأخذ لقطة شاشة مربعة جديدة لمربع البكسلات المحيط بالنقطة (x,y)؛ وبمجرد حصولنا على تلك اللقطة، يُمكِننا إجراء الحسابات الضرورية لعملية المزج، ونكتب بعدها اللون الجديد الناتج إلى الحاوية باستخدام كائن الصنف PixelWriter المسؤول عن الكتابة بالحاوية. ألقِ نظرةً على الشيفرة التالية: snapshotParams.setViewport( new Rectangle2D(x - 4, y - 4, 9, 9) ); canvas.snapshot(snapshotParams, pixels); for (int j = 0; j < 9; j++) { // صف بلقطة الشاشة int c = x - 4 + j; // الصف المقابل بالحاوية for (int i = 0; i < 9; i++) { // عمود بلقطة الشاشة int r = y - 4 + i; // العمود المقابل بالحاوية if ( r >= 0 && r < h && c >= 0 && c < w && smudgeRed[i][j] != -1) { // اِسترجِع لون البكسل الحالي من لقطة الشاشة Color oldColor = pixelReader.getColor(j,i); // 1 double newRed = (oldColor.getRed()*0.8 + smudgeRed[i][j]*0.2); double newGreen = (oldColor.getGreen()*0.8 + smudgeGreen[i][j]*0.2); double newBlue = (oldColor.getBlue()*0.8 + smudgeBlue[i][j]*0.2); // اكتب لون البكسل الجديد إلى الحاوية pixelWriter.setColor( c, r, Color.color(newRed,newGreen,newBlue) ); // امزج جزء من اللون الموجود بالحاوية إلى المصفوفات smudgeRed[i][j] = oldColor.getRed()*0.2 + smudgeRed[i][j]*0.8; smudgeGreen[i][j] = oldColor.getGreen()*0.2 + smudgeGreen[i][j]*0.8; smudgeBlue[i][j] = oldColor.getBlue()*0.2 + smudgeBlue[i][j]*0.8; } } } إذ تعني [1]: احصل على لون جديد للبكسل عن طريق دمج اللون الحالي مع مكونات اللون المُخزَّنة بالمصفوفات. في الواقع، هذه عمليةٌ معقدةٌ نوعًا ما، ولكنها أوضحت طريقة التعامل مع البكسلات إلى حدٍ ما. عليك أن تتذكر أنه من الممكن كتابة ألوان البكسلات إلى الحاوية باستخدام كائنٍ ينتمي إلى الصنف PixelWriter؛ ولكن ينبغي لقراءة البكسلات منها، أن نأخذ لقطة شاشة للحاوية، ثم نَستخدِم كائنًا من الصنف PixelReader لقراءة ألوان البكسلات من كائن النوع WritableImage الذي يحتوي على لقطة الشاشة. عمليات الدخل والخرج للصور يحتوي البرنامج التوضيحي ToolPaint.java على قائمة "File" تحتوي على أمرٍ لتحميل صورةٍ من ملفٍ إلى حاوية؛ وأمرٍ آخر لحفظ صورة من حاوية إلى ملف. بالنسبة لأمر تحميل الصورة، سنحتاج أولًا إلى تحميل الصورة إلى كائنٍ من النوع Image. كنا قد اطلعنا بمقال التعرف على بعض أصناف مكتبة جافا إف إكس JavaFX البسيطة على طريقة تحميل صورةٍ من ملف مورد، وكيفية رسمها ضمن حاوية. وبنفس الطريقة تقريبًا، يُمكِن تحميل صورة من ملف على النحو التالي: Image imageFromFile = new Image( fileURL ); يُمثِّل المعامل سلسلةً نصيةً تُخصِّص موقع الملف بهيئة محدّد موارد موّحد URL، وهو ببساطة مسار الملف مسبوقٌ بكلمة "file:"؛ فإذا كان imageFile كائنًأ من النوع File يحتوي على مسار الملف، فيُمكِننا ببساطة كتابة ما يلي: Image imageFromFile = new Image( "file:" + imageFile ); نَستخدِم عادةً كائن نافذة اختيار ملف من النوع FileChooser لنسمَح للمُستخدِم باختيار ملف -ألقِ نظرةً على مقال مدخل إلى التعامل مع الملفات في جافا-، وعندها سيكون imageFile هو الملف المختار الذي تُعيده تلك النافذة. قد يختار المُستخدِم ملفًا لا يُمكِن قراءته، أو لا يحتوي على صورة، وهنا لا يُبلِّغ باني كائن الصنف Image عن استثناءٍ في تلك الحالة، وإنما يَضبُط متغيرًا مُعرَّفًا بالكائن لكي يُشير إلى وجود خطأ؛ لذلك علينا فحص ذلك المتغير باستخدام الدالة imageFromFile.isError() التي تعيد قيمةً من النوع boolean، وفي حالة وجود خطأ فعلًا، يُمكِننا أن نَستعيد الاستثناء المُتسبِّب بالخطأ باستدعاء الدالة imageFromFile.getException(). بمجرد حصولنا على الصورة وتأكُّدنا من عدم وجود خطأ، يُمكِننا أن نرسمها بالحاوية باستخدام كائن السياق الرسومي الخاص بالحاوية. يَضبُط الأمر التالي حجم الصورة، بحيث تملأ الحاوية كاملةً: g.drawImage( imageFromFile, 0, 0, canvas.getWidth(), canvas.getHeight() ); سنضع جميع ما سبق ضمن تابعٍ، اسمه doOpenImage()، وسيَستدعيه البرنامج ToolPaint لتحميل الصورة المُخزَّنة بالملف الذي اختاره المُستخدِم. ألقِ نظرةً على تعريف التابع: private void doOpenImage() { FileChooser fileDialog = new FileChooser(); fileDialog.setInitialFileName(""); fileDialog.setInitialDirectory( new File( System.getProperty("user.home") ) ); fileDialog.setTitle("Select Image File to Load"); File selectedFile = fileDialog.showOpenDialog(window); if ( selectedFile == null ) return; // لم يختر المُستخدِم أي ملف Image image = new Image("file:" + selectedFile); if (image.isError()) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, an error occurred while\ntrying to load the file:\n" + image.getException().getMessage()); errorAlert.showAndWait(); return; } canvasGraphics.drawImage(image,0,0,canvas.getWidth(),canvas.getHeight()); } والآن، لكي نستخرِج الصورة من حاويةٍ إلى ملف، ينبغي أن نحصل أولًا على الصورة من الحاوية، وذلك بأخذ لقطة شاشة للحاوية بالكامل، وهو ما سنفعله كما يلي: Image image = canvas.snapshot(null,null); لسوء الحظ، لا تتضمَّن مكتبة JavaFX -بإصدارها الحالي على الأقل- خاصية حفظ الصور إلى ملفات؛ ولهذا سنعتمد على الصنف BufferedImage من حزمة java.awt.image بأداة تطوير واجهات المُستخدِمة الرسومية AWT القديمة، إذ يُمثِّل ذلك الصنف صورةً مُخزّنةً بذاكرة الحاسوب، مثل الصنف Image بمكتبة JavaFX. في الواقع، يُمكِننا بسهولة تحويل كائن من النوع Image إلى النوع BufferedImage باستخدام تابعٍ ساكن static مُعرَّف بالصنف SwingFXUtils من حزمة javafx.embed.swing على النحو التالي: BufferedImage bufferedImage = SwingFXUtils.fromFXImage(canvasImage,null); يُمكِننا أن نُمرِّر كائنًا من النوع BufferedImage مثل معاملٍ ثانٍ للتابع ليَحمِل الصورة، والذي من الممكن أن يأخذ القيمة null. بمجرد حصولنا على كائن الصنف BufferedImage، يُمكِننا استخدام التابع الساكن التالي المُعرَّف بالصنف ImageIO من حزمة javax.imageio لكتابة الصورة إلى ملف: ImageIO.write( bufferedImage, format, file ); يُمثِّل المعامل الثاني للتابع السابق سلسلةً نصيةً من النوع String، إذ تخصِّص تلك السلسلة صيغة الملف الذي ستُحفَظ إليه الصورة. يُمكِن حفظ الصور عمومًا بعدة صيغ بما في ذلك "PNG" و "JPEG" و "GIF"، ويستخدم البرنامج ToolPaint صيغة "PNG" دائمًا. يُمثِل المعامل الثالث كائنًا من النوع File، ويُخصِّص بيانات الملف المطلوب حفظه، إذ يُبلِّغ التابع ImageIO.write() عن استثناءٍ، إذا لم يتمكَّن من حفظ الملف؛ وإذا لم يتعرف التابع على الصيغة، فإنه يَفشَل أيضًا، ولكنه لا يُبلِّغ عن استثناء. يُمكِننا الآن أن نُضمِّن كل شيء معًا داخل التابع doSaveImage() بالبرنامج ToolPaint. ألقِ نظرةً على تعريف التابع: private void doSaveImage() { FileChooser fileDialog = new FileChooser(); fileDialog.setInitialFileName("imagefile.png"); fileDialog.setInitialDirectory( new File( System.getProperty("user.home") ) ); fileDialog.setTitle("Select File to Save. Name MUST end with .png!"); File selectedFile = fileDialog.showSaveDialog(window); if ( selectedFile == null ) return; // لم يختر المُستخدِم أي ملف try { Image canvasImage = canvas.snapshot(null,null); BufferedImage image = SwingFXUtils.fromFXImage(canvasImage,null); String filename = selectedFile.getName().toLowerCase(); if ( ! filename.endsWith(".png")) { throw new Exception("The file name must end with \".png\"."); } boolean hasFormat = ImageIO.write(image,"PNG",selectedFile); if ( ! hasFormat ) { // لا ينبغي أن يحدث ذلك نهائيًا throw new Exception( "PNG format not available."); } } catch (Exception e) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, an error occurred while\ntrying to save the image:\n" + e.getMessage()); errorAlert.showAndWait(); } } ترجمة -بتصرّف- للقسم Section 2: Fancier Graphics من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: الخاصيات والارتباطات في جافا مقدمة إلى برمجة واجهات المستخدم الرسومية (GUI) في جافا1 نقطة
-
Inline elements: تقوم بصف العناصر بجانب بعضها البعض على اليمين واليساريمكنك تحديد left & right , margins and padding للنعصر ولا تسمح لك تحديد top & bottomلا يمكنك تحديد width , height Block elements: تسمح بتحديد جميع الخصائصتقوم بإضافة سطر بين العنصر والآخر (تقوم بصف العناصر فوق بعضها البعض)Inline-block elements: تقوم بصف العناصر بجانب بعضها البعض على اليمين واليسارتسمح لك بتحديد top & bottom , margin and paddingتسمح بتحديد width , heightالمصدر1 نقطة