Piano Android con Kotlin, MVVM y LiveData

2
9054

Índice de contenidos

1. Introducción

Con este tutorial podrás construir una aplicación para tablets o móviles que utilicen Android, consistente en un piano de doce teclas que reaccionen al ser pulsadas cambiando visualmente y reproduciendo su sonido. Como lenguaje de desarrollo utilizaremos Kotlin y emplearemos una arquitectura MVVM con LiveData.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: MacBook Pro 17’ (2,66 GHz Intel Core i7, 8GB DDR3)
  • Sistema operativo: macOS Sierra 10.13.6
  • Entorno de desarrollo: Android Studio 3.2
  • Versión mínima Android SDK: 16

3. Arquitectura VMMV y LiveData

La arquitectura que utilizaremos para este tutorial será MVVM. Como MVC y MVP, consta de tres capas, pero guarda diferencias respecto a éstas, por lo que vamos a definirlas:

  • Modelo: representa la capa de datos y la lógica de negocio sin haber diferencias importantes con los otros patrones mencionados.
  • Vista: se encarga de mostrar la información directamente al usuario y es la capa con la que éste interacciona directamente a través de eventos, permitiendo la manipulación de la aplicación y sus datos.
  • View Model o Modelo de Vista: sirve de intermediario entre el modelo y la vista, proporcionando datos a esta última permitiéndole modificarlos. Es ajena a la vista, es decir, no contiene referencias a ésta y, por lo tanto, puede ser reutilizable por varias de ellas. Por tanto, la comunicación con ella se realiza por medio de enlaces de datos o bindings, que, en nuestro caso, será LiveData. Además, una vista puede necesitar de la información proporcionada por más de un View Model.

 

Como vemos, la Vista y el View Model tienen un bajo acoplamiento, lo cual ayuda mucho en la testabilidad al trasladar parte de la lógica al View Model, pero preservando la independencia de esta capa de los elementos de la interfaz de Android.

LiveData es un contenedor de datos asociado a un ciclo de vida de la aplicación. Permite ser observado, por lo que tendremos la seguridad de que al actualizarse las vistas serán notificadas y podrán tratar los cambios como necesiten. Además, al estar asociado al ciclo de vida de un fragmento o actividad, cuando éstos se destruyen, el objeto LiveData también lo hará, previniendo pérdidas de memoria y no siendo necesario el tratamiento manual de su ciclo de vida.

4. Instalación de Android Studio y creación del proyecto

Para desarrollar en Android recomiendo utilizar Android Studio que, como su nombre indica, está pensado para esta tarea. Es gratuito, tiene versiones para macOS, Windows y Linux, y está desarrollado por JetBrains, que son los mismos que desarrollan IntellIJ, así que si has utilizado este entorno te será fácil la adaptación.

Si nunca lo has utilizado, puedes descargarlo de su página oficial e instalarlo como otra aplicación para tu sistema operativo.

Una vez abierto, para crear un proyecto tan sólo tenemos que pulsar en la primera opción de la lista que se nos muestra y configurar nuestro proyecto, incluyendo datos como el nombre de la aplicación. Este tutorial está pensado para Kotlin, por lo que es importante marcar la opción para darle soporte.

Las últimas pantallas nos dejan seleccionar la versión de Android y la plantilla que vamos a utilizar. En nuestro caso indicaremos que nuestra aplicación será para teléfono y tablet, con un SDK mínimo de 16 (API 16: Android 4.1) y utilizaremos la plantilla de actividad vacía.

5. Desarrollando las capas

5.1. El Modelo

Una vez tenemos el proyecto creado, podemos comenzar a desarrollar. Para esto, vamos a crear la capa del modelo, que consiste en una clase que represente las teclas del piano (Key) y otra el piano en general (Keyboard).

Las teclas del piano tendrán dos atributos: pulsed, que indica si la tecla está pulsada o no, y pitch el cual indica los posibles valores de las notas en notación de enteros. Además, la clase Key contará con un método isWhite() que, en función del pitch, indicará si la tecla es blanca o negra.

La notación utilizada asigna a las teclas un valor del 0 al 11, siendo el do un 0, el do sostenido un 1 y el si un 11. Esto elimina la ambigüedad que existe con la denominación más corriente de las notas, donde la tecla del do sostenido también es re bemol. Para el tutorial esto es suficiente, ya que nuestro piano corresponde a una octava, pero si quisiéramos crear uno más grande, deberíamos añadir un atributo que se refiera a la octava de las teclas para poder diferenciarlas.

class Key(val pitch: Int, var pulsed : Boolean = false) {
    fun isWhite(): Boolean = pitch in arrayOf(0, 2, 4, 5, 7, 9, 11)
}

Por otro lado, la clase Keyboard es bastante sencilla, ya que contará con un único atributo: un array de teclas.

class Keyboard(val keys: Array<Key>)

Por último, vamos a crear un objeto Factory para la creación del teclado por defecto, definiendo una clase que se encargue de esta responsabilidad.

class KeyboardFactory {
    fun createDefaultKeyboard(): Keyboard {
        val mutableList = mutableListOf<Key>()
        for (i in 0..11) mutableList.add(Key(i))
        return Keyboard(mutableList.toTypedArray())
    }
}

5.2 El View Model

La responsabilidad de nuestro ViewModel será decidir cuándo hay que actualizar el modelo al pulsar o soltar teclas. Además, será quien contenga el objeto LiveData, el cual en Kotlin deberemos utilizar instanciando la clase MutableLiveData, y el encargado de actualizarlo para proporcionar el teclado a la vista.

Pero antes de implementar el ViewModel, necesitamos importar dos dependencias de livecycle al fichero de build.gradle, el relativo al módulo.

La sección de dependencias deberá contener las dos líneas que se marcan a continuación y después deberemos sincronizar nuestro proyecto:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    implementation "android.arch.lifecycle:extensions:1.1.0"
    implementation "android.arch.lifecycle:viewmodel:1.1.0"
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

Es posible que se encargue al ViewModel pulsar una tecla ya pulsada o soltar una no pulsada. Para evitar actualizar el LiveData cuando no sea necesario, comprobaremos si realmente hay que cambiar algo. Además, vamos a añadir un método estático para crear una instancia asociada al fragmento que se le pase por parámetro.

class KeyboardVM(
   private val keyboard: Keyboard = KeyboardFactory().createDefaultKeyboard(),
   val liveDataKeys: MutableLiveData<Keyboard> = MutableLiveData()
) : ViewModel() {

   init {
       liveDataKeys.postValue(keyboard)
   }

   fun updatePulsedKeys(keys: Array<Key>) {
       val keysToRelease = keyboard.keys.filter { it.pulsed && !keys.contains(it) }
       val keysToPulse = keyboard.keys.filter { !it.pulsed && keys.contains(it) }

       for (key in keysToRelease) keyboard.keys.firstOrNull { it == key }?.pulsed = false
       for (key in keysToPulse) keyboard.keys.firstOrNull { it == key }?.pulsed = true

       liveDataKeys.postValue(keyboard)
   }

   fun releaseKey(key: Key) {
       keyboard.keys.firstOrNull { it == key }?.pulsed = false
       liveDataKeys.postValue(keyboard)
   }


   companion object {
       fun create(fragment: Fragment) = ViewModelProviders.of(fragment).get(KeyboardVM::class.java)
   }
}

5.3. La Vista

Nuestra vista consistirá, en un primer nivel, en un fragmento, ya que la idea es que este piano pueda ser incorporado a proyectos más grandes en caso de ser necesario. Un fragmento de Android representa una parte de una actividad, ya sea un comportamiento o un trozo de la interfaz.

5.3.1. Las vistas de las teclas

Antes de programar nuestro fragmento, vamos a crear las clases necesarias para convertir los objetos de tipo Key en sus representaciones gráficas. Para ello implementaremos una clase denominada KeyView y una KeyViewFactory encargada de dicha conversión. Además, vamos a crear cuatro drawables para pintar los fondos de nuestras teclas y un BackgroundViewManager que se encargue de administrarlos.

La clase KeyView debe heredar de View para que Android pueda tratarla como tal, pero también deberá tener los atributos que tiene Key. Como Kotlin no soporta la herencia múltiple, añadiremos directamente Key como un atributo suyo.

class KeyView(context: Context?, val key: Key) : View(context)

Los drawables los tenemos que definir en unos archivos xml guardados en la carpeta drawable, dentro de res. Para su creación podemos hacer click derecho en esta carpeta y dar a New>Drawable resource file.

Los códigos de los cuatro fondos son los siguientes, teniendo que poner el nombre al crearlo y pegar el código en la pestaña Text (por defecto se puede abrir Design):

key_white_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:shape="rectangle"
   tools:ignore="MissingDefaultResource">
   <solid android:color="@android:color/white" />
   <stroke
       android:width="1dp"
       android:color="#000" />
</shape>

key_white_pulsed_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:shape="rectangle"
   tools:ignore="MissingDefaultResource">
   <gradient
       android:type="linear"
       android:centerX="20%"
       android:startColor="#FFbbbbbb"
       android:centerColor="#FFeeeeee"
       android:endColor="#FFffffff"
       android:angle="90"/>
   <stroke
       android:width="1dp"
       android:color="#000" />
</shape>

key_black_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
   <gradient
       android:type="linear"
       android:centerX="25%"
       android:startColor="#FF777777"
       android:centerColor="#FF333333"
       android:endColor="#FF000000"
       android:angle="90"/>
   <stroke
       android:width="1dp"
       android:color="#000" />
</shape>

key_black_pulsed_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
   <gradient
       android:type="linear"
       android:centerX="25%"
       android:startColor="#FF333333"
       android:centerColor="#FF222222"
       android:endColor="#FF000000"
       android:angle="90"/>
   <stroke
       android:width="1dp"
       android:color="#000" />
</shape>

Una vez ya tenemos los drawables, podemos implementar su manager y el KeyViewFactory:

class BackgroundManager(private val resources: Resources?) {
   private fun getDrawable(id: Int) =
       if (resources != null) ResourcesCompat.getDrawable(resources, id, null) else null

   fun setViewBackground(keyView: KeyView) {
       keyView.background = when {
           keyView.key.isWhite() && keyView.key.pulsed -> getDrawable(R.drawable.key_white_pulsed_background)
           keyView.key.isWhite() -> getDrawable(R.drawable.key_white_background)
           keyView.key.pulsed -> getDrawable(R.drawable.key_black_pulsed_background)
           else -> getDrawable(R.drawable.key_black_background)
       }
   }
}
class KeyViewFactory(
   private val context: Context?,
   private val backgroundManager: BackgroundManager = BackgroundManager(context?.resources)
) {

   fun createViews(keys: Array<Key>): Array<KeyView> {
       val views = mutableListOf<KeyView>()

       for (key in keys) {
           val view = KeyView(context, key)
           backgroundManager.setViewBackground(view)
           views.add(view)
       }

       return views.toTypedArray()
   }
}

5.3.2. El layout del piano

El siguiente paso es crear un layout intermedio que sirva para agrupar estas KeyView y controlar sus dimensiones. Es importante que a la hora de añadir las teclas en él añadamos primero las blancas para que las teclas negras queden superpuestas, lo cual hacemos en el método addKeyboard.

class KeyboardLayout(
   context: Context?,
   private val keyViews: MutableList<KeyView> = mutableListOf(),
   private val keyViewFactory: KeyViewFactory = KeyViewFactory(context)
) :
   LinearLayout(context) {

   private val relativeBlackWidth = 0.6 //relative width to white keys width
   private val relativeBlackHeight = 0.6 //relative height to white keys height
   private var keyWidth = 0

   fun clearKeyViews() {
       keyViews.clear()
       this.removeAllViews()
   }

fun addKeyboard(keyboard: Keyboard) {
   val views = keyViewFactory.createViews(keyboard.keys)

   keyViews.addAll(views)
   for (view in views.filter { it.key.isWhite() }) this.addView(view)
   for (view in views.filter { !it.key.isWhite() }) this.addView(view)

   if (keyViews.size > 0) sizeKeys()
}

   override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
       super.onSizeChanged(w, h, oldw, oldh)
       if (keyViews.size > 0) sizeKeys()
   }

   override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
       super.onLayout(changed, l, t, r, b)
       if (keyViews.size > 0) sizeKeys()
   }

   private fun sizeKeys() {
       val whiteKeys = keyViews.filter { it.key.isWhite() }
       keyWidth = if (whiteKeys.isNotEmpty()) width / whiteKeys.size else 0
       var initPosition = 0

       for (view in keyViews) {
           if (view.key.isWhite()) {
               sizeWhiteKey(view, initPosition)
               initPosition++
           } else {
               sizeBlackKey(view, initPosition)
           }
       }
   }

   private fun sizeBlackKey(view: KeyView, initPosition: Int) {
       val left = ((initPosition - relativeBlackWidth / 2) * keyWidth).toInt()
       val right = (left + keyWidth * relativeBlackWidth).toInt()
       val bottom = (height * relativeBlackHeight).toInt()
       view.layout(left, 0, right, bottom)
   }

   private fun sizeWhiteKey(view: KeyView, initPosition: Int) {
       var initPosition1 = initPosition
       val right = if (view == keyViews.last()) width else (initPosition1 + 1) * keyWidth
       view.layout(initPosition1 * keyWidth, 0, right, height)
   }

}

5.3.3. Creación del fragmento

Y ahora vamos a crear el fragmento, sobrescribiendo los métodos necesarios, entre ellos onActivityCreated para observar el LiveData del ViewModel, estableciendo que, cada vez que éste se modifique, se deberá llamar al KeyboardLayout para redibujar las teclas.

class KeyboardFragment : Fragment() {

   private lateinit var keyboardLayout: KeyboardLayout

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
   ): View? {
       keyboardLayout = KeyboardLayout(context)
       return keyboardLayout
   }

   override fun onActivityCreated(savedInstanceState: Bundle?) {
       super.onActivityCreated(savedInstanceState)
       subscribeToVM(KeyboardVM.create(this))
   }

private fun subscribeToVM(viewModel: KeyboardVM) {
   val keyboardObserver = Observer<Keyboard> { keyboard ->
       if (keyboard != null) {
           addKeyboardToLayout(keyboard)
       }
   }
   viewModel.liveDataKeys.observe(this, keyboardObserver)
}

private fun addKeyboardToLayout(keyboard: Keyboard) {
   keyboardLayout.clearKeyViews()
   keyboardLayout.addKeyboard(keyboard)
}

}

Por último, vamos a añadir el fragmento a la actividad, modificando el fichero activity_main.xml que está en res>layout. Pero para hacerlo vamos a necesitar un id único que identifique este fragmento. ¿Y cómo lo conseguimos? Tan sólo necesitamos definirlo en un fichero ids.xml, que crearemos dentro de la carpeta res>values, de forma similar a cuando creamos los drawables.

<?xml version="1.0" encoding="utf-8"?>
<resources>
   <item type="id" name="keyboardFragment"/>
</resources>

Al editar el código de activity_main.xml, que es el código de a continuación, revisa el paquete del fragmento, ya que igual no coincide con el de tu proyecto.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
       xmlns:tools="http://schemas.android.com/tools"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       tools:context=".FragmentExampleActivity">

       <fragment
   android:id="@id/keyboardFragment"
           android:name="com.autentia.keyboardtutorial.view.KeyboardFragment"
           android:layout_width="match_parent"
           android:layout_height="match_parent" />
   </LinearLayout>
</android.support.constraint.ConstraintLayout>

Si ejecutamos nuestra aplicación, veremos cómo se muestran las teclas, pero que no se fuerzan a mostrarse en horizontal. Para solucionar esto, tan sólo tenemos que añadir una línea en el método onCreate de la clase MainActivity para establecer requestedOrientation como landscape:

class MainActivity : AppCompatActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
   }
}

En este punto ya tenemos la representación gráfica del piano, pero todavía nos queda controlar los eventos necesarios para que exista una interacción real con el usuario y que se reproduzcan los sonidos.

6. Creación del Event Handler

Para el tratamiento de los eventos del piano, vamos a crear una nueva clase que guarde las referencias a las KeyView para poder buscar cual está recibiendo un evento y notificárselo al ViewModel.

Los eventos que trataremos serán:

  • Eventos para pulsar las teclas:
    • ACTION_MOVE
    • ACTION_DOWN
    • ACTION_POINTER_DOWN
  • Eventos para soltar las teclas:
    • ACTION_CANCEL
    • ACTION_UP
    • ACTION_POINTER_UP
class KeyboardEventHandler(private val keyboardVM: KeyboardVM) {

    private val keyViews = mutableListOf<KeyView>()

    fun clearKeyViews() {
        keyViews.clear()
    }

    fun setKeyViews(views: Array<KeyView>) {
        keyViews.addAll(views)
    }

    fun handleEvent(event: MotionEvent) {
        when (event.actionMasked) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_MOVE ->
                updatePulsedKeys(event)
            MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL ->
                releaseKey(event)
        }
    }

    private fun releaseKey(event: MotionEvent) {
        val keyView = findKey(event.getX(event.actionIndex), event.getY(event.actionIndex))
        if (keyView != null) keyboardVM.releaseKey(keyView.key)
    }

    private fun updatePulsedKeys(event: MotionEvent) {
        val keys = mutableListOf<Key>()

        val keysWithEvents = findKeysInEvents(event)
        for (view in keysWithEvents) keys.add(view.key)

        if (keys.size > 0) keyboardVM.updatePulsedKeys(keys.toTypedArray())
    }

    private fun findKey(x: Float, y: Float): KeyView? {
        val keysWithBlackFirst = keyViews.sortedBy { it.key.isWhite() }
        return keysWithBlackFirst.firstOrNull { it.left < x && it.right > x && it.top < y && it.bottom > y }
    }

    private fun findKeysInEvents(event: MotionEvent): MutableList<KeyView> {
        val keysWithEvents = mutableListOf<KeyView>()
        for (i in 0 until event.pointerCount) {
            val keyView = findKey(event.getX(i), event.getY(i))
            if (keyView != null) keysWithEvents.add(keyView)
        }
        return keysWithEvents
    }
}

Para acoplar esta clase, debemos cambiar el KeyboardLayout para crearla, sobrescribir el método onTouchEvent y añadir las llamadas al KeyboardEventHandler para que actualice sus KeyView. Además, tenemos que modificar el KeyboardFragment para pasar la referencia del ViewModel al Layout y que éste pueda crear el EventHandler con ella. Ten en cuenta que las clases que se muestran a continuación no se muestran enteras para evitar alargar más el tutorial, por lo que hay que sustituir o añadir los métodos.

class KeyboardLayout(
   context: Context?,
   keyboardVM: KeyboardVM,
   private val keyboardEventHandler: KeyboardEventHandler = KeyboardEventHandler(keyboardVM),
   private val keyViews: MutableList<KeyView> = mutableListOf(),
   private val keyViewFactory: KeyViewFactory = KeyViewFactory(context)
) : LinearLayout(context) {

fun clearKeyViews() {
   keyViews.clear()
   this.removeAllViews()
   this.keyboardEventHandler.clearKeyViews()
}


fun addKeyboard(keyboard: Keyboard) {
   val views = keyViewFactory.createViews(keyboard.keys)

   keyViews.addAll(views)
keyboardEventHandler.setKeyViews(views)
   for (view in views.filter { it.key.isWhite() }) this.addView(view)
   for (view in views.filter { !it.key.isWhite() }) this.addView(view)

   if (keyViews.size > 0) sizeKeys()
}


override fun onTouchEvent(event: MotionEvent?): Boolean {
   if (event != null) keyboardEventHandler.handleEvent(event)
   return true
}
class KeyboardFragment : Fragment() {

   private lateinit var keyboardLayout: KeyboardLayout

   override fun onCreateView(
      inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
   ): View? {
      keyboardLayout = KeyboardLayout(context, KeyboardVM.create(this))
      return keyboardLayout
   }
}

En este punto, si probamos la aplicación podremos ver cómo responde a los eventos correspondientes, actualizándose el teclado.

7. Creación del Sound Manager

El último paso de este tutorial es la creación de las clases necesarias para el tratamiento de los sonidos. Para ello haremos uso de la clase SoundPool que utiliza archivos de sonidos de pequeño tamaño para reproducirlos. Por lo tanto, necesitaremos los doce sonidos del teclado para poder implementar esta parte.

7.1. Los archivos de sonido

Hay varios sitios en internet donde puedes descargarte ficheros de música de forma gratuita, por ejemplo en la página de la Universidad de Iowa. Si eliges esta opción, los ficheros están en formato aiff, por lo que tendrás que convertirlos a wav. Para ello existen herramientas como la proporcionada por esta página.

Cuando tengamos los ficheros, tenemos que agregarlos a nuestro proyecto. Para ello tenemos que crear un directorio de recursos dentro de res, tal y como se indica en las imágenes a continuación. Como nombre y como tipo de recurso selecciona raw y da a OK.

Esto creará una carpeta a la que tendrás que añadir los ficheros wav, por ejemplo utilizando el gestor de archivos de tu sistema. Después, es posible que tengas que actualizar los recursos del proyecto, para lo que tan sólo haz click derecho en res y sincronízala (puede que incluso sea necesario cerrar y volver a abrir el proyecto para que el SoundResManager que vamos a hacer a continuación reconozca el acceso a los archivos).

Respecto al SoundResManager que hemos mencionado a continuación, será la clase que controlará todos estos ficheros, devolviendo un mapa donde las claves son los pitch y el valor el id de los ficheros. A la hora de copiar el código, ten en cuenta que tus ficheros puede que tengan un nombre diferente al que está indicado y que de ser así tendrás que cambiarlo en el fichero o en el código.

class SoundResManager {
   companion object {
       fun getSoundFilesIds() = mapOf (
           0 to R.raw.piano_4c,
           1 to R.raw.piano_4db,
           2 to R.raw.piano_4d,
           3 to R.raw.piano_4eb,
           4 to R.raw.piano_4e,
           5 to R.raw.piano_4f,
           6 to R.raw.piano_4gb,
           7 to R.raw.piano_4g,
           8 to R.raw.piano_4ab,
           9 to R.raw.piano_4a,
           10 to R.raw.piano_4bb,
           11 to R.raw.piano_4b
       )
   }
}

7.2. Administrar los sonidos

Nuestra clase KeyboardSoundPool, contará con los doce sonidos indicados que cargaremos al inicializar la clase y será la encargada de reproducirlos o pararlos. Al cargarlos, se crea un id que guardaremos en una clase que llamaremos Sound junto con el pitch correspondiente. Además, cuando mandemos al SoundPool que reproduzca el sonido, éste devolverá otro id del stream que se crea, devolviendo un 0 si no se logra iniciar. Dicho valor también lo guardaremos en Sound para poder pararlo, junto con un método que comprobará si el sonido está sonando.

class Sound(val id: Int, val pitch: Int, var streamId: Int = 0) {
   fun isPlaying() = streamId != 0
}

Como SoundPool se debe crear utilizando un builder a partir de la API 21, pero nuestra versión mínima es anterior, guardaremos una instancia de esta como atributo del KeyboardSoundPool en vez de aplicar una herencia, inicializando el SoundPool dependiendo de la versión que se esté utilizando.

Cada vez que se actualice el piano en el layout, llamaremos a esta clase para que identifique qué sonidos hay que parar y cuáles iniciar, comparándolos con el array de Sound que tenemos.

class KeyboardSoundPool(context: Context?) {
   private val soundPool: SoundPool
   private val volume = 1.0f
   private val sounds: Array<Sound>

   init {
       val soundsList = mutableListOf<Sound>()
       val soundFilesIds = SoundResManager.getSoundFilesIds()
       soundPool = if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
            val audioAttributes = AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).setUsage(AudioAttributes.USAGE_MEDIA).build()
            SoundPool.Builder().setMaxStreams(soundFilesIds.size).setAudioAttributes(audioAttributes).build()
        } else {
            SoundPool(soundFilesIds.size, AudioManager.STREAM_MUSIC, 0)
        }

       for (fileID in soundFilesIds) {
           val soundId = soundPool.load(context, fileID.value, 1)
           soundsList.add(Sound(soundId, fileID.key))
       }
       sounds = soundsList.toTypedArray()
   }

   fun updateSounds(soundingPitches: Array<Int>) {
       val soundsToStop = sounds.filter { it.isPlaying() && !soundingPitches.contains(it.pitch) }
       val soundsToPlay = sounds.filter { !it.isPlaying() && soundingPitches.contains(it.pitch) }

       for (sound in soundsToStop) stopSound(sound)
       for (sound in soundsToPlay) startSound(sound)
   }

   private fun startSound(sound: Sound) {
       val streamId = soundPool.play(sound.id, volume, volume, 1, 0, 1f)
       sound.streamId = streamId

   }

   private fun stopSound(sound: Sound) {
       soundPool.stop(sound.streamId)
       sound.streamId = 0
   }
}

Por último, tan sólo nos queda modificar el KeyboardLayout para inicializar el KeyboardSoundPool y para llamar a updateSounds cuando es necesario:

class KeyboardLayout(
   context: Context?,
   keyboardVM: KeyboardVM,
   private val keyboardEventHandler: KeyboardEventHandler = KeyboardEventHandler(keyboardVM),
   private val keyViews: MutableList<KeyView> = mutableListOf(),
   private val keyViewFactory: KeyViewFactory = KeyViewFactory(context),
   private val keyboardSoundPool: KeyboardSoundPool = KeyboardSoundPool(context)
) : LinearLayout(context) {


fun addKeyboard(keyboard: Keyboard) {
   val views = keyViewFactory.createViews(keyboard.keys)

   keyViews.addAll(views)
   keyboardSoundPool.updateSounds(keyboard.keys.filter { it.pulsed }.map { it.pitch }.toTypedArray())
   keyboardEventHandler.setKeyViews(views)
   for (view in views.filter { it.key.isWhite() }) this.addView(view)
   for (view in views.filter { !it.key.isWhite() }) this.addView(view)

   if (keyViews.size > 0) sizeKeys()
}

Y, ahora sí, ya tenemos un piano para Android utilizando una arquitectura MVVM que reacciona a los eventos de pulsar y soltar las teclas, actualizando tanto su representación gráfica como los sonidos que se reproducen.

¡Muchas gracias por haber seguido este tutorial!

8. Referencias

 

2 COMENTARIOS

  1. Muchas gracias por el tutoríal, los que estamos empezando te lo agradecemos.
    ¿Seria posible que mostrases el árbol del directorio para ver como has archivado las distintas clases?
    ¿Existe alguna norma para dicha clasificación, a la hora de crear paquetes y nombrarlos?

  2. amigo si vas a subir un tutorial de capas en kotlin se mas especifico al crear las capas, no te entendi ni pio en tu tutorial, y eso que soy nuevo

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad