Chapitre 3 : personnalisation des thèmes

Dans les articles précédents (intro, chapitre 1, chapitre 2), nous avons répondu aux problématiques liées aux dépendances et à l’outillage de développement. Nous allons maintenant nous concentrer sur le contenu de la librairie, ses composants et sa configuration.

La librairie doit être personnalisable sur plusieurs aspects, notamment :

  • le thème graphique
  • l’internationalisation : les locales supportées ainsi que la langue courante
  • la gestion des appels serveurs et l’authentification
  • le paramétrage de certains composants exposés par la librairie
  • le paramétrage propre à notre métier


Comme évoqué précédemment, toutes nos applications partagent la même stack technologique. Côté code, on retrouve principalement :

  • react-native-paper, une librairie de composants React Native qui implémente le design Material de Google et qui porte le thème
  • i18n-js pour l’internationalisation
  • axios pour les appels serveur


Un provider pour notre librairie


Notre librairie doit être capable de prendre un certain nombre de paramètres en entrée. Ces paramètres doivent être accessibles depuis n’importe quel composant de la librairie, voir même depuis l’application.

Un contexte React est la solution parfaite pour ce besoin. Pour utiliser un contexte, il faut déclarer un Provider. Tous les enfants de ce Provider ont ainsi accès à la valeur du contexte.

Nous avons repris cette logique pour notre librairie. La librairie expose donc un composant “Provider“, qui a été volontairement simplifié pour notre exemple :

Composant Provider de la lib
import { FC } from 'react'
import { ConfigContextProvider } from '../contexts/ConfigContext'

interface Props {
locale?: string
mode?: 'TEST' | 'PRODUCTION'
}

export const Provider: FC = ({
locale = 'fr-FR',
mode = 'PRODUCTION',
children,
}) => (
{children}
)


Ce composant prend tous les paramètres de configuration de la librairie et les stock dans un contexte interne à la librairie.

En situation réelle, notre Provider prend plus de props et déclare plusieurs contextes, mais vous avez compris l’idée 😉

Les applications doivent obligatoirement déclarer ce Provider pour utiliser la librairie. Voici un exemple simplifié du composant App de notre application :

Déclaration du Provider dans l'app

import { Provider as SharedComponentsProvider } from '@lyra/shared-components'
import { FC } from 'react'
import { AppContext, AppContextProvider } from './src/contexts/AppContext'
import { Root } from './src/Root'

export const App: FC = () => (
{({ locale, mode }) => ( )}
)


L’utilisation des contextes nous permet de rester dans le cycle de vie React et d’être totalement synchronisé, de manière à ce que si par exemple, l’utilisateur change la locale dans sa page de paramètres de l’application, la valeur sera mise automatiquement à jour dans la librairie.


Gestion du thème


Dans nos applications, le thème est porté par React Native Paper (RNP). RNP est une librairie de composants React Native qui implémente le design Material de Google.

Sur les screenshots ci-dessous, on remarque que les composants se ressemblent mais que la palette de couleur est différente pour chaque application. Ces différences sont justement configurées dans le thème.

La librairie va donc utiliser des composants de RNP, en prenant en compte le thème de l’application cible.

Nos besoins sont les suivants :

  • Tous les composants de la librairie doivent utiliser le thème de l’application cible
  • Le format du thème sera basé sur celui de RNP mais contiendra quelques champs supplémentaires, il faut que le typage du thème fonctionne correctement dans la lib et dans les apps
  • La librairie doit proposer un thème par défaut
  • Les applications doivent pouvoir passer un thème personnalisé
  • Les applications doivent pouvoir accéder au thème avec un hook ou un High-Order Component (HOC)


Mais plusieurs problématiques apparaissent :

  • Le thème est passé à RNP via un Provider, mais qui doit déclarer ce Provider : l’application ou la librairie ?
  • Le format de nos thèmes hérite de celui de RNP mais rajoute quelques champs custom, comment faire pour que les apps et la lib aient accès au typage de notre thème ?
  • Comment faire pour pouvoir accéder au thème au moment de déclarer nos styles (depuis l’app ou la lib) ?

Qui doit déclarer le Provider RNP ?


Celui qui déclare le Provider RNP devra aussi être responsable d’augmenter le type du thème RNP avec les champs qu’il veut ajouter, comme ceci par exemple :

react-native-paper.ts
declare global {
namespace ReactNativePaper {
interface ThemeColors {
highlighted: string
}
}
}


Avec cette augmentation de module, il est maintenant possible d’accéder à theme.colors.highlighted (qui n’existe pas dans le thème RNP) sans erreur TypeScript.

Si ce sont les apps qui déclarent le Provider RNP, plusieurs problèmes apparaissent :

  • La lib pourra utiliser des composants RNP mais n’aura pas accès au thème en dehors du contexte React (avec useTheme). Les feuilles de style en React Native se déclarent en dehors des composants, donc ça pose problème.
  • La lib ne pourra pas augmenter le type du thème et ne bénéficiera pas de l’augmentation du type par l’app.
  • La lib ne pourra pas proposer de thème par défaut.


C’est pour ces raisons que nous avons choisi de déléguer la responsabilité de déclarer le Provider RNP à notre librairie.

C’est la solution la plus logique, la librairie peut ainsi proposer un thème par défaut, avec son typage. Les apps restent libres d’étendre ce thème (en créant un thème custom) comme bon leur semble.

La librairie déclare donc le Provider RNP au niveau de son propre Provider :

Composant Provider de la lib
import { FC } from 'react'
import { Provider as PaperProvider } from 'react-native-paper'
import { ConfigContextProvider } from '../contexts/ConfigContext'
import { LyraTheme } from '../styles/LyraTheme'

interface Props {
locale?: string
mode?: 'TEST' | 'PRODUCTION'
theme?: ReactNativePaper.Theme
}

export const Provider: FC = ({
locale = 'fr-FR',
mode = 'PRODUCTION',
theme = LyraTheme,
children,
}) => (
{children}
)


À noter que la dépendance react-native-paper sera déclarée en peerDependencies au niveau de la librairie pour ne pas dupliquer la dépendance (voir notre deuxième article pour plus de détails).

Thème par défaut


Si aucun thème n’est passé à la lib, un thème par défaut “Lyra” est utilisé.

Les applications peuvent passer un autre thème en se basant (ou pas) sur le thème par défaut, par exemple :

Déclaration du Provider dans l'app
import { LyraTheme, Provider } from '@lyra/shared-components'

const theme: ReactNativePaper.Theme = {
…LyraTheme,
colors: {
…LyraTheme.colors,
primary: '#29337a',
}
}

export const App = () => (

)

Hook et High Order Component pour la création de styles


Maintenant que le thème et son type associé sont bien en place, il reste un dernier point à adresser sur notre liste de besoins : la mise à disposition d’un hook et d’un HOC pour la création de styles.

Rappel sur les hooks

Les Hooks sont des fonctions qui permettent de « se brancher » sur la gestion d’état local et de cycle de vie de React depuis des fonctions composants.

source

Rappel sur les HOC

Un HOC est une technique avancée de React qui permet de réutiliser la logique de composants. Concrètement, un composant d’ordre supérieur est une fonction qui accepte un composant et renvoie un nouveau composant.

Source

En React Native, le style est passé aux composants grâce à la propriété style. Généralement, on externalise le style avec StyleSheet.create pour plus de lisibilité. Cette méthode ne fait pas grand chose mais au moins elle valide le format du style qu’on lui passe. Exemple :


import { FC } from 'react'
import { StyleSheet } from 'react-native'
import { Text } from 'react-native-paper'

const styles = StyleSheet.create({
link: {
color: 'blue',
textDecorationLine: 'underline',
},
})

export const LinkText: FC = ({ children }) => (
{children}
)


Admettons que ce composant fasse partie de notre librairie et que l’on souhaite que le texte soit de la couleur “primary” du thème. Cela donne :

import { FC } from 'react'
import { StyleSheet } from 'react-native'
import { Text, useTheme } from 'react-native-paper'
 
const styles = StyleSheet.create({
  link: {
    textDecorationLine: 'underline',
  },
})
 
export const LinkText: FC = ({ children }) => {
  const { colors } = useTheme()
  return <Text style={[styles.link, { color: colors.primary }]}>{children}</Text>
}


Ça fonctionne, mais ce n’est pas encore optimal. En effet, cela nous oblige à avoir du style à la fois à l’extérieur du composant mais aussi en inline. Et c’est assez verbeux.

Nous avons donc trouvé une solution qui s’inspire du makeStyles de MUI : une fonction de création de hook.

import { FC } from 'react'
import { Text } from 'react-native-paper'
import { createUseStyles } from '../styles'
 
const useStyles = createUseStyles((theme) => ({
  link: {
    color: theme.colors.primary,
    textDecorationLine: 'underline',
  },
}))
 
export const LinkText: FC = ({ children }) => {
  const { styles } = useStyles()
  return <Text style={styles.link}>{children}</Text>
}


Le hook useStyles retourne un objet avec les styles et le thème. Il est possible de lui passer des propriétés qui sont récupérées en deuxième argument de la callback de createUseStyles.

Pour les composants sous forme de classe, nous mettons également à disposition un HOC withStyles :

import { Component } from 'react'
import { Text } from 'react-native-paper'
import { withStyles } from '../styles'
 
const getStyles = (theme) => ({
  link: {
    color: theme.colors.primary,
    textDecorationLine: 'underline',
  },
})
 
class LinkTextComponent extends Component {
  render() {
    const { styles } = this.props
    return <Text style={styles.link}>{children}</Text>
  }
}
 
export const LinkText = withStyles(getStyles)(LinkTextComponent)

Pour la partie relative au thème, les problématiques sont résolues :

  • La librairie s’occupe de déclarer le Provider de React Native Paper.
  • La librairie propose un thème par défaut, qui reste extensible par les applications.
  • La librairie expose des utilitaires de création de style qui permettent d’accéder au thème.

Gestion de l’internationalisation


Sans rentrer dans les détails, l’internationalisation et la localisation sont des concepts liés à la capacité d’un logiciel à s’adapter à une région donnée. C’est principalement la traduction des textes mais il y a aussi le formatage des dates, des nombres, des devises, et de tout ce qui peut être spécifique à une région.

Nos applications sont disponibles dans plusieurs régions, donc dans plusieurs langues. Et selon l’application et la configuration de celle-ci, la liste des langues disponibles n’est pas toujours la même.

Notre librairie doit donc être capable de fournir ses composants dans la locale désirée. Le soucis, c’est qu’elle ne peut pas anticiper la totalité des locales qui lui seront demandées.

Nos besoins sont les suivants :

  • L’application doit pouvoir spécifier la langue à utiliser
  • L’application doit pouvoir fournir toutes les informations d’une locale si celle-ci n’est pas supportée par la lib, et pouvoir surcharger certaines valeurs selon ses besoins
  • Les composants de la lib doivent respecter la locale spécifiée par l’application


Les problématiques à solutionner sont les suivantes :

  • Comment éviter de dupliquer la dépendance du gestionnaire i18n ?
  • Qui porte la responsabilité de l’internationalisation (dépendance + initialisation) ?
  • Comment fusionner les locales de la librairie et de l’application ?
  • Comment permettre à l’app d’ajouter ou changer des valeurs dans les locales ?


Un gestionnaire d’internationalisation commun


Pour éviter la duplication de dépendance et partager un format commun pour les locales, la solution la plus simple est de se mettre d’accord sur le choix du gestionnaire d’internationalisation.

Les applications utilisent déjà toutes la même librairie : i18n-js. L’idéal serait donc de fusionner les locales entre l’application et la librairie puis d’initialiser le composant d’i18n qu’une seule fois.

Qui porte la responsabilité ?


Comme pour la gestion du thème, la dépendance sera apportée par l’application et déclarée en peerDependencies dans la librairie.

L’application passera la locale courante ainsi que les locales à ajouter / modifier à la librairie via les props de son Provider.

C’est la librairie qui s’occupe d’initialiser le gestionnaire d’internationalisation avec les données de locales fusionnées entre celles de la lib et celles de l’application.

Voici la nouvelle version de notre Provider avec la gestion de l’internationalisation :

Composant Provider de la lib

import i18n from 'i18n-js'
import { merge } from 'lodash'
import { FC, useLayoutEffect } from 'react'
import { Provider as PaperProvider } from 'react-native-paper'
import { ConfigContextProvider } from '../contexts/ConfigContext'
import { LyraTheme } from '../styles/LyraTheme'
import { translations as internalTranslations } from '../i18n'
 
interface Props {
  locale?: string
  mode?: 'TEST' | 'PRODUCTION'
  theme?: ReactNativePaper.Theme
  translations?: Record<string, object> // { 'fr-FR': {...}, 'en-GB': {...} }
}
 
export const Provider: FC<Props> = ({
  locale = 'fr-FR',
  mode = 'PRODUCTION',
  theme = LyraTheme,
  translations,
  children,
}) => {
  // useLayoutEffect pour éviter un flash de contenu non traduit
  useLayoutEffect(() => {
    i18n.fallbacks = true
    i18n.defaultLocale = 'fr-FR'
    i18n.translations = merge({}, internalTranslations, translations)
    i18n.locale = locale
  }, [locale, translations])
 
  return (
    <PaperProvider theme={theme}>
      <ConfigContextProvider locale={locale} mode={mode}>
        {children}
      </ConfigContextProvider>
    </PaperProvider>
  ) 
}



À noter que i18n-js n’utilise pas de Provider, il faut initialiser les champs de son instance globale.

Pour la partie relative à l’internationalisation, les problématiques sont résolues :

✅ Une seule dépendance et une seule initialisation pour le gestionnaire d’internationalisation.

✅ Les traductions de la lib et de l’app sont fusionnées ce qui laisse la liberté aux apps d’ajouter ou remplacer les traductions de la lib.

❗ Le seul point qui ne reste pas optimal est que les apps doivent connaitre les clés de traduction exactes dans la lib pour pouvoir les surcharger.

Les paramètres utilisateur


Dernière partie de cet article assez touffu… nous allons aborder les paramètres utilisateur.

L’intégration de la librairie dans les applications doit être totalement transparente pour l’utilisateur.

Les applications et la librairie gèrent en interne leurs propres paramètres pour leurs composants, mais il ne faut proposer à l’utilisateur qu’un seul écran de paramétrage qui permette de régler tous de paramètres.

Nos besoins

  • Pouvoir intégrer les paramètres de la librairie dans l’écran de paramétrage de l’application
  • L’utilisateur doit pouvoir modifier des paramètres d’app ou de lib de manière transparente


Les problématiques

  • Comment passer des paramètres utilisateur à la librairie ?
  • Comment intégrer des paramètres spécifiques aux composants de la librairie dans l’écran de paramétrage de l’application ?


Passage de paramètres de l’application vers la librairie


Avec notre système de Provider déjà en place, il est facile de passer des paramètres à la librairie.

Ces paramètres sont synchronisés dans un contexte côté librairie et donc accessibles partout dans la librairie.

Voir l’exemple de code du Provider un peu plus haut, les paramètres locale et mode sont des paramètres utilisateurs qui sont gérés au niveau de la librairie.

Intégration des paramètres spécifiques à la librairie


Le comportement de certains composants de la librairie est paramétrable.

Pour que ces paramètres soient parfaitement intégrées, la librairie expose un composant qui se fond parfaitement dans les paramètres de l’application. En effet, ce composant utilise les même composant RNP qui est lui-même correctement configuré au niveau du thème.

La valeur de ces paramètres est stocké dans un contexte interne à la librairie.

L’utilisateur peut donc personnaliser ses paramètres, sans savoir si ceux-ci sont gérés par l’application ou la librairie.

Le screenshot ci-dessous présente l’écran des paramètres de l’application Lyra Collect, le bloc encadré en rouge provient de notre librairie :

✅ L’intégration est donc parfaitement transparente pour l’utilisateur.

✅ L’application peut passer des paramètres personnalisés à la librairie.

✅ La librairie est capable de gérer ses propres paramètres.