تطوير مقبض تحكم دوار (Rotary Knob) مخصص في أندرويد باستخدام Kotlin
مقدمة: لماذا مقبض تحكم دوار مخصص؟
عندما أوصى معلم البيانو لابني بضرورة استخدام المترونوم (metronome) لتحسين توقيت العزف، اغتنمت هذه الفرصة لتعلم لغة Kotlin. قررت استكشاف هذه اللغة ونظام Android البيئي لبناء تطبيق مترونوم خاص بي. في البداية، استخدمت SeekBar للتحكم في BPM (نبضة في الدقيقة) – وهو معدل دقات المترونوم. ولكن مع تطور المشروع، رغبت في محاكاة الوحدات الرقمية المادية التي يستخدمها العديد من الموسيقيين. هذه الوحدات الواقعية لا تحتوي على “SeekBar View” قياسي، لذا أردت محاكاة مقبض التحكم الدوار (rotary knob) الذي قد تجده فيها.
مزايا مقابض التحكم الدوارة
تُعد مقابض التحكم الدوارة (Rotary Knobs) عناصر تحكم مفيدة للغاية في واجهة المستخدم. تشبه إلى حد كبير أشرطة التمرير (sliders) أو SeekBar، ويمكن استخدامها في العديد من السيناريوهات. إليك بعض مزاياها البارزة:
- تستهلك مساحة صغيرة جداً على شاشة تطبيقك.
- يمكن استخدامها للتحكم في نطاقات قيم مستمرة أو منفصلة.
- يتعرف عليها المستخدمون فوراً من خلال تطبيقات العالم الحقيقي.
- ليست من عناصر التحكم القياسية في
Android، وبالتالي تمنح تطبيقك شعوراً “مخصصاً” وفريداً.
على الرغم من وجود بعض مكتبات المقابض الدوارة مفتوحة المصدر لنظام Android، إلا أنني لم أجد ما يلبي احتياجاتي تماماً في أي منها. كانت العديد منها مُبالغاً فيها بالنسبة لمتطلباتي المتواضعة، حيث توفر وظائف مثل تعيين صور خلفية أو التعامل مع النقرات لعمليات وضع متعددة. البعض الآخر لم يوفر المرونة الكافية للتخصيص التي أردتها لمشروعي، وجاءت مع صور مقبض خاصة بها. وهناك مكتبات أخرى افترضت نطاقاً منفصلاً للقيم أو المواضع. وبدا الكثير منها أكثر تعقيداً مما هو مطلوب. لذلك، قررت تصميم مقبض خاص بي، والذي تحول بدوره إلى مشروع صغير ممتع. في هذه المقالة، سأشرح كيفية بناء هذا المكون.

تطبيق المترونوم الناتج ومقبض التحكم الدوار الخاص به.
تصميم مقبض التحكم الدوار
الخطوة الأولى كانت إنشاء الرسم البياني للمقبض نفسه. أنا لست مصمماً بأي حال من الأحوال، لكن خطر لي أن المفتاح لخلق إحساس “بالعمق” والحركة في عنصر تحكم المقبض هو استخدام تدرج شعاعي (radial gradient) غير مركزي. هذا من شأنه أن يسمح لي بخلق وهم السطح المنخفض وانعكاس الضوء. استخدمت Sketch لرسم المقبض، ثم قمت بتصديره إلى صيغة SVG. بعد ذلك، قمت باستيراده مرة أخرى إلى Android Studio كـ drawable. يمكنك العثور على ملف drawable للمقبض في رابط مشروع GitHub في نهاية هذه المقالة.

إنشاء مكون العرض (View) في ملف XML
الخطوة الأولى في إنشاء مكون العرض (View) هي إنشاء ملف XML للتخطيط في مجلد res/layout. يمكن إنشاء العرض بالكامل في الكود، ولكن لإنشاء View جيد وقابل لإعادة الاستخدام في Android، يفضل إنشاؤه في XML. لاحظ وسم <merge> – سنستخدمه لأننا سنقوم بتوسيع (extending) فئة تخطيط Android موجودة، وسيكون هذا التخطيط هو الهيكل الداخلي لذلك التخطيط. سنستخدم ImageView للمقبض، والذي سنقوم بتدويره مع حركة المستخدم.
<?xml version="1.0" encoding="utf-8"?> < merge xmlns:android = "http://schemas.android.com/apk/res/android" android:layout_width = "wrap_content" android:layout_height = "wrap_content" > < ImageView android:id = "@+id/knobImageView" android:layout_width = "wrap_content" android:layout_height = "wrap_content" /> </ merge >
لجعل المقبض قابلاً للتكوين عبر XML، سنقوم بإنشاء سمات (attributes) لنطاق القيم التي سيعيدها المقبض، وكذلك للملف drawable الذي سيستخدمه للمظهر البصري. سننشئ ملف attrs.xml تحت مجلد res/values.
<?xml version="1.0" encoding="utf-8"?> < resources > < declare-styleable name = "RotaryKnobView" > < attr name = "minValue" format = "integer" /> < attr name = "maxValue" format = "integer" /> < attr name = "initialValue" format = "integer" /> < attr name = "knobDrawable" format = "reference" /> </ declare-styleable > </ resources >
إعداد فئة Kotlin لمقبض التحكم الدوار
بعد ذلك، أنشئ ملف فئة Kotlin جديد باسم RotaryKnobView، والذي يوسع (extends) RelativeLayout ويطبق الواجهة GestureDetector.OnGestureListener. سنستخدم RelativeLayout كحاوية رئيسية لعنصر التحكم، وسنطبق OnGestureListener للتعامل مع إيماءات حركة المقبض. تُعد @JvmOverloads مجرد اختصار لتجاوز جميع صيغ مُنشئ View الثلاثة. بعد ذلك، سنقوم بتهيئة بعض القيم الافتراضية وتحديد أعضاء الفئة.
class RotaryKnobView @JvmOverloads constructor ( context: Context, attrs: AttributeSet? = null , defStyleAttr: Int = 0 ) : RelativeLayout(context, attrs, defStyleAttr), GestureDetector.OnGestureListener { private val gestureDetector: GestureDetectorCompat private var maxValue = 99 private var minValue = 0 var listener: RotaryKnobListener? = null var value = 50 private var knobDrawable: Drawable? = null private var divider = 300f / (maxValue - minValue)
ملاحظة حول المتغير divider: أردت أن يكون للمقبض مواضع بداية ونهاية، بدلاً من الدوران إلى ما لا نهاية، تماماً مثل مقبض الصوت في نظام ستريو. قمت بتعيين نقطتي البداية والنهاية عند -150 و150 درجة على التوالي. وبالتالي، فإن الحركة الفعالة للمقبض هي 300 درجة فقط. سنستخدم divider لتوزيع نطاق القيم التي نريد أن يعيدها مقبضنا على هذه الـ 300 درجة المتاحة، وذلك حتى نتمكن من حساب القيمة الفعلية بناءً على زاوية موضع المقبض.
بعد ذلك، نقوم بتهيئة المكون:
- تضخيم التخطيط (
inflate the layout). - قراءة السمات (
attributes) إلى متغيرات. - تحديث المتغير
divider(لدعم قيم الحد الأدنى والأقصى الممررة). - تعيين الصورة.
init { this .maxValue = maxValue + 1 LayoutInflater.from(context) .inflate(R.layout.rotary_knob_view, this , true ) context.theme.obtainStyledAttributes( attrs, R.styleable.RotaryKnobView, 0 , 0 ).apply { try { minValue = getInt(R.styleable.RotaryKnobView_minValue, 0 ) maxValue = getInt(R.styleable.RotaryKnobView_maxValue, 100 ) + 1 divider = 300f / (maxValue - minValue) value = getInt(R.styleable.RotaryKnobView_initialValue, 50 ) knobDrawable = getDrawable(R.styleable.RotaryKnobView_knobDrawable) knobImageView.setImageDrawable(knobDrawable) } finally { recycle() } } gestureDetector = GestureDetectorCompat(context, this ) }
التعامل مع إيماءات المستخدم
لن يتم تجميع الفئة بعد، حيث نحتاج إلى تطبيق وظائف واجهة OnGestureListener. دعنا نتعامل مع ذلك الآن. تتطلب واجهة OnGestureListener أن نقوم بتطبيق ست وظائف: onScroll، onTouchEvent، onDown، onSingleTapUp، onFling، onLongPress، onShowPress. من بين هذه الوظائف، نحتاج إلى استهلاك (إرجاع true) في onDown و onTouchEvent، وتطبيق منطق الحركة في onScroll.
override fun onTouchEvent (event: MotionEvent ) : Boolean { return if (gestureDetector.onTouchEvent(event)) true else super .onTouchEvent(event) } override fun onDown (event: MotionEvent ) : Boolean { return true } override fun onSingleTapUp (e: MotionEvent ) : Boolean { return false } override fun onFling (arg0: MotionEvent , arg1: MotionEvent , arg2: Float , arg3: Float ) : Boolean { return false } override fun onLongPress (e: MotionEvent ) {} override fun onShowPress (e: MotionEvent ) {}
إليك تطبيق الدالة onScroll. سنقوم بملء الأجزاء المفقودة في الفقرة التالية. تستقبل onScroll مجموعتين من الإحداثيات، e1 و e2، تمثلان حركات البداية والنهاية للتمرير الذي أطلق الحدث. نحن مهتمون فقط بـ e2 – الموضع الجديد للمقبض – حتى نتمكن من تحريكه إلى هذا الموضع وحساب القيمة. أستخدم دالة سنراجعها في القسم التالي لحساب زاوية الدوران. كما ذكرنا سابقاً، نحن نستخدم 300 درجة فقط من نقطة بداية المقبض إلى نقطة نهايته، لذا هنا نحسب أيضاً القيمة التي يجب أن يمثلها موضع المقبض باستخدام المتغير divider.
override fun onScroll (e1: MotionEvent , e2: MotionEvent , distanceX: Float , distanceY: Float ) : Boolean { val rotationDegrees = calculateAngle(e2.x, e2.y) // use only -150 to 150 range (knob min/max points if (rotationDegrees >= - 150 && rotationDegrees <= 150 ) { setKnobPosition(rotationDegrees) // Calculate rotary value // The range is the 300 degrees between -150 and 150, so we'll add 150 to adjust the // range to 0 - 300 val valueRangeDegrees = rotationDegrees + 150 value = ((valueRangeDegrees / divider) + minValue).toInt() if (listener != null ) listener!!.onRotate(value) } return true }
حساب زاوية الدوران
الآن دعنا نكتب دالة calculateAngle. تتطلب هذه الدالة بعض الشرح وبعض الرياضيات الأساسية. الغرض من هذه الدالة هو حساب موضع المقبض بالزوايا، بناءً على الإحداثيات الممررة. اخترت التعامل مع موضع الساعة 12 للمقبض على أنه صفر، ثم زيادة موضعه إلى درجات موجبة عند الدوران في اتجاه عقارب الساعة، وتقليله إلى درجات سالبة عند الدوران عكس اتجاه عقارب الساعة من موضع الساعة 12.
private fun calculateAngle (x: Float , y: Float ) : Float { val px = (x / width.toFloat()) - 0.5 val py = ( 1 - y / height.toFloat()) - 0.5 var angle = -(Math.toDegrees(atan2(py, px))) .toFloat() + 90 if (angle > 180 ) angle -= 360 return angle }

نحصل على إحداثيات x و y من دالة onScroll، والتي تشير إلى الموضع داخل العرض حيث انتهت الحركة (لهذا الحدث). يمثل X و Y نقطة في نظام إحداثيات ديكارتي. يمكننا تحويل تمثيل هذه النقطة إلى نظام إحداثيات قطبي، يمثل النقطة بالزاوية فوق أو تحت المحور x ومسافة النقطة عن القطب. يمكن إجراء التحويل بين نظامي الإحداثيات باستخدام دالة atan2. لحسن الحظ، توفر لنا مكتبة الرياضيات في Kotlin تطبيقاً لدالة atan2، كما هو الحال في معظم مكتبات الرياضيات. ومع ذلك، نحتاج إلى مراعاة بعض الاختلافات بين نموذج المقبض الخاص بنا والتطبيق الرياضي البسيط:
- إحداثيات (0,0) تمثل الزاوية العلوية اليمنى للعرض وليس المنتصف.
- بينما يتقدم الإحداثي
xفي الاتجاه الصحيح – يزداد كلما تحركنا إلى اليمين – فإن الإحداثيyمعكوس؛ حيث يمثل 0 الجزء العلوي من العرض، بينما تمثل قيمة ارتفاع عرضنا أدنى خط بكسل في العرض.
لمعالجة ذلك، نقوم بقسمة x و y على عرض وارتفاع العرض على التوالي للحصول عليهما على مقياس طبيعي من 0 إلى 1. ثم نطرح 0.5 من كليهما لنقل نقطة (0,0) إلى المنتصف. وأخيراً، نطرح قيمة y من 1 لعكس اتجاهها.
نظام الإحداثيات القطبية يسير في الاتجاه المعاكس لما نحتاجه. تزداد قيمة الدرجات عندما ندور عكس اتجاه عقارب الساعة. لذلك نضيف إشارة سالبة لعكس نتيجة دالة atan2.
نريد أن تشير قيمة 0 درجة إلى الشمال، وإلا عند تجاوز الساعة 9، ستقفز القيمة من 0 إلى 359. لذلك نضيف 90 إلى النتيجة، مع الحرص على تقليل القيمة بمقدار 360 بمجرد أن تصبح الزاوية أكبر من 180 (لذلك نحصل على نطاق -180 < angle < 180 بدلاً من نطاق 0 < x < 360).
الخطوة التالية هي تحريك دوران المقبض. سنستخدم Matrix لتحويل إحداثيات ImageView. نحتاج فقط إلى الانتباه لقسمة ارتفاع وعرض العرض على 2 بحيث يكون محور الدوران هو منتصف المقبض.
private fun setKnobPosition (angle: Float ) { val matrix = Matrix() knobImageView.scaleType = ScaleType.MATRIX matrix.postRotate(angle, width.toFloat() / 2 , height.toFloat() / 2 ) knobImageView.imageMatrix = matrix }
وأخيراً وليس آخراً، دعنا نكشف عن واجهة (interface) لـ Activity أو Fragment المستهلك للاستماع إلى أحداث الدوران:
interface RotaryKnobListener { fun onRotate (value: Int ) }
استخدام مقبض التحكم الدوار
الآن، دعنا ننشئ تطبيقاً بسيطاً لاختبار مقبضنا. في النشاط الرئيسي (main activity)، دعنا ننشئ TextView ونسحب عرضاً من قائمة الحاويات. عند تقديم خيارات اختيار العرض، حدد RotaryKnobView.

قم بتحرير ملف XML لتخطيط النشاط، واضبط القيم الدنيا والقصوى والابتدائية، بالإضافة إلى drawable المراد استخدامه.
< geva.oren.rotaryknobdemo.RotaryKnobView android:id = "@+id/knob" class = "geva.oren.rotaryknobdemo.RotaryKnobView" android:layout_width = "@dimen/knob_width" android:layout_height = "@dimen/knob_height" android:layout_marginBottom = "312dp" app:layout_constraintBottom_toBottomOf = "parent" app:layout_constraintEnd_toEndOf = "parent" app:layout_constraintStart_toStartOf = "parent" app:layout_constraintTop_toBottomOf = "@+id/textView" app:knobDrawable = "@drawable/ic_rotary_knob" app:initialValue = "50" app:maxValue = "100" app:minValue = "0" />
أخيراً، في فئة MainActivity الخاصة بنا، قم بتضخيم التخطيط وتطبيق واجهة RotaryKnobListener لتحديث قيمة TextField.
package geva.oren.rotaryknobdemo import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity (), RotaryKnobView.RotaryKnobListener { override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) setContentView(R.layout.activity_main) knob.listener = this textView.text = knob.value.toString() } override fun onRotate (value: Int ) { textView.text = value.toString() } }
وبهذا نكون قد انتهينا! هذا المشروع التجريبي متاح على GitHub، وكذلك مشروع المترونوم الأصلي. تطبيق المترونوم لنظام Android متاح أيضاً على متجر Google Play.
الخلاصة التقنية
يُظهر هذا المقال بوضوح أن بناء مكونات واجهة مستخدم مخصصة (Custom UI Components) في Android باستخدام Kotlin ليس مجرد تمرين برمجي، بل هو فرصة لإضافة قيمة فريدة لتجربة المستخدم. يوفر مقبض التحكم الدوار (Rotary Knob) بديلاً جذاباً وعملياً لعناصر التحكم التقليدية مثل SeekBar، خاصة في التطبيقات التي تحاكي الأجهزة المادية. تكمن القوة في فهم كيفية معالجة الإيماءات وتحويل الإحداثيات الديكارتية إلى زوايا دوران، مما يفتح الباب أمام إمكانيات تصميم لا حصر لها. إن القدرة على تجاوز القيود وتصميم حلول مخصصة هي مهارة أساسية للمطورين الذين يسعون لتقديم تطبيقات متميزة.