Comment forcer le thème dark/light sur une app Ionic

Comment forcer le thème dark/light sur une app Ionic
le combat entre les ténèbres et la lumière ne fait que commencer !

Quand j'ai créé ma dernière app, "simple baby diary" (une app qui sert à consigner tous les repas & siestes de bébé), je voulais ajouter une option dans l'app pour forcer un thème dans l'app, sombre ou lumineux.

Quelque chose comme ça :

Comment ça fonctionne ? Ben par défaut, une app Ionic utilise les media queries pour savoir quand utiliser le thème sombre :

:root {
    --ion-color-primary: #3880ff;
    //[...]
}

@media (prefers-color-scheme: dark) {
    // here's the dark theme override
    body {
        //[...]

variables.scss par défaut

Donc j'ai juste cherché une solution simple. (d'ailleurs c'est super simple de forcer le thème sombre dans Chrome pour vos tests)

Sauf que... impossible de trouver une solution simple. C'est visiblement impossible de forcer le prefers-color-scheme sans changer le code.

Du coup je me suis lancé en quête d'une solution simple. Et après quelques minutes, j'ai réalisé.... ben pourquoi ne pas juste prendre le contrôle de ce qui se passe ?

Qu'est ce que je veux en fait ?

  • un mode "auto" par défaut, qui changera le thème selon le mode du téléphone
  • être capable d'activer le thème sombre en permanence
  • être capable d'activer le thème light en permanence

Le code par défaut d'une app Ionic empêche de forcer le thème sombre. Il fallait donc changer la logique.

J'ai donc ajouté 3 classes possibles à mettre sur le "body" de l'app :

  • auto, qui va utiliser le thème configuré du téléphone (en fonction du prefer-colors-scheme)
  • dark, pour forcer le thème sombre
  • light, pour forcer le thème light

A partir de là, tout se fait en quelques étapes simplistes. Déjà on va split le fichier SCSS en 4 :

The fichier variables.scss contiendra le thème light par défaut.

:root {
  --ion-color-primary: #3880ff;
  --ion-color-primary-rgb: 56, 128, 255;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #3171e0;
  --ion-color-primary-tint: #4c8dff;

  // [...]
}

variables.scss

Ensuite, on crée le fichier variables-dark.scss. Lui contiendra quelques mixins avec la config sombre :

@mixin dark-theme() {
  --ion-color-primary: #77c6bf;
  // [...]
  --ion-color-light-tint: #474747;
}

@mixin dark-ios-body() {
  --ion-background-color: #000000;
  // [...]
  --ion-color-step-950: #f2f2f2;
  --ion-item-background: #000000;
  --ion-card-background: #1c1c1d;
}

@mixin dark-ios-modal() {
  --ion-background-color: var(--ion-color-step-100);
  --ion-toolbar-background: var(--ion-color-step-150);
  --ion-toolbar-border-color: var(--ion-color-step-250);
}

@mixin dark-android-body {
  --ion-background-color: #121212;
  // [...]
  --ion-color-step-950: #f3f3f3;
  --ion-item-background: #1e1e1e;
  --ion-toolbar-background: #1f1f1f;
  --ion-tab-bar-background: #1f1f1f;
  --ion-card-background: #1e1e1e;
}

résumé de variables-dark.scss

Maintenant, je veux inclure ces mixins dans 2 situations :

  • quand il y a la classe dark sur body
  • quand il y a la classe auto et que le thème du téléphone (prefers-color-scheme) est sombre

Pour faire ça, j'appelle mes mixins dans le code de theme-dark.scss :

@import "variables-dark";

body.dark {
  @include dark-theme();
}

.ios body.dark {
  @include dark-ios-body();
}

.ios body.dark ion-modal {
  @include dark-ios-modal();
}

.md body.dark {
  @include dark-android-body();
}

@media (prefers-color-scheme: dark) {
  body.auto {
    @include dark-theme();
  }

  .ios body.auto {
    @include dark-ios-body();
  }

  .ios body.auto ion-modal {
    @include dark-ios-modal();
  }

  .md body.auto {
    @include dark-android-body();
  }
}

content of theme-dark.scss

Comme vous le voyez, ce code fera exactement ce que je veux, à savoir inclure ces mixins si le body a la classe dark, ou si le prefers-color-scheme vaut dark et le body a la classe auto.

Et avoir fait des mixins évite d'avoir à répéter tout le css dans les deux cas.

Ca ressemble à une solution plutôt élégante non ? Il n'y a plus qu'à changer la classe sur le body dans mon code.

Ah et n'oubliez pas d'ajouter ces fichiers SCSS à votre fichier projet angular.json ou project.json :

"styles": [
    "src/theme/variables.scss", 
    "src/theme/variables-dark.scss", 
    "src/theme/theme.scss", 
    "src/theme/theme-dark.scss", 
    "src/global.scss"],

angular.json

Maintenant, pour ajouter la classe au body, je vais juste créer un service ColorSchemeService qui va ajouter/enlever la class, via le @Inject(DOCUMENT) :

import { Inject, Injectable } from '@angular/core';
import { DOCUMENT } from '@angular/common';

@Injectable({
  providedIn: 'root',
})
export class ColorSchemeService {
  constructor(@Inject(DOCUMENT) private document: Document) {}
  
  getColorScheme(): string {
    return localStorage.getItem('colorScheme') ?? 'auto';
  }

  setColorScheme(newColorScheme: string): void {
    localStorage.setItem('colorScheme', newColorScheme);
    this.loadStoredTheme();
  }

  loadStoredTheme(): void {
    const colorScheme = localStorage.getItem('colorScheme');
    if (!colorScheme) {
        return;
    }
    this.document.body.classList.remove('auto');
    this.document.body.classList.remove('dark');
    this.document.body.classList.remove('light');
    this.document.body.classList.add(colorScheme);
  }
}

the content of color-scheme.service.ts

Evidemment les perfectionnistes me diront "eh ce serait mieux d'utiliser un ENUM pour ton thème de couleurs, et éviter de le stocker dans le local storage", mais ici je ne cherche pas à faire du code parfait, juste du code qui marche 🤷

Au final, j'ai plus qu'à charger mon thème dans le constructor du AppComponent :

import { Component, Renderer2 } from '@angular/core';
import { ColorSchemeService } from './services/color-scheme.service';

@Component({
  selector: 'z4e-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {
  constructor(private readonly colorSchemeService: ColorSchemeService) {
    this.colorSchemeService.loadStoredTheme();
  }
}

app.component.ts

Et quand je clique sur un des boutons pour forcer le thème :

 <ion-segment color="primary" [(ngModel)]="colorScheme" (ionChange)="colorSchemeChanged()">
    <ion-segment-button value="auto">
      <ion-label>Auto</ion-label>
    </ion-segment-button>
      ...

component.html

colorScheme: string;

constructor(private readonly colorSchemeService: ColorSchemeService) {
  this.colorScheme = this.colorSchemeService.getColorScheme();
}

colorSchemeChanged() {
  this.colorSchemeService.setColorScheme(this.colorScheme);
}

component.ts

And... voilà !

forcer ou ne pas forcer, là est la question

Ah une dernière chose, par défaut le thème light ne déclare pas ses color et background color (surement un bug passager, j'espère qu'il sera corrigé rapidement). Du coup je vous conseille d'ajouter ça dans theme.scss :

body.light {
  color: var(--ion-text-color);
  background: var(--ion-background-color);
}

theme.scss

Vous pouvez trouver le code source ici : https://github.com/HowTommy/ionic-force-color-schemes

En espérant que ça vous aide,

Bonne nuit et bon dev !