Découverte de JetPack Compose

Bannière Jetpack Compose

Description

Jetpack Compose est un framework d’interface utilisateur développé par Google, sorti en version 1.0 en août 2021. Comme SwiftUI pour iOS, il permet de faciliter et d’accélérer la création d’interfaces graphiques pour les applications Android. C’est une approche innovante et différente de ce que proposait Android en termes de développement d’UI via les XML Views.

Avantages

Un des principaux avantages de Compose est l’approche déclarative des éléments. Grâce à la gestion de la fonction remember. Lorsqu’un élément change d’état, l’interface se met à jour automatiquement.
Par exemple, lorsque nous déclarons une variable var name by remember { mutableStateOf("Jean") }, la valeur initiale de la variable est Jean, mais lorsque le nom changera, l’interface affichera automatiquement la nouvelle valeur associée.
Les composants sont appelés Composable et peuvent être imbriqués entre eux afin de créer des composants modulables, complexes et réutilisables. Cela permet ainsi de créer un code plus structuré et plus compréhensible pour les développeurs.
De plus, Compose est plus performant grâce à sa capacité à « recomposer » les bons composants au bon moment. C’est-à-dire qu’il est capable de mettre à jour les composants nécessitant un changement d’état sans avoir à tout recréer.
Voulant optimiser et faciliter au maximum le développement d’interfaces utilisateur, Google intègre des Composable favorisant les standards de l’écosystème Android, tels que Scaffold, qui permet de gérer une vue avec une TopBar, une BottomBar, un FloatingActionButton, tout en gérant automatiquement les bonnes marges pour le contenu.

Démonstration

Prenons l’exemple d’une interface affichant une liste de prénoms pouvant être alimentée par un champ de texte.

XML

activity_main.xml

Ce fichier va définir l’interface principale de l’application. Comprenant un EditText pour la saisie d’un nouveau nom, une ImageView pour ajouter l’élément à la liste et une RecyclerView pour afficher celle-ci. Comme la liste peut contenir un nombre indéterminé d’éléments, ce composant est plus adapté et optimisé.

<androidx.constraintlayout.widget.ConstraintLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:id="@+id/main"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">


   <EditText
       android:id="@+id/et_name"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_margin="16dp"
       android:hint="Nouveau nom"
       android:inputType="text"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toStartOf="@+id/but_add"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       app:layout_constraintVertical_bias="0.0"
       tools:ignore="Autofill">


   <ImageView
       android:id="@+id/but_add"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_below="@id/et_name"
       android:layout_marginEnd="16dp"
       android:src="@android:drawable/ic_menu_add"
       app:layout_constraintBottom_toBottomOf="@+id/et_name"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="1.0"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="@+id/et_name"
       app:tint="#2196F3">


   <androidx.recyclerview.widget.RecyclerView
       android:id="@+id/rv_names"
       android:layout_width="match_parent"
       android:layout_height="0dp"
       android:layout_below="@id/but_add"
       android:layout_marginTop="16dp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/et_name">


</androidx.constraintlayout.widget.ConstraintLayout>

row_item.xml

Représentant un élément de la liste, ce fichier comprendra un TextView pour afficher le nom, ainsi qu’une ImageView pour supprimer l’entrée si nécessaire.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:orientation="vertical">


   <LinearLayout
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:orientation="horizontal"
       android:padding="8dp">


       <TextView
           android:id="@+id/tv_name"
           android:layout_width="0dp"
           android:layout_height="wrap_content"
           android:layout_weight="1"
           android:textSize="16sp">


       <ImageView
           android:id="@+id/but_delete"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           app:srcCompat="@android:drawable/ic_delete">


   <LinearLayout>


   <com.google.android.material.divider.MaterialDivider
       android:layout_width="match_parent"
       android:layout_height="1dp">


<LinearLayout>

ListAdapter.kt

Maintenant que les fichiers XML ont été créés, nous devons les associer à leurs fichiers Kotlin. ListAdapter.kt va, comme son nom l’indique, associér l’élément de la liste avec le composant XML

class ListAdapter(
   private val names: MutableList,
   private val onDeleteClick: (Int) -> Unit
) : RecyclerView.Adapter() {


   inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
       val tvName: TextView = view.findViewById(R.id.tv_name)
       val butDelete: ImageView = view.findViewById(R.id.but_delete)


       init {
           butDelete.setOnClickListener {
               onDeleteClick(adapterPosition)
           }
       }
   }


   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
       val view = LayoutInflater.from(parent.context).inflate(R.layout.row_item, parent, false)
       return ViewHolder(view)
   }


   override fun onBindViewHolder(holder: ViewHolder, position: Int) {
       holder.tvName.text = names[position]
   }


   override fun getItemCount(): Int {
       return names.size
   }
}

MainActivity.kt

Maintenant que tout est en place, nous pouvons créer le fichier principal qui gérera toute la logique de la fonctionnalité. Ce fichier associera les différents composants aux variables ainsi que ListAdapter à la RecyclerView. De plus, c’est ici que nous pourrons gérer les actions des utilisateurs, telles que l’ajout et la suppression d’un prénom avec la mise à jour de l’interface.

class MainActivity : AppCompatActivity() {


   private lateinit var etName: EditText
   private lateinit var butAdd: ImageView
   private lateinit var rvNames: RecyclerView
   private lateinit var names: MutableList
   private lateinit var adapter: ListAdapter


   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       etName = findViewById(R.id.et_name)
       butAdd = findViewById(R.id.but_add)
       rvNames = findViewById(R.id.rv_names)
       names = mutableListOf("Pierre", "Paul", "Jacques")
       adapter = ListAdapter(names) { position ->
           names.removeAt(position)
           adapter.notifyItemRemoved(position)
       }
       rvNames.setLayoutManager(LinearLayoutManager(this))
       rvNames.setAdapter(adapter)
       butAdd.setOnClickListener(View.OnClickListener {
           val name = etName.getText().toString()
           if (name.isNotEmpty()) {
               names.add(name)
               adapter.notifyDataSetChanged()
               etName.setText("")
           }
       })
   }
}

Comme nous pouvons le constater dans la MainActivity.kt, l’adaptateur a besoin d’être notifié d’un changement; à la fois lorsqu’on ajoute un élément adapter.notifyDataSetChanged() et lorsqu’on supprime un élément adapter.notifyItemRemoved(position).

Compose

Avec Compose, nous aurons besoin d’un seul fichier. Nous pouvons bien évidemment séparer les composants en plusieurs fichiers pour plus de lisibilité ou d’ergonomie.
La fonction remember nous permet de suivre les changements de valeurs, tant sur la liste des noms que sur la saisie de texte.
Le composable LazyColumn est l’équivalent d’une RecyclerView. Il permet d’optimiser l’affichage d’une liste de taille indéterminée.
Enfin, le composable FirstNameRow représente une entrée dans la liste. Il n’y a pas ici besoin d’adaptateur, il suffit de déclarer le composant tel quel et de l’intégrer dans la boucle de la LazyColumn.

MainActivity.kt

class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           MainView()
       }
   }
}


@Composable
fun MainView() {
   var names by remember { mutableStateOf(listOf("Pierre", "Paul", "Jacques")) }
   var entryName by remember { mutableStateOf("") }


   Column(
       modifier = Modifier.padding(16.dp),
       verticalArrangement = Arrangement.spacedBy(16.dp)
   ) {
       TextField(
           value = entryName,
           onValueChange = { entryName = it },
           label = {"Nouveau prénom"},
           modifier = Modifier.fillMaxWidth(),
           trailingIcon = {
               Icon(
                   imageVector = Icons.Default.AddCircle,
                   contentDescription = "Ajouter prénom",
                   modifier = Modifier.clickable {
                       if(entryName.isNotEmpty()) {
                           names += entryName
                           entryName = ""
                       }
                   },
                   tint = Color.Blue
               )
           }
       )


       LazyColumn(
           verticalArrangement = Arrangement.spacedBy(5.dp)
       ) {
           items(names.count()) { index ->
               FirstNameRow(firstName = names[index]) {
                   names -= names[index]
               }
           }
       }
   }
}


@Composable
fun FirstNameRow(
   firstName: String,
   onDelete: () -> Unit
) {
   Column(
       Modifier.fillMaxWidth()
   ) {
       Row(
           Modifier.fillMaxWidth(),
           verticalAlignment = Alignment.CenterVertically
       ) {
           Text(text = firstName)
          
           Spacer(modifier = Modifier.weight(1f))


           Icon(
               imageVector = Icons.Default.Close,
               contentDescription = "Supprimer prénom",
               modifier = Modifier.clickable {
                   onDelete()
               },
               tint = Color.Red
           )
       }


       Divider()
   }
}

@Composable
fun MainActivityPreview() {
   MainView()
}

Comme nous pouvons le constater ci-dessus, aucun adaptateur n’a été initié, et aucune notification envers le LazyColumn n’a besoin d’être faite. Tout se fait distinctement, simplement et rapidement.
Nous pouvons également noter qu’il existe une fonction de prévisualisation qui permet d’afficher la vue en temps réel. Nous avons aussi la possibilité d’interagir directement avec les différents composants sans qu’aucun émulateur ou appareil physique ne soit nécessaire. Cela permet d’effectuer des tests d’UI et fonctionnels beaucoup plus rapidement.

Conclusion

Tout comme SwiftUI côté iOS, Jetpack Compose modernise le développement d’applications Android en offrant une approche plus simple, plus puissante et plus concise pour la création d’interfaces utilisateur. Les développeurs bénéficient d’une productivité accrue et d’une meilleure expérience de développement. Pour ma part, je trouve cette approche de développement bien plus intéressante et innovante que le modèle XML traditionnel. De plus, lorsqu’on développe à la fois sur Android avec Kotlin/Compose et sur iOS avec Swift/SwiftUI, nous remarquons des similitudes qui facilitent et accélèrent considérablement le développement sur les deux plateformes concurrentes.