Declarative UI VS Imperative UI

Declarative UI VS Imperative UI

Dans cet article, nous allons rappeler les fondamentaux de la programmation déclarative et impérative. Puis, nous allons prendre un exemple concret de besoin et voir par la pratique comment répondre à ce besoin avec ces deux approches de développement graphique, avec l’approche Imperative UI puis Declarative UI. Enfin, nous comparerons les avantages et les inconvénients de chacune de ces approches.

Retour au fondamentaux

Pour comprendre les interfaces graphiques déclaratives/impératives, il faut d’abord comprendre les deux paradigmes de programmation : la programmation impérative et la programmation déclarative.

L’approche impérative

La programmation impérative est un paradigme de programmation qui décrit les opérations à effectuer pour atteindre un résultat. Elle se concentre sur la description de comment le programme doit fonctionner, en spécifiant une séquence d’instructions dans un ordre précis. Les valeurs utilisées dans les différentes variables sont modifiées au cours de l’exécution du programme.

Une analogie avec la recette d’un gâteau

La programmation impérative peut être comparée à la recette d’un gâteau. Dans une recette, on décrit les ingrédients, les proportions et les étapes de la préparation. Ces instructions sont exécutées dans un ordre précis, et le résultat est garanti : un gâteau délicieux !

def afficher_message(message):
  print(message)

afficher_message("Bonjour le monde !")
def afficher_bouton(couleur):
  bouton = Button()
  bouton.color = couleur
  bouton.text = "Bouton rouge"
  bouton.show()

# Appeler la fonction pour afficher un bouton rouge
afficher_bouton("red")
public class FormulaireContact {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Formulaire de contact");
    frame.setSize(300, 200);
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

    // Création des composants du formulaire
    JLabel labelNom = new JLabel("Nom :");
    JTextField champNom = new JTextField();
    JLabel labelPrenom = new JLabel("Prénom :");
    JTextField champPrenom = new JTextField();
    JLabel labelEmail = new JLabel("Adresse e-mail :");
    JTextField champEmail = new JTextField();
    JButton boutonEnvoyer = new JButton("Envoyer");

    // Ajout des composants au formulaire
    frame.add(labelNom);
    frame.add(champNom);
    frame.add(labelPrenom);
    frame.add(champPrenom);
    frame.add(labelEmail);
    frame.add(champEmail);
    frame.add(boutonEnvoyer);

    // Affichage du formulaire
    frame.setVisible(true);
  }
}

Exemples de langages/Frameworks : Java, C, C++, C#, Python, etc.

L’approche déclarative

La programmation déclarative consiste à décrire le résultat souhaité, sans spécifier les étapes à suivre pour l’obtenir. Autrement dit on répond à la question « quoi » en décrivant l’état désiré puis on laisse le système ou le Framework sous-jacent s’en charger du « comment ».

Une analogie avec la liste de courses

Elle est souvent comparée à une liste de courses : on décrit simplement les produits que l’on souhaite acheter, y compris un gâteau, et le résultat est laissé à la discrétion du commerçant.

Exemples de langages/Frameworks : Docker, HTML CSS, etc.

<h1>Bonjour le monde !</h1>
<button style="color: red">Bouton rouge</button>
<form>
  <input type="text" name="nom" placeholder="Nom">
  <input type="text" name="prenom" placeholder="Prénom">
  <input type="email" name="email" placeholder="Adresse e-mail">
  <input type="submit" value="Envoyer">
</form>
# syntax=docker/dockerfile:1
FROM python:3.7-alpine
WORKDIR /code
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
RUN apk add --no-cache gcc musl-dev linux-headers
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
EXPOSE 5000
COPY . .
CMD ["flask", "run"]

Déclarative UI

A ce stade, pour bien assimiler les concept, vous devriez accepter ou commencer à voir le développement d’interfaces graphiques sous un autre angle de vue, réfléchir et penser d’une autre manière. « Penser les choses en déclaratif ».

La Declarative UI est basée sur le paradigme de la programmation déclarative, qui pour rappel consiste à décrire le résultat souhaité, sans spécifier les étapes à suivre pour l’obtenir. Cette approche est spécifiquement adaptée au développement d’interfaces graphiques.

De nombreux frameworks UI modernes utilisent une approche pure déclarative. Parmi les exemples les plus courants, on peut citer :

Une des particularités majeurs des « Declarative UI » est la gestion de l’état des données (variables) porté par chaque vue/composant/widget. L’UI est une représentation graphique de l’état des données soit d’un composant/widget ou de l’application de manière plus général.

Un peu comme les variables globales et les variables locales, il existe deux types d’états, l’état éphémère et l’état applicatif.

Etat éphémère

En résumé, l’état éphémère (parfois appelé état de l’interface utilisateur ou état local) est l’état que vous pouvez parfaitement contenir dans un seul widget.

Etat applicatif

Un peu comme les variables globales et les variables locales, on peut distinguer deux types d’états, l’état éphémère, qui est temporaire et local, et l’état applicatif, qui est permanent et global. (parfois également appelé état partagé).

On va se suffire de ces rappel de concepts fondamentaux pour entamer un cas pratique avec deux implémentations, l’impérative et la déclarative. On va aborder plus en profondeur d’autres aspect avec d’autres articles.


Cas pratique

L’objectif principal de cette partie est de constater les différences entre deux implémentations, même si vous n’allez peut-être pas comprendre chaque ligne de code dans un premier temps.

Supposons que nous souhaitons afficher ou masquer des composants selon qu’ils sont sélectionnés ou non parmi une liste de composants. Un petit détail qui change tout est que nous souhaitons que l’ordre d’affichage des composants soit le même que l’ordre de sélection dans la liste.

Ayant la liste des composants A, B et C,

  • Si nous sélectionnons B puis A, alors nous affichons B puis A.
  • Si nous sélectionnons C puis A puis B, alors nous affichons C puis A puis B.
  • Etc.

Résultat souhaité

La complexité de la solution dépend de l’approche utilisée, déclarative ou impérative.

Implémentation avec l’approche impérative

Cette implémentation se fera avec Angular (v16). Angular est un framework de développement web qui repose majoritairement sur une approche déclarative. Cependant, il utilise également des éléments impératifs dans certains cas. C’est pourquoi nous allons prendre un scénario qui va nous obliger à faire recours à l’approche impérative.

Deux solutions possibles

  • CSS : gérer l’ordre d’affichage avec du CSS. Cette solution est simple à mettre en œuvre, mais elle n’est pas idéale en termes d’accessibilité. En effet, l’ordre des éléments dans le DOM n’est pas le même que l’ordre affiché.
  • DOM : gérer l’ordre d’affichage avec de l’instanciation et l’insertion dynamique dans le DOM à l’aide de ViewContainerRef. Cette solution est plus complexe à mettre en œuvre, mais elle est plus fiable en termes d’accessibilité.

Choix de la solution

Nous allons choisir la deuxième solution, car elle est plus fiable en termes d’accessibilité.

Étape 1 : Créer trois composants Angular

@Component({
  selector: 'app-view-a',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CommonModule],
  styleUrls: ['./view-a.component.scss'],
  template: `
    <p>view-a - {{ inputA1 }} - {{ inputA2 }}</p>
  `,
})
export class ViewAComponent {
  @Input({ required: true }) inputA1!: string;
  @Input({ required: true }) inputA2!: number;
}
@Component({
  selector: 'app-view-b',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CommonModule],
  styleUrls: ['./view-b.component.scss'],
  template: `
    <p>view-b - {{ inputB1 }}</p>
  `,
})
export class ViewBComponent {
  @Input({required: true}) inputB1!: string;
}
@Component({
  selector: 'app-view-c',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CommonModule],
  template: `
    <p>view-c works!</p>
  `,
})
export class ViewCComponent { }

A présent, nous avons nos trois composants sous une forme très basique. Il nous reste à les afficher selon la sélection des utilisateurs via, par exemple, un multiple select.

Etape 2 : Afficher un menu déroulant de sélection de vues

Pour simplifier les choses, nous allons utiliser la célèbre librairie graphique Material Design. Pour l’ajouter au projet Angular, rien de plus simple :

ng add @angular/material

En savoir un peu plus sur le librairie Material : Getting Started with Angular Material

Ainsi, nous pouvons utiliser le composant/module Material « MatSelectModule » dans le composant AppComponent, comme ceci :

<mat-form-field class="view-select">
  <mat-label>Views</mat-label>
  <mat-select
    multiple
    (valueChange)="onViewSelectionChanged($event)"
  >
    <mat-option *ngFor="let view of views" [value]="view.id">{{
      view.label
    }}</mat-option>
  </mat-select>
</mat-form-field>

Nous pouvons noter que nous devons déclarer, dans le contrôleur (TS) AppComponent :

  1. Une liste d’éléments views qui fera référence aux vues/composants que nous souhaitons afficher/masquer en fonction de la sélection.
  2. Une méthode onViewSelectionChanged qui réagira au changement de la sélection.

Comme nous voulons faire les choses bien, créons une liste (array) bien typée.

Pour l’instant, les éléments de la liste possèdent un id et un label. Créons un type qui porte ces propriétés :

export interface ComponentSelectItem {
  id: string;
  label: string;
}

La liste des vues à déclarer dans le composant AppComponent ressemblera donc à :

readonly views: ComponentSelectItem[] = [
  {
    id: 'A',
    label: 'View A',
  },
  {
    id: 'B',
    label: 'View B',
  },
  {
    id: 'C',
    label: 'View C',
  },
];

À ce stade, il nous reste à déclarer la méthode onViewSelectionChanged, dans un premier temps vide :

  onViewSelectionChanged(selectedViews: ComponentSelectItem[]) {
    console.log('onViewSelectionChanged > selectedViews:', selectedViews);
  }

Résulat

En sélectionnant « View C », puis « View A », puis « View B », nous constatons (console.log) que l’ordre des vues obtenu est A, B, puis C. Notre objectif est de garder le même ordre que la sélection. Ce qui n’est pas le cas ici.

Ce petit souci est dû au composant Material qui ordonne les éléments avec son propre comparateur. Nous allons donc définir un comparateur très basique qui nous permettra d’atteindre notre objectif.

  sortComparator(): number {
    return 1;
  }

<mat-form-field class="view-select">
  <mat-label>Views</mat-label>
  <mat-select
    multiple
    [sortComparator]="sortComparator"    <-- NEW LINE
    (valueChange)="onViewSelectionChanged($event)"
  >
    <mat-option *ngFor="let view of views" [value]="view.id">{{
      view.label
    }}</mat-option>
  </mat-select>
</mat-form-field>

Maintenant, l’ordre est celui de la sélection ! Notre menu de sélection est OK.

Dernière étape : afficher les vues sélectionnées dans le bon ordre

Nous allons faire appel à l’instanciation dynamique/programmée de composants via l’API Angular « ViewContainerRef ».

Dans un premier temps, nous déclarons un « ViewChild » Angular comme suit dans le composant « AppComponent » :

// ... code précédent  
@ViewChild('viewsContainer', { read: ViewContainerRef })
viewsContainer!: ViewContainerRef;
// ... code précédent
<div class="views-container">
  <ng-container #viewsContainer></ng-container>
</div>

L’API ViewContainerRef nous permettra de créer et insérer des composants dynamiquement/ »programmatiquement », avec les méthodes :

  • createComponent(): ComponentRef – instancie un composant unique et insère sa vue hôte dans le conteneur en question.
  • insert(): ViewRef – insère une vue existante dans le conteneur en question.
  • detach() – détache une vue de ce conteneur sans la détruire

L’algorithme de base de la méthode onViewSelectionChanged est le suivant :

  • Parcourir la liste des vues A, B et C
  • Si la vue V est sélectionnée, Alors afficher la vue V dans le conteneur viewsContainer
  • Sinon, si la vue V est affichée, alors l’enlever du conteneur

On va mettre à jour le code du composant AppComponent avec ce qui suit :

export interface ComponentSelectItem {
  id: number;
  label: string;
  componentRef: ComponentRef<unknown> | null; // NEW LINE. Sauvegarder la référence vers le composant construit afin d'éviter de le reconstruire.
}

Pour savoir si nous avons sélectionné un élément de plus ou désélectionné un élément en moins, nous allons nous baser sur la différence entre la taille du tableau de vues sélectionnées et la taille précédente. Nous pourrons ensuite récupérer l’élément en question.

  onViewSelectionChanged(selectedViews: ComponentSelectItem[]) {
    const isNewElementSelected = selectedViews.length > this.previousSelectionLength;
    if (isNewElementSelected) {
      const latestSelectedView = selectedViews[selectedViews.length - 1];
      console.log('onViewSelectionChanged > TODO render latestSelectedView', latestSelectedView);
      // TODO this.renderView(latestSelectedView);
    } else if (selectedViews.length < this.previousSelectionLength) {
      const latestUnselectedViewIndex = this.views.findIndex(
        (viewSelectItem) => viewSelectItem.displayed && !selectedViews.includes(viewSelectItem)
      );
      if (latestUnselectedViewIndex > -1) {
        const latestUnselectedView = this.views[latestUnselectedViewIndex];
        console.log('onViewSelectionChanged > TODO detach latestUnselectedView', latestUnselectedView);
        // TODO this.detachView(latestUnselectedView);
      }
    }
    this.previousSelectionLength = selectedViews.length;
  }

Il nous reste plus qu’à définir les deux méthodes qui s’occupent de l’affichage et du masquage des vues, renderView() et detachView().

Pour la méthode renderView(), comme nous sauvegardons la référence vers les composants construits, nous avons deux cas possibles :

  1. La vue (A, B ou C) a déjà été construite. Dans ce cas, nous n’avons qu’à la remettre dans le DOM, plus précisément dans le conteneur viewsContainer.
  2. Sinon, nous devons construire la vue et l’insérer dans le conteneur viewsContainer.
 private renderView(viewSelectItem: ComponentSelectItem) {
    if (!viewSelectItem) {
      return;
    }

    if (viewSelectItem.componentRef) {
      this.viewsContainer.insert(viewSelectItem.componentRef.hostView);
    } else {
      viewSelectItem.componentRef = this.createAndInitializeComponent(viewSelectItem);
    }
    viewSelectItem.displayed = true;
  }
  private createAndInitializeComponent(viewSelectItem: ComponentSelectItem): ComponentRef<unknown> {
    const componentRef = this.viewsContainer.createComponent(viewSelectItem.component);

    viewSelectItem.inputs?.forEach((value, key) => {
      componentRef.setInput(key, value);
    });

    viewSelectItem.htmlTagAttributes.forEach((value, key) => {
      componentRef.location.nativeElement.setAttribute(key, value);
    });

    return componentRef;
  }
  private detachView(unselectedView: ComponentSelectItem): void {
    if (!unselectedView?.componentRef) {
      return;
    }

    const indexOfView = this.viewsContainer.indexOf(unselectedView.componentRef?.hostView);
    if (indexOfView >= 0) {
      this.viewsContainer.detach(indexOfView);
      unselectedView.displayed = false;
    }
  }

Maintenant, nous pouvons remplacer nos TODOs dans la méthode onViewSelectionChanged.

Nous avons atteint notre objectif initial en faisant appel à l’approche impérative.

Remarque : l’approche déclarative est privilégiée en Angular.

Implémentation avec l’approche déclarative

Il est à rappeler que l’objectif principal est de constater les différences entre les deux approches de programmation, impérative et déclarative. néanmoins nous allons constater dés le départ le concept d’état. Si vous avez déjà de l’expérience avec React, SwiftUI ou autre framework similaire cela va vous paraitre naturel sinon vous pouvez vous contenter de considérer que c’est juste une façon de faire porter l’état d’une vue (composant/widget).

Nous allons faire cette implémentation avec Flutter. Cependant quand on va déclarer un composant ou un widget qui porte des données qui changent dans le temps alors ce widget est un widget qui porte un état, en Flutter il est appelé « StatefulWidget« . Dans le cas contraire, le widget n’est responsable d’aucune donnée variable, alors on l’appel « StatelessWidget« . Voilà ! J’ai dit les plus gros mots de cette section.

Il faut noter que j’ai volontairement simplifier les définition de ces deux concepts pour se limiter à ce qui va nous permettre de mieux comprendre l’implémentation au moins dans les grandes et se focaliser sur notre objectif principal.

Avant tout, nous allons mettre en place l’environnent de développement en suivant le Get started. Puis nous allons créer un projet basé sur l’exemple « Counter ».

Nettoyons le code du main.dart pour obtenir un code qui ressemble à celui-ci

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  final String title;

  const MyHomePage({
    Key? key,
    required this.title,
  }) : super(key: key);

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'Notre code ici',
            ),
          ],
        ),
      ),
    );
  }
}

Étape 1 : Créer trois widgets

import 'package:flutter/material.dart';

class WidgetA extends StatelessWidget {
  const WidgetA({super.key});

  @override
  Widget build(BuildContext context) {
    print('WidgetA is built !');
    return const Card(
      margin: EdgeInsets.all(8.0),
      child: Text('Widget A'),
    );
  }
}
import 'package:flutter/material.dart';

class WidgetB extends StatelessWidget {
  const WidgetB({super.key});

  @override
  Widget build(BuildContext context) {
    print('WidgetB is built !');
    return const Card(
      margin: EdgeInsets.all(8.0),
      child: Text('Widget B'),
    );
  }
}
import 'package:flutter/material.dart';

class WidgetC extends StatelessWidget {
  final String text;

  const WidgetC({super.key, required this.text});

  @override
  Widget build(BuildContext context) {
    print('WidgetC is built !');
    return Card(
      margin: const EdgeInsets.all(8.0),
      child: Text(text),
    );
  }
}

Etape 2 : Afficher un menu déroulant de sélection de vues

Pour nous focaliser sur notre objectif principal, nous allons utiliser un composant externe qui nous permettra une sélection multiple via une liste déroulante en installant le package flutter multiselect.

flutter pub add multiselect

Maintenant, dans la classe _MyHomePageState nous allons tout simplement déclarer la liste de widget qu’on peut sélectionner puis l’utiliser dans le widget DropDownMultiSelect issue du package qu’on vient d’installer.

class _MyHomePageState extends State<MyHomePage> {
  final allWidgets = [
    const WidgetA(),
    const WidgetB(),
    const WidgetC(text: 'Widget C')
  ];
  var selectedWidgets = <Widget>[];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            SizedBox( // <-- NEW CODE
              width: 300,
              child: DropDownMultiSelect(
                options: allWidgets,
                selectedValues: selectedWidgets,
                whenEmpty: 'Select widgets',
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Dernière étape : afficher les vues sélectionnées dans le bon ordre

Nous allons déclarer une variable qui nous permettra de porter la liste des widgets sélectionnés « selectedWidgets » qui seront afficher dans l’ordre de la sélection.

A chaque modification de la section, on mettra à jour cette liste selectedWidgets. Ce qui nous amène à un changement d’état.

class _MyHomePageState extends State<MyHomePage> {
  final allWidgets = [
    const WidgetA(),
    const WidgetB(),
    const WidgetC(text: 'Widget C')
  ];
  var selectedWidgets = <Widget>[]; // <-- NEW LINE

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            SizedBox(
              width: 300,
              child: DropDownMultiSelect(
                onChanged: (List<Widget> newSelection) {
                  setState(() { // <-- NEW CODE BLOC
                    selectedWidgets = newSelection;
                  });
                },
                options: allWidgets,
                selectedValues: selectedWidgets,
                whenEmpty: 'Select widgets',
              ),
            ),
            const SizedBox(height: 140), // <-- NEW LINE
            ...selectedWidgets, // <-- NEW LINE
          ],
        ),
      ),
    );
  }
}

Conclusion

Le principal constat est la gestion relativement longue est compliquée nécessaire dans le cas de l’approche impérative qu’on ne retrouve pas avec l’approche déclarative.

L’approche impérative et l’approche déclarative offrent chacune des avantages et des inconvénients. L’approche impérative est plus précise et efficace, mais elle peut être plus difficile à comprendre et à maintenir. L’approche déclarative est plus facile à comprendre et à maintenir, mais elle peut être moins précise et efficace.

En général, l’approche déclarative est préférée pour le développement d’interface utilisateur, car elle offre de nombreux avantages, notamment une meilleure facilité de compréhension, de maintenance, de flexibilité et d’évolutivité.

Avantages de l’approche impérative UI

Précision et efficacité

L’approche impérative permet de décrire de manière précise et efficace les étapes à suivre pour obtenir le résultat souhaité. Cela peut être utile pour des tâches complexes ou qui nécessitent un contrôle précis.

Facilité de développement

L’approche impérative peut être plus facile à développer que l’approche déclarative, car elle offre plus de contrôle sur le processus de développement.

Inconvénients de l’approche impérative UI

Difficulté de compréhension et de maintenance

L’approche impérative peut être plus difficile à comprendre et à maintenir que l’approche déclarative, car elle décrit les étapes à suivre pour obtenir le résultat souhaité.

Flexibilité et évolutivité limitées

L’approche impérative peut être moins flexible et évolutive que l’approche déclarative, car elle est plus liée aux étapes à suivre pour obtenir le résultat souhaité.

Avantages de l’approche déclarative UI

Facilité de compréhension et de maintenance

L’approche déclarative est plus facile à comprendre et à maintenir que l’approche impérative, car elle décrit simplement le résultat souhaité.

Flexibilité et évolutivité

L’approche déclarative est plus flexible et évolutive que l’approche impérative, car elle permet de modifier le résultat souhaité sans modifier le code.

Réutilisabilité

Les composants déclaratifs sont plus réutilisables que les composants impératifs.

Inconvénients de l’approche déclarative UI

Précision et efficacité limitées

L’approche déclarative peut être moins précise et efficace que l’approche impérative, car elle offre moins de contrôle sur le processus de développement.

Difficulté de développement

L’approche déclarative peut être plus difficile à développer que l’approche impérative, car elle nécessite une bonne compréhension des concepts de la programmation déclarative.

Leave a Comment