Як я побудував поворотну ручку Android з Kotlin, щоб допомогти моєму синові вправлятись на фортепіано

Коли вчитель фортепіано мого сина сказав йому, що він повинен використовувати метроном, щоб потренуватися в хронометражі, я взяв це як можливість вивчити Котліна. Я вирішив вивчити мову та екосистему Android, щоб створити додаток Metronome.

Моя початкова реалізація використовувала SeekBar для контролю BPM (ударів за хвилину) - швидкості, з якою тикає метроном.

Однак у міру того, як проект прогресував, я хотів зробити його схожим на фізичну цифрову одиницю, якою користуються багато музикантів у реальному фізичному світі.

Фізичні одиниці не мають "SeekBar View", і я хотів імітувати поворотну ручку, яку може мати реальна одиниця.

Поворотні ручки - дуже корисні елементи керування інтерфейсом. Вони нагадують повзунок або панель пошуку, що використовується у багатьох ситуаціях. Ось деякі їх переваги:

  • Вони споживають дуже мало нерухомості у вашому додатку
  • Вони можуть використовуватися для управління безперервними або дискретними діапазонами значень
  • Вони відразу впізнаються користувачами з реальних програм
  • Вони не є стандартними елементами керування для Android, і, таким чином, надають унікальний «спеціальний» характер вашому додатку

Поки існує кілька бібліотек з відкритим кодом для Android, я не знайшов у тому, що шукав.

Багато з них були надмірними для моїх скромних потреб, такі функції, як встановлення фонових зображень або обробка кранів для двох або більше операцій в режимі тощо.

Деякі не мали можливості налаштування, я хотів відповідати моєму проекту, і прийшов із власним зображенням ручки.

Треті припускали дискретний діапазон значень або позицій. І багато з них здавалися набагато складнішими, ніж потрібно.

Тож я вирішив створити його сам, що само по собі перетворилося на кумедний маленький проект.

У цій статті я обговорю, як я його побудував.

Тож давайте подивимося, як ми можемо створити поворотну ручку.

Розробка ручки

Першим кроком було створення графіки для самої ручки. Я ніяким чином не є дизайнером, але мені спало на думку, що ключовим фактором створення відчуття “глибини” та руху в ручці регулятора буде використання відцентрового радіального градієнта. Це дозволило б мені створити ілюзію пригніченої поверхні та відбиття світла.

Я використав ескіз, щоб намалювати ручку, а потім експортував її у svg. Потім я імпортував його назад у студію Android як малювальний.

Ви можете знайти ручку, яку можна малювати, за посиланням проекту GitHub унизу цієї статті.

Створення подання в xml

Першим кроком у створенні подання є створення файлу xml макета в папці res / layout.

Вигляд можна повністю створити в коді, але хороший багаторазовий перегляд в Android повинен бути створений у xml.

Зверніть увагу на тег - ми використаємо це, оскільки ми будемо розширювати існуючий клас макета Android, і цей макет буде внутрішньою структурою цього макета.

Ми використовуватимемо ImageView для ручки, яку ми будемо обертати, коли користувач рухає її.

Щоб зробити регулятор конфігуруваним за допомогою xml, ми створимо атрибути для діапазону значень, які повертає ручка, а також для малюваного, який він буде використовувати для візуалів.

Ми створимо файл attrs.xml у розділі res / values.

Далі створіть новий файл класу Kotlin, RotaryKnobView, який розширює 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)

Примітка щодо змінної дільника - я хотів, щоб регулятор мав початкове і кінцеве положення, а не можливість необмеженого обертання, подібно до регулятора гучності на стереосистемі. Я встановив початкову та кінцеву точки -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.

З них нам потрібно споживати (повернути 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. У наступному абзаці ми заповнимо відсутні частини.

 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 - нове положення регулятора - тому ми можемо анімувати його до положення та обчислити значення.

Я використовую функцію, яку ми розглянемо в наступному розділі для обчислення кута повороту.

As mentioned earlier, we’re only using 300 degrees from the knob's start point to its end point, so here we also calculate what value the knob’s position should represent using the divider.

Calculating the rotation angle

Now let’s write the calculateAngle function.

 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 }

This function calls for a bit of explanation and some 8th grade math.

The purpose of this function is to calculate the position of the knob in angles, based on the passed coordinates.

I opted to treat the 12 o’clock position of the knob as zero, and then increase its position to positive degrees when turning clockwise, and reducing to negative degrees when turning counterclockwise from 12 o’clock.

We get the x, y coordinates from the onScroll function, indicating the position within the view where the movement ended (for that event).

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.

The Android Metronome app is also available on Google’s play store.