تعتمد برمجة واجهات المُستخدِم الرسومية (graphical user interface) اعتمادًا رئيسيًا على ما يُعرَف باسم الأحداث (events). بخلاف برامج الطرفية، لا تَحتوِي برامج واجهة المُستخدِم الرسومية (GUI) على البرنامج main()
الذي اعتدنا اِستخدَامه لوَصْف ما ينبغي للبرنامج القيام به منذ لحظة تَشْغِيله خطوة بخطوة وحتى انتهائه، وإنما لابُدّ من إعداد برامج الواجهة بحيث تَتَمكَّن من الاستجابة إلى الأنواع المختلفة من الأحداث (events) التي يُمكِنها أن تَقَع بأوقات غَيْر مُتوقعة وبترتيب لا يُمكِن التَحكُّم به. تَقَع مثلًا بعض من أبسط أنواع الأحداث نتيجة لاِستخدَام الفأرة أو لوحة المفاتيح مثل أن يَضغَط المُستخدِم على مفتاح بلوحة المفاتيح أو يُحرِك الفأرة أو يَضغَط على أحد أزرار الفأرة. يستطيع المُستخدِم عمومًا أن يقوم بأي من تلك الأمور بأي لحظة ولابُدّ للحاسوب عندها من أن يستجيب بشكل مناسب.
تُمثَل الأحداث (events) بالجافا باِستخدَام كائنات (objects)، فعندما يَقَع حَدَث معين، يُجمّع النظام كل المعلومات المُرتبِطة بذلك الحَدَث (event) ثم يُنشِئ كائنًا يَتَضمَّن تلك المعلومات. تَتَوفَّر أصناف (classes) مختلفة لتمثيل الأنواع المختلفة من الأحداث، فمثلًا، يُنشَئ كائن ينتمي للصَنْف MouseEvent
عندما يَضغَط المُستخدِم على أحد أزرار الفأرة بينما يُنشَئ كائن ينتمي للصنف KeyEvent
عندما يَضغَط المُستخدِم على مفتاح بلوحة المفاتيح. في حين يَحتوِي الكائن الأول على المعلومات المُتعلِّقة بحَدَث الضغط على زر الفأرة والتي تُجيب عن أسئلة مثل: أي مُكوِّن واجهة نَقر عليه المُستخدِم بالتحديد ويُعرَف ذلك المُكوِّن باسم مقصد الحَدَث (target event)؟ أي نقطة (x,y) ضِمْن المُكوِّن قد نقر عليها بالتحديد؟ هل ضَغَطَ على أحد مفاتيح التباديل (modifier keys) كالمفتاح "shift"؟ أي مفتاح تحديدًا بالفأرة ضَغَطَ عليه؟ في المقابل، يَحتوِي الكائن الآخر على المعلومات المُتعلِّقة بحَدَث الضغط على مفتاح بلوحة المفاتيح. تُمرَّر تلك الكائنات المُنشَئة كمُعامِل (parameter) إلى تابع (method) يُعرَف باسم مُعالِج الحَدَث (event handler) ويُكتَب عادةً بهيئة تعبير لامدا (lambda expressions) بمنصة جافا إف إكس (JavaFX). تُعدّ كتابة مُعالِجات الأحداث (event handler) بمثابة تبلّيغ للحاسوب بما ينبغي له القيام به عند وقوع تلك الأحداث.
ينبغي لأي مبرمج جافا أن يَكُون على إطلاع بماهية الأحداث (events) في العموم. تَقَع كثير من الأمور منذ لحظة وقوع حَدَث (event) معين كضَغْط المُستخدِم على مفتاح معين أو كتحريكه للفأرة وحتى لحظة استدعاء التابع (method) الذي يُفْترَض له أن يَستجِيب لذلك الحَدَث. في الواقع، أنت لا تحتاج لمَعرِفة ذلك تفصيليًا، وإنما ينبغي فقط أن تفهم التالي: أثناء زمن التَشْغِيل، هُنالك برنامج (routine) بمكان ما يُنفِّذ حَلْقة التَكْرار (loop) التالية:
// بينما البرنامج قيد التنفيذ while the program is still running: // انتظر إلى أن يحين موعد الحدث التالي Wait for the next event to occur // عالج الحدث Handle the event
تُسمَى تلك الحَلْقة بحَلْقة الأحداث (event loop)، والتي لابُدّ لأي برنامج واجهة مُستخدِم رسومية (GUI program) من أن يُنفِّذها. لا تحتاج لكتابة تلك الحَلْقة بنفسك ببرامج الواجهة بلغة الجافا لأنها تُعدّ بالفعل جزءًا من النظام، ولكن قد تَضطرّ لذلك بلغة برمجية آخرى.
سنَفْحَص خلال هذا القسم كيفية معالجة حَدَثي الفأرة (mouse event) والمفتاح (key event) بلغة الجافا كما سنُغطِي إِطار عمل معالجة الأحداث (handling events) في العموم. وأخيرًا، سنرى طريقة إنشاء تَحرِيكة (animation).
معالجة الأحداث
لكي يُصبِح لحَدَث (event) معين تأثير ضِمْن برنامج، فلابُدّ لذلك البرنامج من أن يَستمِع (listen) إلى ذاك الحَدَث (event) حتى يَتَمكَّن من رصده، ومِن ثَمَّ الإستجابة إليه. تُوكَل مُهِمّة الاستماع إلى الأحداث إلى ما يُعرَف باسم مُستمِع الحَدَث (event listener)، والذي يُعرَّف باِستخدَام واجهة (interface) تَصِف توابع مُعالجات الأحداث (event handler methods) الخاصة به والتي تَكُون مسئولة بشكل فعليّ عن الاستجابة على تلك الأحداث. غالبًا ما تُعرِّف تلك الواجهات (interface) تابع مُعالج حَدَث (event handler method) وحيد فيما يُعرَف باسم واجهات نوع الدالة (functional interface)، وفي تلك الحالة، قد يُخصَّص المُستمِع (listener) بهيئة تعبير لامدا (lambda expression). تُستخدَم واجهات (interfaces) مختلفة لتعريف مُستمعِي الأحداث (events) من الأصناف المختلفة.
يُعرَّف مُستمِع الحدث (event listener) لغالبية الأحداث بمنصة جافا إف إكس (JavaFX) باِستخدَام واجهة نوع الدالة EventHandler
. تُعرِّف تلك الواجهة التابع handle(event)
والذي يَستقبِل كائن حَدَث (event object) من الصنف Event
يتضمن معلومات عن ذلك الحدث. عندما تُوفِّر تعريفًا (definition) للتابع handle()
، فأنت تَكْتُب الشيفرة التي ستُنفَّذ لمعالجة الحدث.
في الواقع، الصَنْف EventHandler
هو نوع مُعمَّم أو نوع مُحدَّد بمُعامِلات نوع (parameterized type) أي يُعرِّف عدة أنواع مختلفة: EventHandler<MouseEvent>
و EventHandler<KeyEvent>
و EventHandler<ActionEvent>
. في حين يُعرِّف النوعان EventHandler<MouseEvent>
و EventHandler<KeyEvent>
التابع handle(event)
، فإن مُعامِل ذلك التابع event
يَكُون من الصنف MouseEvent
بالنوع الأول ومن الصنف KeyEvent
بالثاني. في الحقيقة، نحن لم ولن نَتعرَض للأنواع المُعمَّمة (parameterized types) حتى القسم ٧.٣، ولا حاجة لذلك عمومًا فيما هو مُتعلِّق بهذا الفصل، فكل ما أنت بحاجة لمَعرِفته هو فكرة أن كائن الحَدَث (event object) المُستخدَم للمعالجة دائمًا ما سيتناسب مع ذلك الحَدَث، فمثلًا، يَكُون كائن الحَدَث من النوع MouseEvent
عند معالجة حَدَث فأرة.
تَرتَبِط غالبية الأحداث (events) بمنصة جافا إف إكس (JavaFX) بإحدى مُكوِّنات واجهة المُستخدِم الرسومية (GUI components). فمثلًا، عندما يَضغَط المُستخدِم على زر بالفأرة، يُعدّ المُكوِّن المُتْضمِّن لمؤشر الفأرة عند وقوع حَدَث الضُغط مُرتبِطًا به، ويُسمَى هذا الكائن بمقصد الحَدَث (event target). لكي تَتَمكَّن من الاستجابة لحدث معين، ستحتاج إلى تسجيل مُستمِع (listener) إِما بمقصد الحَدَث (event target) أو بكائن آخر على علم بذاك الحَدَث. لنَفْحَص مثلًا التَعْليمَة التالية من برنامج HelloWorldFX.java من القسم ٦.١:
helloButton.setOnAction( e -> message.setText("Hello World!") );
بالأعلى، helloButton
هو كائن زر من النوع Button
. عندما ينقر المُستخدِم على هذا الزر بالتحديد، سيقع حَدَث (event) من النوع ActionEvent
مقصده (target) هو helloButton
. يُسجِّل التابع helloButton.setOnAction()
مُستمِع حَدَث (event listener) يُفْترَض له أن يَستقبِل تنبيهًا بكل مرة يقع فيها حَدَث من النوع ActionEvent
من ذلك الزر. عُرِّف المُستمِع بالأعلى بهيئة تعبير لامدا (lambda expression) يُنفَّذ كاستجابة على ذلك الحَدَث عند وقوعه، وبحيث يَستقبِل كائن الحَدَث من الصنف ActionEvent
كقيمة للمُعامِل e
. سنُعالج غالبية الأحداث (events) ضمن هذا الفصل بنفس الطريقة.
لا تَقْتصِر الاستجابة على بعض أحداث المفاتيح والفأرة على مقصد الحَدَثَ (event target) فقط. فمثلًا، عندما تَضغَط على زر الفأرة أثناء وجود مؤشرها بحاوية (canvas) من الصَنْف Canvas
واقعة ضِمْن مُكوِّن حاوية من الصَنْف BorderPane
بمشهد من الصَنْف Scene
، يَكُون عندها مقصد الحَدَثَ هو كائن الصَنْف Canvas
، ولكن يُمكِن أيضًا لكائني الصَنْفين BorderPane
و Scene
أن يستجيبا لذلك الحَدَث أي يُمكِن تَسْجيل مُستمِع الحدث (event listener) بأي من تلك الكائنات، ويُعرَف عندها هذا الكائن باسم "مصدر الحدث (event source)". يَستقبِل تابع معالجة الحدث (event handler method) مُعامِل كائن الحدث evt
والذي يَملُك مصدر ومقصد يُعطَى باستدعاء كُلًا من evt.getSource()
و evt.getTarget()
على الترتيب، وغالبًا ما يكونا نفس الكائن ولكنه ليس ضروريًا. لاحِظ أنه يُمكِن إرسال نفس ذات الحدث (event) إلى أكثر من معالج (handler)، ولكن إذا استهلك أي منها الحَدَثَ باستدعاء evt.consume()
، سيَتَوقَّف إرساله إلى أي معالجات آخرى. مثلًا، عندما تَكْتُب بصندوق إدخال (input box) نصي، فإنه يستهلك أحداث المفاتيح الواقعة أثناء الكتابة، حتى لا يُعطِى أي فرصة للمشهد (scene) المُتْضمِّن للصندوق لمُعالجتها.
الأمر في الحقيقة أكثر تعقيدًا من ذلك بقليل. تنتقل بعض أحداث الفأرة والمفاتيح أولًا عبر المشهد (scene) ثم عبر عُقَد مبيان المشهد (scene graph nodes) الحاضنة لمقصد الحدث (event target)، وهو ما يُعرَف بمرحلة "ترشيح الأحداث (event filtering)" أو " (bubble down)" من معالجة الحدث. بعد وصوله للمقصد (target)، ينتقل الحدث عائدًا عبر مبيان المشهد (scene graph) ثم إلى المشهد (scene)، وهو ما يعرف بمرحلة "معالجة الأحداث (event handling)" أو "(bubble up)". قد يُستهَلك الحدث بأي نقطة وعندها تَتَوقَّف العملية. لن نتعرض لذلك خلال هذا الفصل، ولكن يمكنك الإطلاع على توثيق التابعين addEventFilter()
و addEventHandler()
بالصنفين Scene
و Node
لمزيد من المعلومات.
سنهتم بأحداث المفاتيح (key events) والفأرة (mouse events) بهذا القسم. في الحقيقة، لا تحتاج كثير من برامج واجهات المُستخدِم الرُسومية (GUI programs) للتَعامُل مع تلك الأحداث مباشرةً، وإنما عادةً ما تَتَعامَل مع مُكوِّنات الواجهة المُبرمَجَة بالفعل لمُعالجة تلك الأحداث. فمثلًا، عندما ينقر المُستخدِم على زر من الصَنْف Button
، فإن الزر يَكُون مُبرمَجًا بالفعل ليَستمِع لأحداث الفأرة (mouse events) ومِنْ ثَمَّ يَستجيب لها بتوليد كائن حَدَثَ من الصَنْف ActionEvent
. لذا عند كتابة تطبيق يحتوي على أزرار، غالبًا ما ستَكْتُب معالج لأحداث الصَنْف ActionEvent
وليس لأحداث الفأرة (mouse events). بالمثل، عندما يَكْتُب المُستخدِم داخل صندوق إِدْخَال نصي (input box)، فإن الصندوق يَكُون مُبرمَجًا بالفعل ليَستمِع لأحداث المفاتيح (key events) ومِنْ ثَمَّ يَستجِيب لها. ولكن بالنهاية سيَكُون من المفيد أن تَكُون على دراية بأحداث المفاتيح والفأرة خاصة أنها ستُمكِّنك من القيام ببعض الأمور الشيقة.
أحداث الفأرة (mouse events)
تُستخدَم كائنات من النوع MouseEvent
لتمثيل أحداث الفأرة (mouse event)، والتي لا تقتصر في الواقع على الفأرة، فقد تقع تلك الأحداث أيضًا نتيجة لاستخدام أجهزة إِدْخَال آخرى كلوحات التتبع (trackpad) وشاشات اللمس (touch screen)، ويُترجِم النظام عندها الأحداث الناتجة عن تلك الأجهزة إلى أحداث من الصَنْف MouseEvent
. يَقَع هذا الصَنْف بالإضافة إلى غيره من الأصناف المُتعلِّقة بأحداث المفاتيح والفأرة بحزمة javafx.scene.input
. تَقَع أنواع مختلفة من الأحداث عند اِستخدَام الفأرة، فمثلًا، تَقَع الأحداث التالية عند النقر على زر فأرة: "mouse pressed" و "mouse released" و "mouse clicked". في المقابل، تَقَع متتالية من الأحداث عند تحريك مؤشر الفأرة من نقطة إلى أخرى على الشاشة. والآن، لكي نَتَمكَّن من الاستجابة إلى أحداث الفأرة الواقعة داخل مُكوِّن معين، يُمكِننا أن نُسجِّل مُستمِع حَدَثَ (event listener) بذلك المُكوِّن. فمثلًا، إذا كان لدينا المكون c
، نستطيع أن نُسجِّل به مُستمِع لكل حدث مختلف باستخدام توابع نسخ (instance methods) مثل c.setOnMousePressed(handler)
و c.setOnMouseMoved(handler)
. تَستقبِل تلك التوابع مُعالِج حَدَث (event handler) كمُعامِل، والذي يُمرَّر عادةً بهيئة تعبير لامدا (lambda expression). لنَفْترِض أن لدينا حاوية canvas
عبارة عن مُكوِّن من النوع Canvas
وأننا نريد استدعاء التابع redraw()
في كل مرة ينقر فيها المُستخدِم على تلك الحاوية، يُمكِننا إذًا اِستخدَام التالي:
canvas.setOnMousePressed( evt -> redraw() );
في العموم، تُكْتَب تلك التَعْليمَة بمَتْن التابع start()
المُعرَّف بالصَنْف Application
المُمثِل للتطبيق. لاحِظ أنه من المُمكِن أيضًا مُعالجة حَدَث النقر على زر الفأرة عند وقوع المُؤشر داخل الحاوية (canvas) إما عن طريق المشهد (scene) أو عن طريق أي عقدة (node) آخرى بمبيان المشهد (scene graph) بشَّرْط أن تَكُون مُتضمِّنة لتلك الحاوية بصورة مباشرة أو غير مباشرة، ومع ذلك، عادةً ما تُوكَل مُهِمّة معالجة حََدَث (event) معين إلى مقصده (target).
تتضمن أنواع أحداث الفأرة التالي:
-
MouseEntered
: يقع عندما يتحرك مُؤشِر الفأرة من خارج مُكوِّن واجهة معين إلى داخل ذلك المُكوِّن. -
MouseExited
: يقع عند خروج مُؤشِر الفأرة من مُكوِّن معين. -
MousePressed
: يقع عندما يَضغَط المُستخدِم على أحد أزرار الفأرة. -
MouseReleased
: يقع عندما يُحرِّر المُستخدِم أحد أزرار الفأرة بَعْد الضَغْط عليه. -
MouseClicked
: يقع بَعْد الحدثMouseReleased
إذا كان المُستخدِم قد ضَغَطَ على زر الفأرة وحَرَّرها داخل نفس المُكوِّن. -
MouseDragged
: يقع عندما يُحرِك المُستخدِم مؤشر الفأرة بينما يَضغَط باستمرار على أحد أزرار الفأرة. -
MouseMoved
: يقع عندما يُحرِك المُستخدِم مؤشر الفأرة دون الضَغْط باستمرار على أي زر من أزرار الفأرة.
لاحِظ أن مقصد (target) الأحداث MouseDragged
و MouseReleased
و MouseClicked
هو نفس مُكوِّن الواجهة الذي كان قد ضُغِطَ (press) عليه بالأساس حتى لو كان المُؤشِر قد خرج من ذلك المُكوِّن بينما مقصد الأحداث MousePressed
و MouseMoved
فهو المُكوِّن الحاوي لمُؤشِر الفأرة عند وقوع الحدث. وأخيرًا، فإن مقصد الأحداث MouseEntered
و MouseExited
هو المُكوِّن الذي يَدْخُل إليه مُؤشِر الفأرة أو يَخرُج منه على الترتيب.
عند وقوع حَدَث فأرة (mouse event) معين، نحتاج عادةً إلى مَعرِفة موقع مُؤشِر الفأرة. تُتاح تلك المعلومة بكائن حَدَث الفأرة من الصَنْف MouseEvent
والمُمرَّر كمُعامِل إلى تابع معالجة الحدث حيث يَتَضمَّن ذلك الكائن توابع نُسخ (instance methods) تُعيد معلومات عن الحَدَث (event). بفَرْض أن المُتْغيِّر evt
هو ذلك المُعامِل، فيُمكِننا إذًا استدعاء التابعين evt.getX()
و evt.getY()
لمَعرِفة موقع مؤشر الفأرة حيث يعيدان قيم من النوع double
تُمثِل كُلًا من الإحداثي x
و y
لموقع مؤشر الفأرة عند وقوع الحدث. يُعبَر عن نظام الإحداثيات وفقًا لمُكوِّن مصدر الحدث (event source) أي تُمثِل إحداثيات النقطة (٠،٠) ركنه الأيسر العلوي. تَذَكَّر أن مصدر الحدث هو المُكوِّن الذي سُجِّل به مُستمِع الحدث (event listener) أي أنه قد يَكُون مختلفًا عن مقصد الحدث (event target) على الرغم من ندرة حدوث ذلك.
يستطيع المُستخدِم أن يَضغَط باستمرار على مفتاح تبديل (modifier key) واحد أو أكثر بينما يَستخدِم الفأرة. تَتَضمَّن مفاتيح التبديل المفاتيح التالية: المفتاح "Shift"، والمفتاح "Control"، والمفتاح "Alt" (أو "Option" بأجهزة ماك)، والمفتاح "Meta" (أو "Command" بأجهزة ماك). قد تَرغَب أحيانًا بالإستجابة إلى حَدَث فأرة (mouse event) معين بصورة مختلفة في حالة كان المُستخدِم يَضغَط باستمرار على مفتاح تبديل معين. يُمكِنك اختبار ذلك باستدعاء توابع النُسخ التالية evt.isShiftDown()
و evt.isControlDown()
و evt.isAltDown()
و evt.isMetaDown()
المُعرَّفة بكائن حَدَث الفأرة.
قد ترغب بمعالجة أحداث الفأرة بشكل مختلف اعتمادًا على ما إذا كان المُستخدِم قد ضَغَطَ على زر الفارة الأيسر أم الأوسط أم الأيمن. بالنسبة لأحداث الفأرة الصادرة عن الأزرار، يُمكِنك أن تَستدعِي التابع evt.getButton()
لمَعرِفة أي زر ضَغَطَ عليه المُستخدِم أو حَرَّره. يُعيد ذلك التابع أحد ثوابت أنواع التعداد (enumerated type constants) التالية: MouseButton.PRIMARY
والذي يُمثِل زر الفأرة الأيسر أو MouseButton.MIDDLE
أو MouseButton.SECONDARY
الذي يُمثِل زر الفأرة الأيمن. أما بالنسبة لأحداث الفأرة غَيْر الصادرة عن الأزرار مثل mouseEntered
و mouseExited
، يُعيِد التابع evt.getButton()
الثابت MouseButton.NONE
.
قد يَضغَط المُستخدِم باستمرار على أكثر من زر فأرة بنفس الوقت. إذا أردت مَعرِفة أي من أزرار الفأرة كان المُستخدِم يَضغَط عليها باستمرار أثناء وقوع حَدَث معين، يُمكِنك استدعاء الدوال evt.isPrimaryButtonDown()
و evt.isMiddleButtonDown()
و evt.isSecondaryButtonDown()
والتي تُعيد قيم منطقية.
كمثال بسيط، لنَفْترِض أننا نَرغَب برَسْم مستطيل أحمر اللون كلما نقر المُستخدِم على كائن حاوية canvas
من الصَنْف Canvas
، ولكن إذا كان المُستخدِم ضاغطًا باستمرار على المفتاح "Shift" أيضًا أثناء وقوع الحَدَث، فنُريد أن نرسم شكلًا بيضاويًا أزرق اللون. يُمكننا القيام بذلك بتعريف مُعالِج الحَدَث (event handler) التالي:
canvas.setOnMousePressed( evt -> { GraphicsContext g = canvas.getGraphicsContext2D(); if ( evt.isShiftDown() ) { g.setFill( Color.BLUE ); g.fillOval( evt.getX() - 30, evt.getY() - 15, 60, 30 ) } else { g.setFill( Color.RED ); g.fillRect( evt.getX() - 30, evt.getY() - 15, 60, 30 ); } } );
سيُساعدك البرنامج SimpleTrackMouse.java على فهم كيفية عَمَل أحداث الفأرة (mouse events) بصورة أفضل. يَستمِع البرنامج لجميع أنواع أحداث الفأرة (mouse events) السبعة، ويستجيب لها بعَرْض كُلًا من إحداثيات مُؤشِر الفأرة، ونوع الحَدَث، بالإضافة إلى قائمة بمفاتيح التعديل (modifier keys) وأزرار الفأرة المضغوط عليها. يُمكِنك أيضًا أن تُجرِّب البرنامج لترى ما يَحدُث أثناء استخدامك للفأرة. يفضَّل أيضًا أن تَطَّلِع على شيفرة البرنامج المصدرية.
السحب (dragging)
يُقصَد بعملية السحب (dragging) أن يُحرِك المُستخدِم الفأرة بينما يَضغَط باستمرار على أحد أزرار تلك الفأرة. سنناقش الآن كيف يُمكِن لبرنامج معين أن يستجيب لعملية السحب (dragging). تبدأ عملية السحب عندما يَضغَط المُستخدِم على أحد أزرار الفأرة، وتستمر بينما يَسحَب تلك الفأرة، وأخيرًا تنتهي عندما يُحرِّر (release) ذاك الزر. تَتَضمَّن عملية السحب إذًا الأحداث التالية: MousePressed
و MouseDragged
-قد يُستدعَى أكثر من مرة أثناء تَحرِيك الفأرة- و MouseReleased
، لذا تُقسَّم الاستجابة لأحداث السحب على ثلاثة مُعالِجات أحداث (event handlers). في مثل تلك الحالات، عادةً ما نحتاج إلى ضَبْط بعض مُتْغيِّرات النُسخ (instance variables) لنتابع ما يَحدُث بين كل استدعاء لتابع والاستدعاء الذي يليه. فمثلًا، عادةً ما يحتاج مُعالِج الحدث MouseDragged
إلى الاحتفاظ بإحداثيات المُؤشِر من الاستدعاء السابق، ولهذا قد نُخزِّن تلك المعلومة بمُتْغيِّرات النُسخ prevX
و prevY
من النوع double
كما قد نُخزِّن إحداثيات المُؤشِر المبدئية -أي عند وقوع الحدث MousePressed
- بمُتْغيِّرات نُسخ آخرى. بالإضافة إلى ذلك، قد نُعرِّف مُتْغيِّرًا من النوع boolean
يَحمِل الاسم dragging
بحيث يُضبَط إلى القيمة المنطقية true
إذا كان هنالك عملية سحب (dragging) قيد التّنْفيذ. يُعدّ ذلك ضروريًا لأنه سيُمكِّن توابع معالجة الأحداث من اختبار ما إذا كان هنالك عملية سحب قيد التّنْفيذ أم لا؛ لأنه لا ينبغي لكل حدث MousePressed
أن يبدأ عملية سحب جديدة، وكذلك في حالة ضَغَطَ المُستخدِم على زر فأرة آخر بدون أن يُحرِّر الأول، فسيَقَع حدثان MousePressed
قبل وقوع الحَدَث MouseReleased
، وعندها لا ينبغي عادةً بدء عملية سحب جديدة عند وقوع الحدث MousePressed
بالمرة الثانية. سنُعرِّف توابع نسخ (instance methods) لمعالجة الأحداث (events) كالتالي:
private double startX, startY; // المَوضع الأصلي الذي نقر عليه المستخدم private double prevX, prevY; // إحداثيات الفأرة الأحدث private boolean dragging; // اضبطه إلى true إذا كانت هناك عملية سحب . . . // متغيرات نسخ أخرى public void mousePressed(MouseEvent evt) { if (dragging) { // يضغط المستخدم على زر الفأرة الآخر قبل أن يحرر الأول // تجاهل الضغط على الزر الثاني return; } if ( we-want-to-start-dragging ) { dragging = true; startX = evt.getX(); // تذكر موضع البداية startY = evt.getY(); prevX = startX; // أحدث مَوضِع prevY = startY; . . // أجري أي معالجات أخرى . } } public void mouseDragged(MouseEvent evt) { // افحص أولًا إذا ما كنا نعالج عملية سحب if ( dragging == false ) return; int x = evt.getX(); // موضع الفأرة الحالي int y = evt.getY(); . . // عالج حركة الفأرة من (prevX, prevY) إلى (x,y). . prevX = x; // تذكر الموضع الحالي للاستدعاء التالي prevY = y; } public void mouseReleased(MouseEvent evt) { // افحص أولًا إذا ما كنا نعالج عملية سحب if ( dragging == false ) return; dragging = false; // .انهي عملية السحب . . // أي معالجات أخرى . }
سنُسجِّل الآن معالجات أحداث (event handlers) بالمُكوِّنات ذات الصلة بحيث يَقْتصِر دورها على استدعاء تلك التوابع الثلاثة المُعرَّفة بالأعلى:
c.setOnMousePressed( e -> mousePressed(e) ); c.setOnMouseDragged( e -> mouseDragged(e) ); c.setOnMouseReleased( e -> mouseReleased(e) );
يَقْتصِر دور مُعالجات الأحداث (event handlers) بالتَعْليمَات السابقة على استدعاء تابع (method) آخر مُعرَّف ضِمْن نفس الصَنْف، والذي يَستقبِل نفس مُعامِل (parameter) مُعالج الحدث. يُمكِننا إذًا استخدام مَراجِع التوابع (method reference) -اُنظر القسم الفرعي ٤.٥.٤- لكتابة تعبيرات لامدا (lambda expressions) السابقة، ولمّا كانت التوابع المُستدعَاة عبارة عن توابع نُسخ (instance methods) مُعرَّفة بالكائن this
، فستُستخدَم أسماء مَراجِع مثل this::mousePressed
للإشارة إليها. يُمكِننا الآن تَسْجيل مُعالِجات الأحداث (event handlers) كالتالي:
c.setOnMousePressed( this::mousePressed ); c.setOnMouseDragged( this::mouseDragged ); c.setOnMouseReleased( this::mouseReleased );
سنَفْحَص الآن برنامجًا يَتَضمَّن واحدة من الاِستخدَامات التقليدية عملية السحب (dragging): سنَسمَح للمُستخدِم برسم منحنى عن طريق سحب الفأرة بمساحة رسم (drawing area) بيضاء كبيرة، وسنُمكِّنه أيضًا من اختيار اللون المُستخدَم للرسم من خلال النقر على أحد المستطيلات المُلوَّنة على يمين مساحة الرسم. يُمكِنك الإطلاع على شيفرة البرنامج بالكامل بالملف SimplePaint.java. تَعرِض الصورة التالية نافذة البرنامج بَعْد القيام ببعض الرسم:
سنُناقش فقط بعضًا من أجزاء الشيفرة المصدرية للبرنامج وليس كلها، ولكن يُفضَّل أن تقرأها كاملة بتأني وستَجِد الكثير من التعليقات (comments) التوضيحية بالشيفرة.
بهذا البرنامج، تَقَع عملية الرسم ضِمْن حاوية (canvas) واحدة تُغطِي النافذة بالكامل. صُمّم البرنامج ليتناسب مع أي حجم للحاوية بشَّرْط ألا تَكُون صغيرة أكثر من اللازم. في الحقيقة، يُصعِب ذلك الأمور قليلًا عما لو كنا قد اِفترَضنا حجمًا ثابتًا للحاوية (canvas)؛ فلابُدّ لنا الآن من حساب قيم الإحداثيات وفقًا لعَرْض الحاوية وارتفاعها. يُمكِننا مَعرِفة عَرْض الحاوية وارتفاعها باستدعاء التابعين canvas.getWidth()
و canvas.getHeight()
على الترتيب. سنَفْحَص الآن بعضًا من تلك الحسابات: تمتد مساحة الرسم (drawing area) البيضاء الكبيرة من y = 3
إلى y = height - 3
رأسيًا، ومن x = 3
إلى x = width - 56
أفقيًا. تُراعِي تلك الحسابات وجود إطار رمادي حول الحاوية (canvas) بعَرْض يَبلُغ ٣ بكسل، وكذلك وجود لوحة الألوان على طول الحافة اليُمنَى للحاوية، والتي يَبلُغ عَرْضها ٥٠ بكسل مع ٣ بكسل للإطار و٣ بكسل للحاجز الفاصل بين مساحة الرسم ولوحة الألوان. وفقًا لذلك، تَبعُد الحافة اليُمنى لمساحة الرسم (drawing area) مسافة قدرها ٥٦ بكسل عن الحافة اليمنى للحاوية (canvas).
يوجد مربع أبيض مكتوب عليه كلمة "CLEAR" أسفل لوحة الألوان الواقعة على طول الحافة اليُمنَى للحاوية (canvas). باستبعاد ذلك المربع، نستطيع أن نَحسِب المسافة الرأسية المُتاحة لجميع المستطيلات المُلوَّنة ثم نُقسِّمها على ٧ لنَحصُل على قيمة المسافة الرأسية المُتاحة لكل مستطيل على حدى بحيث تُخزَّن تلك القيمة بالمُتْغيِّر colorSpace
. بالإضافة إلى ذلك، يَفصِل فراغ عَرْضه ٣ بكسل بين كل مستطيل وآخر، ولهذا فإن ارتفاع كل مستطيل يُساوِي colorSpacing - 3
. بفَرْض بِدء عدّ المستطيلات من الصفر، ستَبعُد إذًا الحافة العلوية لمستطيل N
مسافة قدرها N*colorSpacing + 3
بكسل عن الحافة العُلوية للحاوية (canvas)؛ وذلك لوجود عدد N
من المستطيلات أعلى المستطيل N
يَبلُغ ارتفاع كُلًا منها قيمة تُساوِي colorSpace
بكسل بالإضافة إلى ٣ بكسل لإطار الحافة العلوية للحاوية (canvas). نستطيع الآن كتابة الأمر اللازم لرسم مستطيل N
كالتالي:
g.fillRect(width - 53, N*colorSpace + 3, 50, colorSpace - 3);
لم يكن ذلك سهلًا! ولكنه يُوضِح نوعية التفكير والدقة المطلوبين في بعض الأحيان للحصول على نتائج جيدة.
تُستخدَم الفأرة بهذا البرنامج لثلاثة أغراض مختلفة هي: اختيار اللون، ومسح الرسوم، ورسم المنحنى. تَشتمِل الأخيرة فقط على عملية سحب (dragging) أي لا تُؤدي كل نقرة على الفأرة إلى بدء عملية سحب. سيَفْحَص التابع mousePressed()
إحداثيات النقطة (x,y) التي نقر عليها المُستخدِم ليُقرِّر كيفية الاستجابة. فمثلًا، إذا نقر المُستخدِم على المستطيل "CLEAR"، فستُفرَّغ مساحة الرسم (drawing area) باستدعاء التابع clearAndDrawPalette()
والذي يُعيد رَسْم الحاوية (canvas) بالكامل. أما إذا نقر المُستخدِم بموضع آخر داخل لوحة الألوان، فلابُدّ للمعالج من أن يُحدِّد اللون الذي نقر عليه المُستخدِم وذلك بقسمة الإحداثي y
على قيمة المُتغيِّر colorSpacing
، ثم يُخصِّص ذلك اللون لعملية الرسم. أخيرًا، إذا نقر المُستخدِم على مساحة الرسم (drawing area)، فستبدأ عملية سحب (drag)، وسيُضبَط المُتْغيِّر المنطقي dragging
في تلك الحالة إلى القيمة true
مما يُمكِّن التابعين mouseDragged
و mouseReleased
من مَعرِفة أن هنالك عملية رسم منحنى قيد التّنْفيذ. تُوكَل مُهِمّة الرسم الفعليّ للمنحنى للتابع mouseDragged()
والذي يَرسِم خطًا من الموضع السابق لمُؤشِر الفأرة وحتى موضعه الحالي. سنحتاج أيضًا إلى التأكُّد من أن الخط المرسوم لن يمتد إلى ما بَعْد مساحة الرسم (drawing area) البيضاء داخل الحاوية (canvas)، وهو ما لا يَحدُث تلقائيًا؛ لأن الحاسوب يَنظُر للإطار وللوحة الألوان على أساس كَوْنهما جزءًا من الحاوية (canvas). سيَضطرّ التابع mouseDragged()
إذًا إلى تَعْديل قيم كُلًا من الإحداثي x
والإحداثي y
بحيث يَقعَا ضِمْن نطاق مساحة الرسم إذا سحب المُستخدِم مؤشر الفأرة إلى خارج مساحة الرسم البيضاء أثناء رسمه لمنحنى.
أحداث المفاتيح (key events)
تُصبِح أفعال (actions) المُستخدِم في العموم أحداثًا (events) بالبرنامج وبحيث يَكُون مُكوِّن واجهة (GUI component) معين هو مقصد ذلك الحدث (event target). فمثلًا، عندما يَضغَط المُستخدِم على أي من أزرار الفأرة، يُعدّ المُكوِّن المُتَضمِّن لمؤشر الفأرة هو مقصد الحدث. والآن، ماذا عن أحداث لوحة المفاتيح (key events)؟ فمثلًا، عندما يَضغَط المُستخدِم على مفتاح، فأي مُكوِّن واجهة يَكُون مقصدًا للحدث KeyEvent
؟
تَعتمِد واجهة المُستخدِم الرُسومية (GUI) على فكرة التركيز (input focus) لتَحْديد مقصد أحداث (event target) لوحة المفاتيح. بأي لحظة، يُمكِن أن يَقَع التركيز على عنصر واجهة (interface element) واحد فقط على الشاشة وبحيث تُوجَّه إليه أحداث لوحة المفاتيح (keyboard events). إذا كان ذلك العنصر مُكوِّن بمنصة جافا إف إكس (JavaFX component)، يُنشَئ كائن من النوع KeyEvent
يَتَضمَّن معلومات عن حدث لوحة المفاتيح ثم يُرسَل لأي من مُعالِجات أحداث المفاتيح (key event handlers) المُسجَّلة بذلك المُكوِّن والمُستمعِة للأحداث من الصَنْف KeyEvent
. نظرًا للكيفية التي تُعالَج بها أحداث المفاتيح (key events)، يَحصُل كائن المشهد من الصنف Scene
بالنافذة المُتْضمِّنة للمُكوِّن الواقع عليه التركيز على فرصة لمعالجة أحداث المفاتيح بل قد يُصبِح مقصدًا (event) لها إذا لم يَكُن هناك أي مُكوِّن آخر واقع عليه التركيز بالنافذة. سنُضيف خلال البرامج التالية مُعالِجات أحداث المفاتيح (key event handlers) إلى كائن المشهد.
عادةً ما يُعطِي البرنامج ملحوظة مرئية للمُستخدِم عن مُكوِّن الواجهة الواقع عليه التركيز (input focus). فعلى سبيل المثال، إذا كان المُكوِّن عبارة عن صندوق إدخال نصي، قد تكون تلك الملحوظة بهيئة مُؤشِر وامض أو برَسْم إطار مُلوَّن حول حافة ذلك المُكوِّن، وهو ما قد تلاحظه بمُكوِّنات الأزرار، ويُكافئ عندها الضغط على مفتاح مسطرة المسافات (space bar) النقر على الزر.
لنَفْترِض أن comp
هو مُكوِّن واجهة ترغب بأن يقع عليه التركيز (input focus)، يُمكِنك عندها أن تَستدعِي comp.requestFocus()
. بأي واجهة مُستخدِم (user interface) تقليدية، عندما يَنقُر المُستخدِم على مُكوِّن معين باستخدام الفأرة، يقع التركيز على ذلك المُكوِّن تلقائيًا كما ينتقل التركيز من مُكوِّن واجهة إلى آخر بكل مرة يَضغَط فيها المُستخدِم على المفتاح "tab". يَحدُث ذلك تلقائيًا في غالبية الأحوال ودون الحاجة لإجراء أي برمجة، ولكن لا تَطلُب بعض المُكوِّنات أن يقع عليها التركيز تلقائيًا بعد نقر المُستخدِم عليها مثل الصَنْف Canvas
، وعندها لابُدّ من استدعاء التابع requestFocus()
بكائن ذلك الصنف حتى يُصبِح مَوضِع التركيز وكذلك لابُد من استدعاء comp.setFocusTraversable(true)
لتفعيل خاصية انتقال التركيز التلقائي للمُكوِّن عند الضغط على المفتاح "tab". تستطيع أيضًا اِستخدَام التابع comp.isFocused
لاختبار ما إذا كان التركيز واقعًا على ذلك المُكوِّن.
تُعدّ النافذة التي تَتَضمَّن المُكوِّن الواقع عليه التركيز نافذة "مركزة (focused)" أو "نشطة (active)" وعادةً ما تَكُون النافذة الأمامية بالشاشة. بمنصة جافا إف إكس (JavaFX)، يُمثِل كائن المرحلة من الصَنْف Stage
نافذة، ويُمكِنك أن تَستدعِي stage.requestFocus()
لتَطلُب نقلها إلى مقدمة الشاشة لكي تُصبِح "نشطة (active)" كما يُمكِنك أن تَستدعِي stage.isFocused()
لاختبار ما إذا كانت نشطة.
تُفرِّق الجافا بين المفاتيح التي تَضغَط عليها والمحارف (characters) التي تَكْتُبها بصورة واضحة. هنالك الكثير من المفاتيح بأي لوحة مفاتيح: مفاتيح الحروف، ومفاتيح الأعداد، ومفاتيح التَعْدِيل (modifier keys) مثل "Control" و "Shift"، ومفاتيح الأسهم، ومفتاحي "صفحة لأعلى" و"صفحة لأسفل"، ومفاتيح الوظائف (function keys)، وغيره. أحيانًا لا يؤدي الضَغْط على مفتاح معين إلى كتابة محرف كالمفتاح "shift". في المقابل، أحيانًا ما تَتَطلَّب كتابة محرف واحد الضَغْط على أكثر من مفتاح. فمثلًا، تَتَطلَّب كتابة الحالة الكبيرة من الحرف "A" الضغط باستمرار على المفتاح "Shift" ثُمَّ الضَغْط على المفتاح "A" وأخيرًا تحرير المفتاح "Shift". كذلك تَتَطلَّب كتابة "é" بأجهزة ماك الضَغْط باستمرار على المفتاح "Option" ثم الضَغْط على المفتاح "E" ثم تحرير المفتاح "Option" وأخيرًا الضغط على "E" مجددًا. كُتِبَ بالنهاية محرف واحد فقط على الرغم من الحاجة للضغط على ثلاثة مفاتيح مع تحرير إحداها بالوقت المناسب.
بمنصة جافا إف إكس، هنالك ثلاثة أنواع من أحداث المفاتيح (key event): KeyPressed
و KeyReleased
و KeyTyped
. يَقَع الأول عندما يَضغَط المُستخدِم على أي مفتاح بلوحة المفاتيح أما الثاني فيَقَع عندما يُحرِّر مفتاحًا كان قد ضَغَطَ عليه أما الثالث فيَقَع عندما يَكْتُب محرفًا سواء كان ذلك نتيجة لضَغْطه على مفتاح واحد أو أكثر. قد يُؤدي فِعْل (action) واحد كالضَغْط على المفتاح "E" إلى وقوع الحَدَثَين keyPressed
و keyTyped
. على سبيل المثال، تُؤدي كتابة الحالة الكبيرة من الحرف "A" إلى وقوع حدثين keyPressed
وآخرين keyReleased
بالإضافة إلى حَدَث keyTyped
.
في العموم، يُفضَّل أن تُفكِّر بوجود مجريين منفصلين من الأحداث (events) الأول يحتوي على أحداث keyPressed
و keyReleased
والآخر يَتَضمَّن أحداث keyTyped
بحيث تستمع بعض أنواع التطبيقات إلى أحداث المجرى الأول بينما تستمع تطبيقات آخرى إلى أحداث المجرى الثاني. يُمكِنك استخراج نفس المعلومات الموجودة بمجرى أحداث keyTyped
من مجرى أحداث keyPressed/keyReleased
ولكنها عملية صعبة نوعًا ما كما أنها تعتمد على النظام إلى درجة معينة. لاحظ أن بعض أفعال المُستخدِم (user actions) -مثل الضغط على المفتاح "Shift"- يُمكِن فقط رصدها بهيئة أحداث keyPressed
. على سبيل المثال، تُميِّز لعبة الحاسوب Solitaire ورق اللعب الذي يُمكِن نقله عند الضَغْط باستمرار على المفتاح "Shift". يُمكِنك القيام بذلك بالجافا عن طريق تَمْييز ورق اللعب عند الضَغْط على المفتاح "Shift" ثم إلغاء ذلك التَمْييز عند تحرير المفتاح.
لاحِظ أيضًا أنه عند الضَغْط باستمرار على مفتاح معين، فقد يتكرَّر ذلك المفتاح تلقائيًا أي ستَقَع متتالية من أحداث KeyPressed
متبوعة بحَدَث KeyReleased
وحيد بالنهاية كما قد تَقَع أيضًا متتالية من أحداث KeyTyped
. لن يُؤثِر ذلك في الغالب على أسلوب كتابة البرامج ولكن لا تَفْترِض أن كل حَدَث KeyPressed
لابُدّ وأن يُقابله حَدَث KeyReleased
.
كل مفتاح بلوحة المفاتيح له ترميز (key code) يُميِّزه والتي تُمثَل بمنصة جافا إف إكس (JavaFX) باستخدام ثوابت التعداد KeyCode
. عندما يُستدعَى مُعالِج الحَدَث KeyPressed
أو KeyReleased
، فإن المُعامِل evt
يحتوي على ترميز المفتاح المضغوط عليه أو المُحرَّر على الترتيب، ويُمكِننا مَعرِفته باستدعاء الدالة evt.getCode()
. على سبيل المثال، عندما يَضغَط المُستخدِم على المفتاح "shift"، تُعيد تلك الدالة القيمة KeyCode.SHIFT
. يُمكِنك الإطلاع على توثيق التعداد KeyCode
لمَعرِفة اسم الثابت لترميز مفتاح معين مع أنه من السهل تَخْمِين أسمائها عمومًا. فمثلًا، مفاتيح الحروف لها أسماء مثل KeyCode.A
و KeyCode.Q
بينما مفاتيح الأسهم لها أسماء مثل KeyCode.LEFT
و KeyCode.RIGHT
و KeyCode.UP
و KeyCode.DOWN
أما مفتاح مسطرة المسافات (space bar) هو KeyCode.SPACE
وأخيرًا مفاتيح الوظائف لها أسماء مثل KeyCode.F7
.
في حالة الحَدَث KeyTyped
، سترغب عادةً بمَعرِفة المحرف المَكْتُوب لذا يُمكِنك استدعاء الدالة evt.getCharacter()
لمَعرِفة ذلك حيث تُعيد قيمة من النوع String
تَحتوِي على المحرف المَكْتُوب.
كأول مثال، يَرسِم البرنامج KeyboardEventDemo.java مربعًا صغيرًا داخل حاوية (canvas). يستطيع المُستخدِم أن يُحرِك ذلك المربع إلى اليسار أو إلى اليمين أو إلى أعلى أو إلى أسفل عن طريق الضَغْط على مفاتيح الأسهم (arrow keys). نُفِذَ (implement) ذلك بالتابع التالي:
private void keyPressed( KeyEvent evt )
سيُستدعَى التابع بالأعلى بواسطة مُعالِج حَدَث (event handler) يَستمِع لأحداث KeyPressed
. تُسجِّل التَعْليمَة التالية ذلك المُعالِج بكائن المشهد من الصَنْف Scene
بمَتْن التابع start()
:
scene.setOnKeyPressed( e -> keyPressed(e) );
يَفْحَص التابع keyPressed()
قيمة الدالة evt.getCode()
، فإذا كان المفتاح المضغوط عليه واحدًا من مفاتيح الأسهم (arrow keys)، يُعاد رسم الحاوية (canvas) بحيث تُظهِر المربع بمَوضِع مختلف.
يُسجِّل البرنامج أيضًا مُعالِجات (handlers) للأحداث KeyReleased
و KeyTyped
بنفس الكيفية. فمثلًا، سيُغيِّر مُعالِج الحَدَث KeyTyped
لون المربع عندما يَكْتُب المُستخدِم الحرف "r" أو "g" أو "b" أو "k". اِحرص على قراءة الشيفرة الكاملة للبرنامج بتأني وجرِّب تَشْغِيله.
الصنف AnimationTimer
سنناقش الآن نوعًا آخر من الأحداث البسيطة المستخدمة بالتحريك الحاسوبي (animation). تقع الأحداث (events) في هذه الحالة بالخلفية لذا أنت لا تكون مضطرًا لتسجيل مستمع (listener) ليستجيب لها، ولكن ستحتاج إلى كتابة تابع (method) يستدعيه النظام عند وقوع تلك الأحداث.
يَعتمِد التَحرِيك الحاسوبي (computer animation) على متتالية من الصور الثابتة المُنفصلة، يُطلَق على كُل منها اسم الإطار (frame). تُعرَض هذه الصور بشكل سريع واحدة تلو الآخرى، فإذا كان التَغْيِير بين كل صورة والصورة التي تَليها طفيفًا، ستبدو متتالية الصور وكأنها تَحرِيكة مُستمرة (continuous animation). بمنصة جافا إف إكس (JavaFX)، تُستخدَم كائنات من النوع AnimationTimer
المُعرَّف بحزمة javafx.animation
لبرمجة تحريكة (animation). بفَرْض أن الكائن animator
من ذلك الصنف، فيُمكِننا استدعاء التابع animator.start()
لبدء التحريكة إذا كانت مُتوقِّفة أو لإعادة تَشْغِيلها إذا كانت مشغَّلة. يُمكِننا أيضًا استدعاء التابع animator.stop()
لإيقاف التحريكة. يَتَضمَّن الصنف التابع handle(time)
والذي لا يُمكِنك استدعائه وإنما ينبغي أن تَكْتُب تّنْفيذه لتُحدِّد ما ينبغي أن يَحدُث بالتَحرِيكة. سيَستدعِي النظام ذلك التابع مرة واحدة لكل إطار (frame) بالتحريكة لكي يُنفِّذ كل ما هو ضروري لتّنْفيذ الإطار.
يَستدعِى النظام التابع handle()
بخيط تطبيق جافا إفا إكس (JavaFX application thread) مما يَعنِي إمكانية الرسم على حاوية (canvas) أو مُعالجة مُكوِّن واجهة (GUI component) أو القيام بأي أشياء آخرى مشابهة بشَّرْط ألا تَستغرِق وقتًا طويلًا؛ لأن التحريكات بمنصة جافا إف إكس (JavaFX) تُنفَّذ بمُعدل ٦٠ إطار لكل ثانية أي يُستدعَى التابع handle()
كل ١\٦٠ ثانية.
ملحوظة: بُلِّغ عن وجود خطأ برمجي (bug) قد يؤدي أحيانًا إلى استدعاء التابع handle()
أكثر من ٦٠ مرة بالثانية، ولذلك أضفنا بعض الشيفرة إلى تّنْفيذ (implementation) الصَنْف AnimationTimer
لنتجنَّب ذلك الخطأ البرمجي وأرفقنا معها تعليقات توضيحية.
لاحِظ أن الصَنْف AnimationTimer
هو صَنْف مُجرّد (abstract class) كما أن التابع handle()
هو تابع مُجرّد (abstract method) لذا ستحتاج إلى كتابة صَنْف فرعي (subclass) من AnimationTimer
يُعرِّف التابع handle()
لتَتَمكَّن من برمجة تَحرِيكة (animation). لنَفْترِض أنك ترغب باستدعاء تابع اسمه draw()
بكل إطار بالتحريكة، يُمكِنك القيام بذلك باِستخدَام صَنْف فرعي مجهول الاسم (anonymous subclass) من الصَنْف AnimationTimer
كالتالي (اُنظر القسم الفرعي ٥.٨.٣):
AnimationTimer animator = new AnimationTimer() { public void handle( long time ) { draw(); } };
سنحتاج الآن إلى استدعاء animator.start()
لتَشْغِيل التَحرِيكة، وهو ما يُمكِنك القيام به بمَتْن تابع التطبيق start()
.
يُمثِل المُعامِل time
-بالأعلى- الوقت الحالي مُقاس بحساب الفارق بين الوقت الحالي ووقت آخر عشوائي بالماضي بوحدة النانوثانية. يُمكِنك اِستخدَام time
ضِمْن الحسابات المطلوبة لرسم الإطار (frame) كطريقة لتّمْيِيز كل إطار عما يليه.
آلات الحالة (state machines)
نحن الآن جاهزين لفحص برنامج يوظف كلا من التحريك (animation) وأحداث المفاتيح (key events) لتنفيذ لعبة بسيطة. يستخدم البرنامج الصنف AnimationTimer
لإدارة التحريك كما يستخدم عددًا من متغيرات النسخ (instance variables) لمتابعة "الحالة (state)" الحالية للعبة.
تُعبِر القيم المُخزَّنة بمُتْغيِّرات نُسخ (instance variables) كائن معين عن "حالة (state)" ذلك الكائن. عند استدعاء إحدى توابعه، قد يَعتمِد ما يُنفِّذه الكائن حينها على حالته، أو بتعبير آخر، يُمكِن للتابع أن يَفْحَص قيم مُتْغيِّرات النُسخ (instance variables) ليُقرِّر ما ينبغي له القيام به. قد تَتَغيَّر حالة الكائن، أو بتعبير آخر، يُمكِن للتابع أن يُسنِد (assign) قيم جديدة لمُتْغيِّرات النُسخ. هنالك فكرة بعلوم الحاسوب (computer science) تُعرَف باسم "آلة الحالة (state machine)"، وهي تُعبِر عن شيء ذات حالة (state)، والتي قد تَتغيَّر استجابة لبعض الأحداث (event) أو المُدْخَلات (input)، وبحيث تَعتمِد كيفية استجابة الآلة لحَدَث معين على حالتها عند وقوع ذلك الحَدَث. في الواقع، يُعدّ الكائن بمثابة آلة حالة (state machine)، وأحيانًا ما تَكُون وجهة النظر تلك مفيدة أثناء تصميم الأصناف.
تبرز أهمية وجهة النظر تلك ببرمجة واجهات المُستخدِم الرُسومية (graphical user interfaces) والتي تعتمد على فكرة الأحداث (events) اعتمادًا كليًا. عند تصميم برنامج واجهة، ينبغي أن تُفكِّر بالأسئلة التالية: أي معلومات ينبغي تَضْمِينها ضِمْن حالة (state) البرنامج؟ ما هي الأحداث التي يُمكِنها أن تُغيِّر من حالة البرنامج؟ كيف ستعتمد استجابة حَدَث معين على حالة البرنامج الحالية؟ هل ينبغي تَغْيير مَظهَر الواجهة ليَعكِس تَغْييرًا بحالة البرنامج؟ كيف ستُؤثِر حالة البرنامج على رسم محتويات الحاوية (canvas)؟ يُعدّ ذلك كله بمثابة بديل عن نهج التصميم المتدرج (step-wise-refinement) الذي يقع ضِمْن "الاستراتيجيات من أعلى لأسفل (top-down)"، وهو ما لا يُطبَق على كيفية تصميم برنامج حَدَثي التوجه (event-oriented).
بالبرنامج KeyboardEventDemo
-بالأعلى-، تُخزَّن حالة (state) البرنامج بمُتْغيِّرات نُسخ (instance variables) مثل squareColor
و squareLeft
و squareTop
تُمثِل كُلًا من لون المربع ومَوضِعه. يَستخدِم التابع draw()
مُتْغيِّرات الحالة (state variables) تلك لرَسْم المربع على الحاوية (canvas) بينما تُغيِّر توابع مُعالجة أحداث المفاتيح (key events) من قيمها.
سنَفْحَص ببقية هذا القسم مثالًا آخر تَلعَب فيه حالة (state) البرنامج دورًا أكبر حيث سيَلعَب المُستخدِم لعبة بسيطة من خلال الضَغْط على مفاتيح الأسهم (arrow keys). البرنامج متاح بملف الشيفرة SubKiller.java، وكالعادة، سيَكُون من الأفضل لو تَمَكَّنت من تصريف (compile) البرنامج وتَشْغِيله وكذلك قراءة شيفرته المصدرية بالكامل. اُنظر الصورة التالية:
تُغطِى حاوية (canvas) نافذة التطبيق بالكامل. بأسفل تلك الحاوية، يَعرِض البرنامج "غواصة" سوداء تتحرك عشوائيًا ذهابًا وإيابًا بالقرب من أسفل النافذة. بأعلى النافذة، هناك "قارب" أزرق يُمكِنك تَحرِيكه ذهابًا وإيابًا بالضَغْط على مفتاحي "سهم يسار" و"سهم يمين". رُبِط بالقارب "قنبلة" حمراء يُمكِنك قذفها بالضَغْط على مفتاح "سهم لأسفل". الهدف من اللعبة هو تفجير الغواصة عن طريق ضربها بقنبلة. إذا سَقَطَت القنبلة إلى خارج أسفل الشاشة، فستَحصُل على قنبلة جديدة أما إذا انفجرت الغواصة، فستُنشَئ غواصة جديدة وستَحصُل أيضًا على قنبلة جديدة. جرِّب اللعبة وتَأكَّد من ضرب الغواصة مرة واحدة على الأقل حتى تَتَمكَّن من رؤية الانفجار.
سنُفكِّر الآن بالكيفية التي ينبغي لنا بها برمجة تلك اللعبة. أولًا، لأننا نَعتمِد على البرمجة كائنية التوجه (object-oriented programming)، فسنُمثِل كُلًا من القارب والقنبلة والغواصة ككائنات. كل كائن منها مُعرَّف باِستخدَام صَنْف مُتداخِل (nested class) منفصل ضِمْن صَنْف التطبيق الأساسي كما أن كُلًا منها لديه حالته (state) الخاصة والمُمثَلة باِستخدَام مُتْغيِّرات النُسخ (instance variables) المُعرَّفة بصنفه. سنَستخدِم المُتْغيِّرات boat
و bomb
و sub
للإشارة إلى كُلًا من كائنات القارب والقنبلة والغواصة على الترتيب.
الآن، ما هي حالة (state) البرنامج؟ أو ما الأشياء التي ستَتَغيَّر من وقت لآخر بحيث تُؤثِر على مَظهَر أو سُلوك البرنامج؟ بدايةً، تَتَكوَّن الحالة من مَوضِع كُلًا من القارب والقنبلة والغواصة، ولذلك ستَتَضمَّن كائناتها مُتْغيِّرات نُسخ (instance variables) لتَخْزِين مَواضِعها. ثانيًا، من المحتمل أن تَسقُط القنبلة إلى أسفل الشاشة، وهو ما يُشكِّل فارقًا بالحالة، لذلك سنُمثِل ذلك الجانب من الحالة ضِمْن الكائن bomb
باِستخدَام مُتْغيِّر من النوع boolean
لوجود احتمالين وحيدين، وسيَكُون اسمه هو bomb.isFalling
. ثالثًا، قد تتحرك الغواصة لليسار أو لليمين، لذا سنُمثِل ذلك الفرق باِستخدَام مُتْغيِّر آخر من النوع boolean
هو sub.isMovingLeft
. رابعًا، قد تنفجر الغواصة، وهو ما يُعدّ جزءًا من الحالة، لذا سنُمثِله باِستخدَام مُتْغيِّر من النوع boolean
هو sub.isExploding
. يَقَع الانفجار عبر متتالية من الأُطر (frames) يزداد حجمه خلالها بصورة تؤثر على مَظهَر الغواصة بكل إطار. بالإضافة إلى ذلك، سنحتاج أيضًا إلى مَعرِفة وقت انتهاء الانفجار حتى نَتَمكَّن من العودة إلى تَحرِيك الغواصة ورسمها بالطريقة العادية، لذا سنُعرِّف مُتْغيِّرًا من النوع int
هو sub.explosionFrameNumber
للاحتفاظ بعدد الأُطر المرسومة منذ بداية الانفجار، وستُستخدَم قيمته فقط أثناء وقوع الانفجار.
كيف ستَتَغيَّر قيم تلك المُتْغيِّرات؟ ومتى؟ في الواقع سيَتَغيَّر بعضها من تلقاء نفسه. على سبيل المثال، تَتَغيَّر مُتْغيِّرات الحالة (state variables) المُمثِلة لمَوضِع الغواصة أثناء تحركها لليسار ولليمين نتيجة للتحريكة (animation) المُدارة باِستخدَام الصَنْف AnimationTimer
. بكل مرة يُستدعَى فيها التابع handle()
، فإنه سيُعدِّل بعض مُتْغيِّرات الحالة لكي تُصبح جاهزة لرسم الإطار (frame) التالي من التحريكة. لمّا كانت كُلًا من الكائنات boat
و bomb
و sub
تُعرِّف التابع updateForNextFrame()
المسئول عن تحديث مُتْغيِّراتها لتُصبِح جاهزة للرسم بإطار (frame) التحريكة التالي، فإن التابع handle()
فقط يَستدعِي تلك التوابع كالتالي:
boat.updateForNewFrame(); bomb.updateForNewFrame(); sub.updateForNewFrame();
بالإضافة إلى مَوضِع الغواصة، تُعدِّل توابع التحديث -بالأعلى- قيم بعض مُتْغيِّرات الحالة (state variables) الآخرى: أولًا، ستزداد قيمة الإحداثي y
أثناء سقوط القنبلة. ثانيًا، إذا تَمكَّنت القنبلة من ضَرْب الغواصة، فسيُضبَط المُتْغيِّر isExploding
بالكائن المُمثِل للغواصة إلى القيمة true
كما سيُضبَط المُتْغيِّر isFalling
بالكائن المُمثِل للقنبلة إلى false
. ثالثًا، عندما تَسقُط القنبلة إلى أسفل الشاشة، سيُضبَط المُتْغيِّر isFalling
إلى false
. رابعًا، أثناء انفجار الغواصة، ستزداد قيمة المُتْغيِّر explosionFrameNumber
بمقدار الواحد مع كل إطار إلى أن تَصِل إلى قيمة مُحدّدة، سينتهي عندها الانفجار وسيُضبَط المُتْغيِّر isExploding
إلى القيمة false
. أخيرًا، تُبدِّل الغواصة بين الحركة لليمين والحركة لليسار من وقت لآخر، ولمّا كان اتجاه حركتها مُخزَّنًا بالمُتْغيِّر isMovingLeft
بالكائن المُمثِل للغواصة، فإن التابع updateForNewFrame()
المُعرَّف بكائن الغواصة سيُغيِّر قيمة isMovingLeft
عشوائيًا كالتالي:
if ( Math.random() < 0.02 ) { isMovingLeft = ! isMovingLeft; }
هنالك احتمالية واحد من الخمسين أن يُعيد الاستدعاء Math.random()
قيمة أقل من ٠,٠٢ أي ستُنفَّذ التَعْليمَة isMovingLeft = ! isMovingLeft
بمتوسط إطار واحد لكل خمسين إطار. تَعكِس تلك التَعْليمَة قيمة isMovingLeft
من false
إلى true
أو من true
إلى false
أي أنها تَعكِس اتجاه حركة الغواصة.
بالإضافة إلى تَغْييرات الحالة (state) الواقعة بين كل إطار (frame) والإطار الذي يليه، فهنالك تَغْييرات آخرى بمُتْغيِّرات الحالة ولكنها تَقَع عندما يَضغَط المُستخدِم على مفاتيح معينة. يَفْحَص مُعالِج الحَدَث KeyPressed
المفتاح الذي ضَغَطَ عليه المُستخدِم، فإذا كان أي من المفتاحين "سهم يسار" أو "سهم يمين"، فإنه سيُعدِّل من مَوضِع القارب. أما إذا كان مفتاح "سهم لأسفل"، فإنه سيُغيِّر حالة القنبلة من عدم السقوط إلى السقوط. تَستعرِض الشيفرة التالية جزءًا من مَتْن تابع التطبيق start()
يُعرِّف خلالها المُعالِج بهيئة تعبير لامدا (lambda expression) مُسجَّل بالكائن scene
المُمثِل للمشهد:
scene.setOnKeyPressed( evt -> { // يستجيب مستمع الحدث إلى أحداث KeyPressed على الحاوية // تحرك مفاتيح سهم يمين و سهم يسار القارب // بينما يحرر مفتاح سهم لأسفل القنبلة KeyCode code = evt.getCode(); // أي مفتاح ضُغط عليه if (code == KeyCode.LEFT) { boat.centerX -= 15; } else if (code == KeyCode.RIGHT) { boat.centerX += 15; } else if (code == KeyCode.DOWN) { if ( bomb.isFalling == false ) bomb.isFalling = true; } } );
لاحِظ أنه ليس من الضروري إعادة رَسْم الحاوية (canvas) بذلك التابع؛ لأنها في الواقع تَعرِض تحريكة (animation) يُعَاد رسمها باستمرار على أية حال أي ستُصبِح أي تغييرات بالحالة (state) مرئية للمُستخدِم بمُجرّد رسم الإطار التالي. لابُدّ لنا من أن نَتَأكَّد من أن المُستخدِم لا يُحاوِل تحريك القارب إلى خارج الشاشة، وهو ما كان بإمكاننا القيام به بمُعالِج الحدث (event handler)، ولكننا سنقوم به ببرنامج (routine) آخر مُعرَّف بالكائن المُمثِل للقارب.
سيَكُون من الأفضل لو تَمكَّنت من قراءة الشيفرة المصدرية للبرنامج بالملف SubKiller.java. قد تَكُون بعض أجزاء تلك الشيفرة صعبة نوعًا ما، ولكن بقليل من الجهد، ينبغي أن تَكُون قادرًا على قراءة البرنامج بالكامل وفهمه. حَاوِل التَفْكير بالبرنامج من وجهة نظر "آلات الحالة (state machines)"، ولاحِظ كيفية تَغيُّر حالة كُلًا من الكائنات الثلاثة نتيجة للأحداث الواقعة من المؤقت والمُستخدِم.
على الرغم من أن تلك اللعبة ليست متقدمة بالموازنة مع لعب آخرى إلا أنها تُبيِّن كيفية تطبيق فكرة آلة الحالة (state-machine) بالبرمجة المبنية على الأحداث (event-oriented programming).
القيم القابلة للمراقبة (observable)
هنالك نوع آخر من الأحداث البسيطة تلعب دورًا مُهِمًّا بمنصة جافا إف إكس (JavaFX) هي الأحداث الواقعة عند تَعْدِيل قيمة "قابلة للمراقبة (observable)". فمثلًا، يَستخدِم البرنامج SubKiller
من القسم السابق كائن stage
من الصَنْف Stage
لديه خاصية (property) من النوع ObservableBooleanValue
تُحدِّد ما إذا كان الكائن يُمثِل النافذة الواقع عليها التركيز (focused window)، والتي تستطيع جَلْب قيمتها باستدعاء stage.focusedProperty()
. عندما تَتَغيَّر قيمة خاصية من النوع ObservableBooleanProperty
، يَقَع حدث (event). يُمكِنك أن تُسجِّل مُستمِع تَغْيير (change listener) من الصَنْف ChangeListener
بتلك الخاصية، والذي يَتَضمَّن تابع معالج حدث (event handler) يُستدعَى عند وقوع الحدث. في تلك الحالة، سيَستقبِل ذلك التابع ثلاثة مُعامِلات: الخاصية القابلة للمراقبة (observable) المسئولة عن توليد الحَدَث، والقيمة السابقة للخاصية، والقيمة الجديدة. نوع القيمة القديمة والجديدة لخاصية من النوع ObservableBooleanValue
هو boolean
. هنالك أنواع قيمة آخرى "قابلة للمراقبة (observable)" مثل ObservableIntegerValue
و ObservableStringValue
و ObservableObjectValue
.
ستبقى التحريكة (animation) مُشغَّلة حتى لو لم يَعُدْ التركيز واقعًا على نافذة SubKiller
، وهو ما قد يَكُون مزعجًا عند محاولة العمل على نافذة آخرى، لهذا سنُوقِف التحريكة (animation) مؤقتًا عندما تفقد النافذة التركيز وسنُعيد تَشْغِيلها عندما تَكْتسِبه مرة آخرى. عندما تفقد النافذة التركيز أو تَكْتسِبه، ستَتَغيَّر قيمة الخاصية stage.focusedProperty()
المنطقية والقابلة للمراقبة (observable). للاستجابة لذلك التَغْيير، سنُضيف مُستمِع تَغْيير (change listener) لتلك الخاصية بحيث يُوقِف التحريكة عن العمل عندما تَتَغيَّر قيمة الخاصية إلى false
ويُشغِّلها عندما تَتَغيَّر قيمتها إلى true
. سنُضيف الشيفرة التالية إلى التابع start()
:
stage.focusedProperty().addListener( (obj,oldVal,newVal) -> { // يوقف المستمع التحريكة إذا لم تعد نافذة البرنامج // محل التركيز if (newVal) { // أصبحت النافذة محل التركيز timer.start(); } else { // لم تعد النافذة محل التركيز timer.stop(); } draw(); // أعد رسم الحاوية });
يُسجِّل التابع addListener()
لخاصية "قابلة للمراقبة" (observable) مُستمِع تَغْيير (change listener) بتلك الخاصية. يَستقبِل تعبير لامدا (lambda expression) لمُعالِج الحَدَث (event handler) ثلاثة مُعامِلات (parameters). اِستخدَمنا فقط المُعامِل newVal
بالأعلى، والذي يُمثِل القيمة الحالية للخاصية focused
بالكائن المُمثِل للمرحلة (stage).
تتضمن مكونات واجهة المستخدم الرسومية (GUI) بمنصة جافا إف إكس (JavaFX) خاصيات عديدة قابلة للمراقبة (observable) من أنواع مختلفة. على سبيل المثال، نص الزر هو خاصية من النوع ObservableStringProperty
وكذلك عَرْض حاوية (canvas) وارتفاعها عبارة عن قيم من النوع ObservableDoubleProperty
. سنَتَعرَّض لمزيد من الأمثلة بالقسم التالي.
ترجمة -بتصرّف- للقسم Section 3: Basic Events من فصل Chapter 6: Introduction to GUI Programming من كتاب Introduction to Programming Using Java.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.