Ici on va utiliser le système de vue "classique" et afficher une liste d'éléments modifiavle
Vous allez créer un unique projet que vous mettrez à jour au fur à mesure des TPs:
Empty VIEWS Activity (⚠️ pas Empty Activity SVP ⚠️)TodoNicolasAlexandre (⚠️ pas TP1 SVP ⚠️)com.something.todo (ce sera la racine de tous vos packages et sert d'identifiant unique d'application)Créez une icône d'application personnalisée avec l'outil intégré Image Asset Studio: ouvrez le Resource Manager à gauche, près du volet projet puis cliquez sur le + en haut à gauche et choisissez Image Asset: ici vous pouvez choisie une couleur de fond, une image: icône système ou personnalisée avec un SVG ou un "clipart" (bibliotheque d'icones en cliquant sur la petite icon android) et générer automatiquement les différentes tailles nécessaires pour les différentes version d'Android.
Vérifiez que l'icône est bien prise en compte dans le AndroidManifest.xml (attribut android:icon de la balise application) et en lançant l'app.
📁 Les fichiers source Java ou Kotlin sont rangés en "packages":
package com.nicoalex.todo.nomdupackagecom/nicoalex/todo/nomdupackageParcourez les différents fichiers de config, notamment les plus importants:
app/build.gradle.kts: contient la configuration de module principal (app), notamment les versions compatibles, son propre numéro de version, etc et surtout les différentes dépendances../build.gradle.kts: contient moins de choses, en général des plugins, mais concerne tout le projetlibs.versions.toml: un catalogue de dépendances, de plugins et de versions, qui est utilisé par les fichiers précédents. Vérifiez que vous utilisez les dernières versions disponible, surtout pour kotlin.app/src/main/AndroidManifest.xml: contient les info de packaging de l'app comme les activités existantes, le nom de l'app, l'icône, etc.Créez un nouveau package list à l'intérieur votre package source de base (pas à côté !) :
clic droit sur 'com.nicoalex.todo' > new > package > tapez 'list'
Vous y mettrez tous les fichiers source (Kotlin) concernant la liste de tâches
TaskListFragment.kt qui contiendra la classe TaskListFragment:fragment_task_list.xml dans res/layoutTaskListFragment, overrider la méthode onCreateView(...): commencez à taper onCrea... et utilisez l'auto-completion de l'IDE pour vous aider (vous pouvez supprimer la ligne super.onCreateView(...))onViewCreated(...) aussi, faites le maintenant de la même façon, vous devriez avoir quelque chose comme ça:class TaskListFragment : Fragment() {
override fun onCreateView(...): View {
// ici on crée la vue et on la retourne (regardez le type de retour: `View`), on ne fait rien d'autre.
}
override fun onViewCreated(...) {
// ici la vue est créée, on peut récupérer des références aux vues et les manipuler
}
}
rootView à afficher: créez la à l'aide de votre nouveau layout comme ceci:val rootView = inflater.inflate(R.layout.fragment_task_list, container, false)
String locale, ajoutez la en tant que propriété de votre classe TaskListFragment:private val taskList = listOf("Task 1", "Task 2", "Task 3")
Cette activity va servir de conteneur de fragments:
Dans activity_main.xml, remplacez la balise TextView par celle ci et adaptez:
<androidx.fragment.app.FragmentContainerView
android:name="com.nicoalex.todo.list.TaskListFragment"
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
TaskListAdapter.kt, créez 2 nouvelles classes: TaskListAdapter et TaskViewHolder:// l'IDE va râler ici car on a pas encore implémenté les méthodes nécessaires
class TaskListAdapter : RecyclerView.Adapter<TaskListAdapter.TaskViewHolder>() {
var currentList: List<String> = emptyList()
// on utilise `inner` ici afin d'avoir accès aux propriétés de l'adapter directement
inner class TaskViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(taskTitle: String) {
// on affichera les données ici
}
}
}
TaskListFragment, créez une instance de votre nouvelle classe TaskListAdapter en propriété de votre fragment (comme taskList):private val adapter = TaskListAdapter()
onCreateView:adapter.currentList = taskList
TaskListFragment, placez une balise RecyclerView (vous pouvez taper < Recyc... et vous aider de l'auto-complétion ou bien utilisez le mode visuel)layoutManager qui lui dit de s'afficher comme une liste (verticale par défaut):app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
id: soit en mode visuel soit en mode code, en vous aidant de l'auto-complétion android:id="@+id/id_de_votre_recycler_viewTaskListFragment, overridez onViewCreated pour y récupérez une référence à la RecyclerView du layout en utilisant findViewById:val recyclerView = view.findViewById<RecyclerView>(R.id.id_de_votre_recycler_view)
recyclerView a une propriété adapter qui doit être connectée à l'adapter que vous avez créé (null par défaut)item_task.xml qui servira à afficher chaque cellule de la liste avec comme racine un LinearLayout contenant pour l'instant une seule TextView en enfant:<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/task_title"
android:background="@android:color/holo_blue_bright"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
Dans TaskListAdapter, implémenter toutes les méthodes requises:
Astuce: Pré-remplissez votre adapter en cliquant sur le nom de votre classe (qui doit être pour l'instant soulignée en rouge) et cliquez sur l'ampoule jaune ou tapez Alt + ENTER (sinon, CTRL/CMD + o n'importe où dans la classe)
getItemCount doit renvoyer la taille de la liste de tâche à afficheronCreateViewHolder doit retourner un nouveau TaskViewHolder en générant un itemView, à partir du layout item_task.xml:val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_task, parent, false)
onBindViewHolder doit insérer la donnée dans la cellule (TaskViewHolder) en fonction de sa position dans la liste en utilisant la méthode bind() que vous avez créée dans TaskViewHolder (elle ne fait rien pour l'instant)bind() qui doit récupérer une référence à la TextView dans item_task.xml et y insérer le texte récupéré en argument (pour être plus propre, déplacez cette référence en tant que propriété de votre TaskViewHolder)data class Task avec 3 attributs: un id, un titre et une descriptionTaskListFragment, remplacer la liste taskList parprivate val taskList = listOf(
Task(id = "id_1", title = "Task 1", description = "description 1"),
Task(id = "id_2", title = "Task 2"),
Task(id = "id_3", title = "Task 3")
)
data class à la place de simples StringTextView)fragment_task_list.xml en ConstraintLayout (si ce n'est pas déjà fait) en faisant un clic droit dessus en mode designResource Manager à gauche, cliquez sur le + en haut à gauche puis Vector Asset puis double cliquez sur l'image du logo android et trouvez une icône + (en tapant add) puis finish pour ajouter une icône à vos resourceFloating Action Button (FAB) en bas à droite de ce layout et utilisez l'icône crééeRetournez dans le code, récupérez une référence à votre nouveau bouton et utilisez .setOnClickListener {} pour ajouter une tâche à votre liste à chaque fois qu'on clique dessus:
// Instanciation d'un objet task avec des données préremplies:
val newTask = Task(id = UUID.randomUUID().toString(), title = "Task ${taskList.size + 1}")
taskList = taskList + newTask
Cette façon de "notifier" manuellement n'est pas idéale, il existe en fait une sous-classe de RecyclerView.Adapter qui permet de gérer cela automatiquement: ListAdapter
Améliorer l'implémentation de TasksListAdapter en héritant de ListAdapter au lieu de RecyclerView.Adapter
Il faudra notamment: créer un DiffUtil.ItemCallback et le passer au constructeur parent, supprimer getItemCount et la propriété currentList car ils sont déjà définis dans ListAdapter
Exemple:
object MyItemsDiffCallback : DiffUtil.ItemCallback<MyItem>() {
override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem) : Boolean {
return // comparaison: est-ce la même "entité" ? => même id?
}
override fun areContentsTheSame(oldItem: MyItem, newItem: MyItem) : Boolean {
return // comparaison: est-ce le même "contenu" ? => mêmes valeurs? (avec data class: simple égalité)
}
}
class ItemListAdapter : ListAdapter<Item, ItemListAdapter.ItemViewHolder>(ItemsDiffCallback) {
override fun onCreateViewHolder(...)
override fun onBindViewHolder(...)
}
// Usage is simpler:
val adapter = ItemListAdapter()
recyclerView.adapter = adapter
adapter.submitList(listOf("Item#1", "Item #2"))
private val taskList = List(100) { index ->
Task(id = "id_$index", title = "Task $index")
}
RecyclerView scrolle automatiquement en bas à chaque ajout de tâche: recyclerView.smoothScrollToPosition(...)Utiliser le ViewBinding (documentation / slides) dans TaskListFragment:
inflate pour récupérer une instance de type XxxBindingfindViewByIds par des calls direct du genre binding.myViewIdPuis faites pareil pour les ViewHolder: c'est un peu plus complexe, il faudra changer le constructeur pour qu'il prenne un val binding: ItemTaskBinding afin d'y avoir accès dans le corps de la classe et passer binding.root au constructeur parent.
Dans le layout de vos item, ajouter un ImageButton qui servira à supprimer la tâche associée. Vous pouvez utiliser par exemple l'icône @android:drawable/ic_menu_delete
Aidez vous des lignes de code plus bas pour réaliser un "Click Listener" à l'aide d'une lambda en suivant ces étapes:
onClickDelete de type lambda qui prends en arguments une Task et ne renvoie rien: (Task) -> Unit et l'initier à {} (elle ne fait rien par défaut)onClickListener du bouton supprimeronClickDelete depuis l'adapter et implémentez là: donnez lui comme valeur une lambda qui va supprimer la tache passée en argument de la liste{}):var onClickDelete: (Task) -> Unit = {}
onClickDelete(task)
myAdapter.onClickDelete = { task ->
// Supprimer la tâche
}
DetailFragment en utilisant un ConstraintLayout (vous pouvez "convert" dans le menu du clic droit sur la root view) avec deux EditText (pour le titre et la description) et un Button de validationDetailFragment, récupérez les références aux vues et implémentez le clicapp/build.gradle.kts > dependencies {...}, ajoutez les dépendances qui vous manquent (mettre les versions plus récentes si l'IDE vous le propose, il vous proposera également de facilement les passer dans le fichier centralisé libs.versions.toml):implementation("androidx.fragment:fragment:1.8.9")
implementation("androidx.fragment:fragment-ktx:1.8.9")
parentFragmentManager.commit {
replace<DetailFragment>(R.id.fragment_container)
addToBackStack(null)
}
DetailFragment, créez un FragmentResultLauncher dans TaskListFragment:class DetailFragment : Fragment() {
override fun onViewCreated(...) {
// ...
parentFragmentManager.setFragmentResultListener(REQUEST_KEY) { _, bundle ->
val result = bundle.getString(RESULT_KEY)
// Utilisez le résultat ici
}
}
companion object { // pour définir des membres "statiques", ici des constantes:
const val REQUEST_KEY = "request_key"
const val RESULT_KEY = "result_key"
}
val newTask = Task(id = UUID.randomUUID().toString(), title = "New Task !")
parentFragmentManager.setFragmentResult(BlankFragment.REQUEST_KEY, Bundle().apply {
putString(BlankFragment.RESULT_KEY, newTask)
})
parentFragmentManager.popBackStack() // retour au fragment précédent
Task ne fait pas partie des types de base autorisés dans un bundle !Serializable: Faites donc hériter Task de java.io.Serializable, comme c'est une data class, il n'y a rien à implémenter !val task = result.data?.getSerializableExtra("task") as Task?
DetailFragment pour récupérer les valeurs entrées par l'utilisateur dans les EditText et les utiliser pour créer la nouvelle tâcheAjoutez un argument taskId de type String?, null par défaut (pour garder le cas d'ajout de nouvelle tâche) à DetailFragment pour identifier la tâche à éditer, vous pourrez ensuite faire:
parentFragmentManager.commit {
replace(R.id.fragmentContainerView, DetailFragment(taskId))
addToBackStack(null)
}
Inspirez vous de ce que vous avez fait pour le bouton "supprimer" et le bouton "ajouter" pour créer un bouton "éditer" permettant de modifier chaque tâche en ouvrant l'activité DetailFragment pré-remplie avec les informations de la tâche en question.
Pour l'instant on a utilisé des lambda mais une façon plus classique de gérer les clicks d'un item est de définir une interface que l'on implémentera dans le 1er Fragment, mettez à jour votre code pour utiliser cette méthode:
interface TaskListListener {
fun onClickDelete(task: Task)
fun onClickEdit(task: Task)
}
class TaskListAdapter(val listener: TaskListListener) : ... {
// use: listener.onClickDelete(task)
}
class TaskListFragment : Fragment {
val adapterListener : TaskListListener = object : TaskListListener {
override fun onClickDelete(task: Task) {...}
override fun onClickEdit(task: Task) {...}
}
val adapter = TaskListAdapter(adapterListener)
}
AndroidManifest.xml, ajouter la possibilité de partager du texte depuis les autres applications (par ex en surlignant un texte dans un navigateur puis en cliquant sur "partager") et ouvrir le formulaire de création de tâche avec une description pré-remplie (Documentation)Intent implicite, ajouter la possibilité de partager du texte vers les autres applications (avec un OnLongClickListener sur les tâches par ex ou bien avec un bouton dans la vue formulaire) (Documentation)Que se passe-t-il pour votre liste si vous tournez votre téléphone pour passer en mode paysage ? 🤔
onSaveInstanceStateputSerializable (un peu comme précédemment avec putExtra) pour sauvegarder la listegetSerializable dans onCreateView ou onViewCreated, sur le paramètre savedInstanceState