كيف صنعت مقبض دوار Android مع Kotlin لمساعدة ابني في ممارسة البيانو

عندما أخبره مدرس البيانو الخاص بابني أنه يجب عليه استخدام المسرع لممارسة التوقيت ، انتهزته كفرصة لتعلم لغة Kotlin. قررت أن أتعلم اللغة والنظام البيئي لنظام Android حتى أتمكن من إنشاء تطبيق Metronome.

استخدم تطبيقي الأولي SeekBar للتحكم في BPM (نبضة في الدقيقة) - المعدل الذي يدق به الميترونوم.

ومع ذلك ، مع تقدم المشروع ، أردت أن أجعله يشبه وحدة رقمية فعلية ، كما يستخدمها العديد من الموسيقيين في العالم المادي الحقيقي.

لا تحتوي الوحدات الفيزيائية على "SeekBar View" ، وأردت تقليد المقبض الدوار الذي قد تمتلكه الوحدة الفعلية.

المقابض الدوارة هي عناصر تحكم مفيدة للغاية في واجهة المستخدم. إنها تشبه إلى حد كبير شريط التمرير أو SeekBar ، ويمكن استخدامها في العديد من المواقف. فيما يلي بعض مزاياها:

  • يستهلكون القليل جدًا من العقارات في تطبيقك
  • يمكن استخدامها للتحكم في نطاقات القيم المستمرة أو المنفصلة
  • يمكن التعرف عليها على الفور بواسطة المستخدمين من تطبيقات العالم الحقيقي
  • إنها ليست عناصر تحكم Android قياسية ، وبالتالي تضفي إحساسًا "مخصصًا" فريدًا على تطبيقك

على الرغم من وجود عدد قليل من مكتبات المفاتيح مفتوحة المصدر لنظام Android ، إلا أنني لم أجد ما كنت أبحث عنه تمامًا في أي منها.

كان الكثير منهم مبالغة في تلبية احتياجاتي المتواضعة ، مع وظائف مثل تعيين صور الخلفية أو التعامل مع الصنابير لعمليتين أو أكثر من عمليات الوضع ، إلخ.

لم يكن لدى البعض القدرة على التخصيص التي أردتها لتناسب مشروعي وجاءوا مع صورة المقبض الخاصة بهم.

لا يزال البعض الآخر يفترض مجموعة منفصلة من القيم أو المواقف. وبدا الكثير منهم أكثر تعقيدًا مما هو مطلوب.

لذلك قررت تصميم واحدة بنفسي - والتي تحولت إلى مشروع صغير ممتع في حد ذاته.

في هذه المقالة سأناقش كيف بنيتها.

لذلك دعونا نرى كيف يمكننا إنشاء مقبض دوار.

تصميم مقبض

كانت الخطوة الأولى هي إنشاء الرسم للمقبض نفسه. أنا لست مصممًا بأي شكل من الأشكال ، ولكن خطر لي أن المفتاح لخلق إحساس "بالعمق" والحركة في مقبض التحكم سيكون باستخدام تدرج نصف قطري خارج المركز. سيسمح لي هذا بإيجاد الوهم بوجود سطح منخفض وانعكاس للضوء.

لقد استخدمت Sketch لرسم المقبض ، ثم قمت بتصديره إلى svg. ثم قمت باستيراده مرة أخرى إلى Android studio كقابل للرسم.

يمكنك العثور على المقبض القابل للرسم في رابط مشروع GitHub أسفل هذه المقالة.

إنشاء العرض في xml

تتمثل الخطوة الأولى في إنشاء العرض في إنشاء ملف تخطيط xml في مجلد res / layout.

يمكن إنشاء العرض بالكامل في رمز ، ولكن يجب إنشاء عرض جيد قابل لإعادة الاستخدام في Android بتنسيق xml.

لاحظ العلامة - سنستخدم ذلك نظرًا لأننا سنقوم بتوسيع فئة تخطيط Android الحالية وسيكون هذا التخطيط هو الهيكل الداخلي لذلك التخطيط.

سنستخدم ImageView للمقبض ، والذي سنقوم بتدويره أثناء تحريك المستخدم له.

لجعل المقبض قابلاً للتكوين بواسطة xml ، سننشئ سمات لمجموعة القيم التي سيعود بها المقبض ، بالإضافة إلى العنصر القابل للرسم الذي سيستخدمه في المرئيات.

سننشئ ملف attrs.xml ضمن الدقة / القيم.

بعد ذلك ، قم بإنشاء ملف فئة Kotlin جديد ، RotaryKnobView ، والذي يمتد إلى RelativeLayout ويقوم بتنفيذ واجهة GestureDetector.OnGestureListener.

سنستخدم RelativeLayout كحاوية رئيسية للتحكم ، ونطبق OnGestureListener للتعامل مع إيماءات حركة المقبض.

JvmOverloads هو مجرد اختصار لتجاوز جميع النكهات الثلاث لمنشئ العرض.

بعد ذلك سنقوم بتهيئة بعض القيم الافتراضية وتحديد أعضاء الفصل.

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)

ملاحظة حول متغير الفاصل - أردت أن يكون للمقبض وضعي البداية والنهاية ، بدلاً من أن يكون قادرًا على التدوير إلى أجل غير مسمى ، تمامًا مثل مقبض الصوت في نظام الاستريو. قمت بتعيين نقطتي البداية والنهاية عند -150 درجة و 150 درجة على التوالي. لذا فإن الحركة الفعالة للمقبض هي 300 درجة فقط.

سنستخدم المقسّم لتوزيع نطاق القيم التي نريد أن يعيدها مقبضنا بناءً على 300 درجة المتاحة - حتى نتمكن من حساب القيمة الفعلية بناءً على زاوية موضع المقبض.

بعد ذلك ، نقوم بتهيئة المكون:

  • تضخيم التخطيط.
  • اقرأ السمات في المتغيرات.
  • قم بتحديث الحاجز (لدعم القيم التي تم تمريرها في الحد الأدنى والحد الأقصى.
  • اضبط الصورة.
 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.

من بينها ، نحتاج إلى استهلاك (إرجاع صحيح) في 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. سنملأ الأجزاء المفقودة في الفقرة التالية.

 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 }

يتلقى onScroll مجموعتين من الإحداثيات ، e1 و e2 ، تمثلان حركات البداية والنهاية للتمرير الذي أطلق الحدث.

نحن مهتمون فقط بـ e2 - الموضع الجديد للمقبض - حتى نتمكن من تحريكه لوضعه وحساب القيمة.

أنا أستخدم دالة سنراجعها في القسم التالي لحساب زاوية الدوران.

كما ذكرنا سابقًا ، نحن نستخدم 300 درجة فقط من نقطة بداية المقبض إلى نقطة نهايته ، لذلك نحسب هنا أيضًا القيمة التي يجب أن يمثلها موضع المقبض باستخدام الحاجز.

حساب زاوية الدوران

لنكتب الآن دالة calculateAngle.

 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 }

تتطلب هذه الوظيفة القليل من الشرح وبعض رياضيات الصف الثامن.

الغرض من هذه الوظيفة هو حساب موضع المقبض في الزوايا ، بناءً على الإحداثيات التي تم تمريرها.

اخترت أن أتعامل مع موضع الساعة 12 للمقبض على أنه صفر ، ثم أزيد موضعه إلى درجات موجبة عند الدوران في اتجاه عقارب الساعة ، وتقليله إلى درجات سالبة عند الدوران عكس اتجاه عقارب الساعة من الساعة 12.

نحصل على إحداثيات x و y من وظيفة onScroll ، مما يشير إلى الموضع داخل العرض حيث انتهت الحركة (لهذا الحدث).

X and y represent a point on a cartesian coordinate system. We can convert that point representation to a polar coordinate system, representing the point by the angle above or below the x axis and the distance of the point from the pole.

Converting between the two coordinate systems can be done with the atan2 function. Luckily for us, the Kotlin math library provides us with an implementation of atan2, as do most Math libraries.

We do, however, need to account for a few differences between our knob model and the naïve math implementation.

  1. The (0,0) coordinates represent the top right corner of the view and not the middle. And while the x coordinate progresses in the right direction — growing as we move to the right — the y coordinate is backwards — 0 is the top of the view, while the value of our view’s height is the lowest pixel line in the view.

    To accommodate that we divide x and y by the respective width and height of the view to get them on a normalized scale of 0–1.

    Then we subtract 0.5 from both to move the 0,0 point to the middle.

    And lastly, we subtract y’s value from 1 to reverse its direction.

  2. The polar coordinate system is in reverse direction to what we need. The degrees value rises as we turn counter clockwise. So we add a minus sign to reverse the result of the atan2 function.
  3. We want the 0 degrees value to point north, otherwise passing 9 o’clock, the value will jump from 0 to 359.

    So we add 90 to the result, taking care to reduce the value by 360 once the angle is larger than 180 (so we get a -180 < angle < 180 range rather than a 0 < x < 360 range)

The next step is to animate the rotation of the knob. We'll use Matrix to transform the coordinates of the ImageView.

We just need to pay attention to dividing the view’s height and width by 2 so the rotation axis is the middle of the knob.

 private fun setKnobPosition(angle: Float) { val matrix = Matrix() knobImageView.scaleType = ScaleType.MATRIX matrix.postRotate(angle, width.toFloat() / 2, height.toFloat() / 2) knobImageView.imageMatrix = matrix }

And last but not least, let’s expose an interface for the consuming Activity or Fragment to listen to rotation events:

 interface RotaryKnobListener { fun onRotate(value: Int) }

Using the knob

Now, let’s create a simple implementation to test our knob.

In the main activity, let's create a TextView and drag a view from the containers list. When presented with the view selection, select RotaryKnobView.

Edit the activity’s layout xml file, and set the minimum, maximum, and initial values as well as the drawable to use.

Finally, in our MainActivity class, inflate the layout and implement the RotaryKnobListener interface to update the value of the 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() } }

And we're done! This example project is available on github as well as the original metronome project.

تطبيق Android Metronome متاح أيضًا في متجر Google play.