Aller au contenu

Jetpack Compose : convertir des vues Android classiques en composables réutilisables

18 janvier 2024

Skyler De Francesca

Vous développez une application native Android moderne avec Jetpack Compose, mais vous avez du mal à intégrer des vues, des extensions ou des fonctions reposant sur des vues Android classiques? Dans le présent article, nous vous montrons comment résoudre ce problème en convertissant vos vues existantes en fonctions composables réutilisables pour les intégrer facilement dans votre application Jetpack Compose.

Remarque : Nous utiliserons le composant Android « TextView » pour montrer comment intégrer une vue Android dans un projet basé sur Jetpack Compose. TextView est un composant basique, mais les méthodes et les techniques présentées ici s’appliquent également aux vues plus complexes.

Le défi

Imaginons que nous voulons utiliser l’élément d’interface TextView dans notre projet basé sur Compose. Nous allons utiliser le TextView à de nombreux endroits. Puisque ce n’est pas un composable, nous aimerions le convertir en composable pour l’utiliser tout au long de notre projet. Ainsi, nous pourrons utiliser une simple API de Compose au lieu d’avoir à travailler avec la bibliothèque des vues Android chaque fois que nous voulons utiliser la vue, et donc réduire les lourdeurs et les redondances dans le code. Il est temps de créer notre composable!

Créer le composable

La première étape consiste à comprendre comment convertir une vue Android classique en composable. Heureusement, c’est assez facile avec le composable « AndroidView » de l’API d’interopérabilité Android.

/**
 * Example 1
 */
@Composable
fun TextViewComposable() {
    AndroidView(
        factory = { context ->
            TextView(context)
        }
    )
}

L’extrait ci-dessus est un exemple très simple d’utilisation basique d’AndroidView. Le bloc « factory » attend qu’une vue (android.view.View) soit retournée. Ici, nous retournons simplement un TextView vide. Un TextView vide n’est pas très utile… Comment faire en sorte qu’il affiche du texte? Et pourquoi pas en couleur?

/**
 * Example 2
 */
@Composable
fun TextViewComposable(text: String, @ColorInt color: Int) {
    AndroidView(
        factory = { context ->
            val textView = TextView(context)
            textView.text = text
            textView.setTextColor(color)
            textView
        }
    )
}

Dans cet exemple, le TextView affichera le texte précisé dans les paramètres du composable, dans la couleur précisée. Nous conserverons une référence au TextView dans le bloc « factory » pour accéder aux attributs et aux méthodes du composant, ce qui nous permet de modifier la vue avant qu’elle soit retournée. Simple comme bonjour!

Un instant… Et si le texte ou la couleur était changé dans le composable parent? Dans l’exemple ci-dessus, à la création du composable, le TextView n’affichera que le texte et la couleur initialement configurés. Ainsi, si on change les valeurs dans le composable parent, la vue restera la même. Comment résoudre ce problème?

/**
 * Example 3
 */
@Composable
fun TextViewComposable(text: String, @ColorInt color: Int) {
    val currentText by rememberUpdatedState(newValue = text)
    val currentColor by rememberUpdatedState(newValue = color)

    AndroidView(
        factory = { context ->
            val textView = TextView(context)
            textView.textSize = 30f
            textView
        },
        update = { textView ->
            textView.text = currentText
            textView.setTextColor(currentColor)
        }
    )
}

Ici, nous avons opéré deux changements pour faire en sorte que le TextView soit mis à jour chaque fois que les paramètres (texte et couleur) sont modifiés :

  1. Nous avons introduit les nouvelles variables « currentText » et « currentColor » pour donner un état au composable. Nous utilisons la fonction « rememberUpdatedState() » pour mettre les deux nouvelles variables à jour lorsque leurs paramètres sont modifiés. Cette fonction repose aussi sur l’API « remember » de Compose, qui permet aux valeurs de nos deux variables de persister malgré les recompositions. En d’autres mots, elle donne un état au composable.
  2. Nous avons déplacé le code servant à initialiser et à mettre à jour les attributs du TextView dans la fonction de rappel « update » de manière à ce qu’il soit appelé à chaque recomposition. Désormais, quand les paramètres (texte et couleur) sont modifiés, le TextView sera également mis à jour. Dans le bloc « factory », nous pouvons laisser des éléments que nous ne voulons appeler qu’initialement pour qu’ils ne changent pas (ex : taille du texte).

Nous avons créé un composable simple qui nous permet d’intégrer TextView dans nos projets. Si nous avons besoin d’autres fonctionnalités de TextView, nous pourrons changer le composable en ce sens (ex : permettre la mise à jour de « textSize »).

Complexifier le composable

Comme vous pouvez l’imaginer, toutes les vues classiques ne sont pas aussi faciles à convertir en composables. Tout ce que nous avons fait, c’est permettre le paramétrage de valeurs dans le TextView et faire en sorte que ces valeurs soient mises à jour en même temps que les paramètres du composable correspondant. Et si la vue que vous essayez d’intégrer fonctionne avec des événements que vous voulez détecter et exposer? Des fonctions que vous voulez appeler? Il faudrait trouver comment détecter ces événements et appeler ces fonctions depuis le composable parent. Prétendons que les fonctionnalités ci-dessous sont intégrées au TextView, et que nous voulons pouvoir les gérer depuis un composable parent de « TextViewComposable ».

Abracadabra

Imaginons que le TextView est associé à un événement nommé « abracadabra », lequel change le texte du TextView en un mot aléatoire dès qu’un utilisateur clique dessus. Dans notre exemple, une fonction de rappel « TextView.abracadabraListener » est appelée à chaque instance de l’événement « abracadabra ». Cette fonction donne au texte sa nouvelle valeur.

Rotation

Nous avons aussi ajouté une fonctionnalité permettant de faire pivoter le texte à l’aide des fonctions suivantes :

  • TextView.rotateX(): Fait pivoter le texte horizontalement
  • TextView.rotateY(): Fait pivoter le texte verticalement
  • TextView.reset(): Replace le texte dans son sens initial

Comment faire en sorte que le TextViewComposable expose l’événement « abracadabra » du TextView? C'est simple et facile.

Tout d’abord, nous devons créer un paramètre dans « TextViewComposable » qui servira de fonction de rappel lorsque l’événement se produira. De cette manière, nous pouvons transmettre l’événement et la valeur au composable parent.

@Composable
fun TextViewComposable(
    ...
    onAbracadabra: (String) -> Unit = {}
) {

Ensuite, nous devons détecter l’événement « abracadabra » et appeler la fonction de rappel « onAbracadabra » dans le détecteur d’événement.

…
factory = { context ->
    val textView = TextView(context)
    textView.abracadabraListener = { newText -> onAbracadabra(newText.toString()) }
    textView
},
…

Déclencher la fonctionnalité de rotation n’est pas aussi simple. Nous pourrions, par exemple, créer une classe qui contient des fonctions représentant les contrôles que nous souhaitons exposer. Cette classe contiendra également une liste des détecteurs d’événement pouvant être ajoutés à l’une de ses instances ou en être retirés. En principe, les fonctions de « contrôle », soit rotateX(), rotateY() et reset(), appelleront la fonction associée aux détecteurs d’événement. Ainsi, nous pouvons initialiser le contrôleur dans le composable parent et appeler les fonctions de contrôle à partir de ce dernier. Le TextView utilisera cette instance du contrôleur en tant que paramètre et lui associera un détecteur d’événement pour transférer chaque appel de fonction du contrôleur vers l’appel de fonction associé du TextView.

La classe qui sert de contrôleur ressemblerait à ceci :

class RemoteTextViewController {
    interface RemoteTextViewControllerListener {
        fun onRotateX()
        fun onRotateY()
        fun onReset()
    }

    private val listeners: MutableMap<String, RemoteTextViewControllerListener> = mutableMapOf()

    fun rotateX() {
        listeners.values.forEach { it.onRotateX() }
    }

    fun rotateY() {
        listeners.values.forEach { it.onRotateY() }
    }

    fun reset() {
        listeners.values.forEach { it.onReset() }
    }

    fun addListener(key: String, value: RemoteTextViewControllerListener) {
        if (!listeners.containsKey(key)) {
            listeners[key] = value
        }
    }

    fun removeListener(key: String) {
        listeners.remove(key)
    }
}

Ensuite, dans le TextViewComposable, nous ajoutons le contrôleur en tant que paramètre de manière à ce que le composable parent puisse le fournir. Nous voulons aussi maintenir cet état à l’aide de la fonction « rememberUpdatedState() ».

@Composable
fun TextViewComposable(
    ...
    remoteController: RemoteTextViewController
) {
    val currentRemoteController by rememberUpdatedState(newValue = remoteController)
		
 ...
}

Enfin, nous allons créer un détecteur d’événement dans le bloc « factory » d’AndroidView et l’ajouter au contrôleur. Ce détecteur d’événement appellera les fonctions du TextView associé.

factory = { context ->
    val textView = TextView(context)
    currentRemoteController.addListener(
        "TextViewWrapper",
        object : RemoteTextViewController.RemoteTextViewControllerListener {
            override fun onRotateX() {
                textView.rotateX()
            }

            override fun onRotateY() {
                textView.rotateY()
            }

            override fun onReset() {
                textView.reset()
            }

        })
    textView
},

Le composable fini, avec les fonctionnalités « Abracadabra » et « Rotation » (ainsi que quelques nouveaux attributs adaptables), ressemble à ceci :

/**
 * Example 4
 */
@Composable
fun TextViewComposable(
    modifier: Modifier = Modifier,
    text: String,
    @ColorInt color: Int = Color.parseColor("#FF0000"),
    textSize: Float = 30F,
    textShadowRadius: Float = 0F,
    textShadowDx: Float = 0F,
    textShadowDy: Float = 0F,
    @ColorInt textShadowColor: Int = Color.parseColor("#0000FF"),
    remoteController: RemoteTextViewController,
    onAbracadabra: (String) -> Unit = {}
) {
    val currentText by rememberUpdatedState(newValue = text)
    val currentColor by rememberUpdatedState(newValue = color)
    val currentTextSize by rememberUpdatedState(newValue = textSize)
    val currentTextShadowRadius by rememberUpdatedState(newValue = textShadowRadius)
    val currentTextShadowDx by rememberUpdatedState(newValue = textShadowDx)
    val currentTextShadowDy by rememberUpdatedState(newValue = textShadowDy)
    val currentTextShadowColor by rememberUpdatedState(newValue = textShadowColor)
    val currentRemoteController by rememberUpdatedState(newValue = remoteController)

    AndroidView(
        modifier = modifier,
        factory = { context ->
            val textView = TextView(context)
            textView.abracadabraListener = { newText -> onAbracadabra(newText.toString()) }
            currentRemoteController.addListener(
                "TextViewWrapper",
                object : RemoteTextViewController.RemoteTextViewControllerListener {
                    override fun onRotateX() {
                        textView.rotateX()
                    }

                    override fun onRotateY() {
                        textView.rotateY()
                    }

                    override fun onReset() {
                        textView.reset()
                    }
                })
            textView
        },
        update = { textView ->
            textView.text = currentText
            textView.textSize = currentTextSize
            textView.setShadowLayer(
                currentTextShadowRadius,
                currentTextShadowDx,
                currentTextShadowDy,
                currentTextShadowColor
            )
            textView.setTextColor(currentColor)
        }
    )
}

Nous avons maintenant un bon composable pour exercer un contrôle complet sur le TextView! Toutefois, nous pouvons encore améliorer le code.

Simplifier la mise à jour et les tests du composable

Au fur et à mesure que nous ajoutons des fonctionnalités à notre composable, l’approche décrite ci-dessus risque de ne pas fonctionner. Il en va de même si nous essayons de convertir une vue plus complexe. Notre composable pourrait finir par faire des centaines de lignes, ce qui compliquerait sa mise à jour et ses tests.

Ce que nous pouvons faire, c’est déléguer la mise à jour du TextView à une autre classe, et appeler les fonctions de cette classe depuis le composable lorsqu’on souhaite mettre la vue à jour. De cette manière, nous pouvons fragmenter la fonctionnalité qui se trouvait dans le corps du composable en plusieurs fonctions plus petites, plus faciles à mettre à jour et à tester.

Voici un exemple de classe qui fonctionnerait :

class TextViewUpdater(
    val textView: TextView,
    remoteTextViewController: RemoteTextViewController? = null,
    var abracadabraListener: (String) -> Unit = {}
) {

    init {
        textView.abracadabraListener = { abracadabraListener(it.toString()) }
        remoteTextViewController?.addListener("TextViewController", object :
            RemoteTextViewController.RemoteTextViewControllerListener {
            override fun onRotateX() {
                rotateX()
            }

            override fun onRotateY() {
                rotateY()
            }

            override fun onReset() {
                reset()
            }
        })
    }

    fun update(
        text: String,
        textSize: Float,
        color: Int,
        textShadowRadius: Float,
        textShadowDx: Float,
        textShadowDy: Float,
        textShadowColor: Int
    ) {
        textView.text = text
        textView.textSize = textSize
        textView.setShadowLayer(
            textShadowRadius,
            textShadowDx,
            textShadowDy,
            textShadowColor
        )
        textView.setTextColor(color)
    }

    private fun rotateX() {
        textView.rotateX()
    }

    private fun rotateY() {
        textView.rotateY()
    }

    private fun reset() {
        textView.reset()
    }
}

La classe décrite ci-dessus est assez simple, mais elle nous permet de séparer les fonctionnalités permettant de contrôler le TextView en plusieurs fonctions. Si vous travaillez avec une vue dont les opérations font appel à une logique plus complexe, cette modularité facilite les tests.

La classe ci-dessus peut-être utilisée dans le TextViewComposable :

/**
 * Example 5
 */
@Composable
fun TextViewComposable(
    modifier: Modifier = Modifier,
    text: String,
    @ColorInt color: Int = Color.parseColor("#FF0000"),
    textSize: Float = 30F,
    textShadowRadius: Float = 0F,
    textShadowDx: Float = 0F,
    textShadowDy: Float = 0F,
    @ColorInt textShadowColor: Int = Color.parseColor("#0000FF"),
    remoteController: RemoteTextViewController,
    onAbracadabra: (String) -> Unit = {}
) {
    val context = LocalContext.current

    val textViewUpdater by remember {
        mutableStateOf(TextViewUpdater(TextView(context), remoteController, onAbracadabra))
    }

    val currentText by rememberUpdatedState(newValue = text)
    val currentColor by rememberUpdatedState(newValue = color)
    val currentTextSize by rememberUpdatedState(newValue = textSize)
    val currentTextShadowRadius by rememberUpdatedState(newValue = textShadowRadius)
    val currentTextShadowDx by rememberUpdatedState(newValue = textShadowDx)
    val currentTextShadowDy by rememberUpdatedState(newValue = textShadowDy)
    val currentTextShadowColor by rememberUpdatedState(newValue = textShadowColor)

    AndroidView(
        modifier = modifier,
        factory = {
            textViewUpdater.textView
        },
        update = {
            textViewUpdater.update(
                currentText,
                currentTextSize,
                currentColor,
                currentTextShadowRadius,
                currentTextShadowDx,
                currentTextShadowDy,
                currentTextShadowColor
            )
        }
    )
}

Puisque nous avons délégué la mise à jour du TextView à la classe TextViewUpdater, le composable n’appellera que les fonctions à l’intérieur de cette classe. Vous remarquerez que nous avons diminué la longueur et la complexité du composable, le rendant ainsi plus facile à mettre à jour et à lire.

Conclusion

Nous avons vu comment convertir des vues Android classiques en composables réutilisables à l’aide de Jetpack Compose. Il y a peut-être d’autres moyens d’arriver au même résultat, mais nous espérons que le présent article vous aidera à démarrer. Si vous voulez tester vous-même la méthode décrite ici, vous trouverez un projet Android complet incluant le code utilisé en exemple ici sur GitHub (https://github.com/skyler-de/AndroidViewsInComposeExample).

VOUS AVEZ UN PROJET ?