Cómo construí un botón giratorio de Android con Kotlin para ayudar a mi hijo a practicar el piano

Cuando el profesor de piano de mi hijo le dijo que debería usar un metrónomo para practicar el tiempo, lo aproveché como una oportunidad para aprender Kotlin. Decidí aprender el idioma y el ecosistema de Android para poder crear una aplicación Metronome.

Mi implementación inicial usó un SeekBar para controlar BPM (Beats por Minuto), la velocidad a la que hace tictac del metrónomo.

Sin embargo, a medida que avanzaba el proyecto, quería que se pareciera a una unidad digital física, como la utilizan muchos músicos en el mundo físico real.

Las unidades físicas no tienen una "Vista SeekBar" y quería imitar el botón giratorio que podría tener una unidad real.

Los botones giratorios son controles de interfaz de usuario muy útiles. Se parecen mucho a un control deslizante o SeekBar, y se pueden utilizar en muchas situaciones. Estas son algunas de sus ventajas:

  • Consumen muy poco espacio en tu aplicación
  • Se pueden utilizar para controlar rangos de valores continuos o discretos.
  • Son inmediatamente reconocibles por los usuarios de aplicaciones del mundo real.
  • No son controles estándar de Android y, por lo tanto, otorgan una sensación "personalizada" única a su aplicación

Si bien existen algunas bibliotecas de botones de código abierto para Android, no encontré lo que estaba buscando en ninguna de ellas.

Muchos eran excesivos para mis modestas necesidades, con funciones como configurar imágenes de fondo o manejar toques para dos o más operaciones de modo, etc.

Algunos no tenían la capacidad de personalización que quería para mi proyecto y venían con su propia imagen de perilla.

Otros asumieron un rango discreto de valores o posiciones. Y muchos de ellos parecían mucho más complejos de lo necesario.

Así que decidí diseñar uno yo mismo, que se convirtió en un pequeño proyecto divertido en sí mismo.

En este artículo discutiré cómo lo construí.

Entonces, veamos cómo podemos crear un botón giratorio.

Diseñar una perilla

El primer paso fue crear el gráfico para la perilla. No soy diseñador de ninguna manera, pero se me ocurrió que la clave para crear una sensación de "profundidad" y movimiento en un control de perilla sería utilizar un gradiente radial descentrado. Esto me permitiría crear la ilusión de una superficie deprimida y un reflejo de luz.

Usé Sketch para dibujar la perilla, luego la exporté a svg. Luego lo importé de nuevo al estudio de Android como dibujable.

Puede encontrar la perilla que se puede dibujar en el enlace del proyecto de GitHub al final de este artículo.

Creando la vista en xml

El primer paso para crear la Vista es crear un archivo xml de diseño en la carpeta res / layout.

La vista se puede crear completamente en código, pero se debe crear una buena Vista reutilizable en Android en xml.

Observe la etiqueta; la usaremos ya que ampliaremos una clase de diseño de Android existente y este diseño será la estructura interna de ese diseño.

Usaremos un ImageView para la perilla, que rotaremos a medida que el usuario la mueva.

Para hacer que la perilla sea configurable por xml, crearemos atributos para el rango de valores que devolverá la perilla, así como para el elemento de diseño que usará para las imágenes.

Crearemos un archivo attrs.xml en res / values.

A continuación, cree un nuevo archivo de clase Kotlin, RotaryKnobView, que amplíe RelativeLayout e implemente la interfaz GestureDetector.OnGestureListener.

Usaremos RelativeLayout como contenedor principal para el control e implementaremos OnGestureListener para manejar los gestos de movimiento de la perilla.

@JvmOverloads es solo un atajo para anular los tres tipos del constructor View.

A continuación, inicializaremos algunos valores predeterminados y definiremos los miembros de la clase.

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)

Una nota sobre la variable divisoria: quería que la perilla tuviera posiciones de inicio y final, en lugar de poder rotar indefinidamente, como una perilla de volumen en un sistema estéreo. Establecí los puntos de inicio y finalización en -150 y 150 grados, respectivamente. Entonces, el movimiento efectivo de la perilla es de solo 300 grados.

Usaremos el divisor para distribuir el rango de valores que queremos que devuelva nuestra perilla sobre estos 300 grados disponibles, de modo que podamos calcular el valor real en función del ángulo de posición de la perilla.

A continuación, inicializamos el componente:

  • Infle el diseño.
  • Lea los atributos en variables.
  • Actualice el divisor (para admitir el pasado en valores mínimos y máximos.
  • Configura la imagen.
 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) }

La clase no se compilará todavía, ya que necesitamos implementar las funciones de OnGestureListener. Manejemos eso ahora.

Detectar gestos del usuario

La interfaz OnGestureListener requiere que implementemos seis funciones:

onScroll, onTouchEvent, onDown, onSingleTapUp, onFling, onLongPress, onShowPress.

De estos, necesitamos consumir (devolver verdadero) en onDown y onTouchEvent, e implementar el inicio de sesión de movimiento en 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) {}

Aquí está la implementación de onScroll. Completaremos las partes que faltan en el siguiente párrafo.

 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 recibe dos conjuntos de coordenadas, e1 y e2, que representan los movimientos de inicio y finalización del desplazamiento que desencadenó el evento.

Solo nos interesa el e2, la nueva posición de la perilla, por lo que podemos animarlo para colocarlo y calcular el valor.

Estoy usando una función que revisaremos en la siguiente sección para calcular el ángulo de rotación.

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.