Comment interagir ou avoir des interactions avec/entre plusieurs StatefulWidget?

Difficulté: Débutant

Introduction

Parfois, il est nécessaire d’accéder à un StatefulWidget à partir d’un autre Widget pour effectuer certaines opérations ou de lier deux ou plusieurs StatefulWidgets pour réaliser une logique métier.

Par exemple, supposons que vous ayez un bouton à 2 états, qui est soit ‘on’, soit ‘off’. Cela est facile à faire.

Cependant, si vous considérez maintenant une série de ces boutons où votre règle dit: “un seul de ces boutons ne peut être activé à la fois". Comment faire? Plus compliqué encore… imaginez le cas où ces boutons feraient partie de différentes arborescences de widgets…

Il existe plusieurs façons de gérer ce cas et cet article va présenter quelques solutions. Certaines seront simples, d’autres plus complexes; certaines seront meilleures que d’autres mais l’objectif de cet article n’est pas d'être exhaustif mais de vous faire comprendre le principe général…

Commençons…


Bouton à 2 états

Écrivons d’abord le code de base d’un bouton à 2 états. Le code pourrait ressembler à ceci:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@immutable
class ButtonTwoStates extends StatefulWidget {
  const ButtonTwoStates({
    Key key,
    this.isOn = false,
    this.index,
    this.onChange,
  }) : super(key: key);

  final bool isOn;
  final int index;
  final ValueChanged<bool> onChange;

  @override
  _ButtonTwoStatesState createState() => _ButtonTwoStatesState();
}

class _ButtonTwoStatesState extends State<ButtonTwoStates> {
  //
  // Etat interne
  //
  bool _isOn;

  @override
  void initState() {
    super.initState();
    _isOn = widget.isOn;
  }

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: _toggleState,
      child: Container(
        width: 80.0,
        height: 56.0,
        color: _isOn ? Colors.red : Colors.green,
      ),
    );
  }

  //
  // L'utilisateur appuie sur le bouton et nous voulons basculer
  // son état interne
  //
  void _toggleState() {
    _isOn = !_isOn;
    if (mounted) {
      setState(() {});
    }
    widget.onChange?.call(_isOn);
  }
}

Explication:

  • ligne 20: état interne de ce bouton
  • ligne 25: nous initialisons l'état, sur la base des informations fournies au Widget
  • ligne 31: lorsque nous tapons sur le bouton, nous appelons la méthode _toggleState
  • ligne 45: nous basculons simplement l'état interne du bouton,
  • lignes 46-48: nous demandons au bouton de reconstruire, en s’assurant (ligne 104) que le bouton est toujours là
  • ligne 49: nous invoquons le rappel (si mentionné)

Note relative à la syntaxe: ‘?.call()’

Le code suivant: “widget.onChange?.call(_isOn);” est équivalent à

if (widget.onChange != null){
   widget.onChange(_isOn);
}


Page de base

Créons maintenant une page contenant 2 de ces boutons. Le code pourrait ressembler à ceci:

class TestPage extends StatefulWidget {
  @override
  _TestPageState createState() => _TestPageState();
}

class _TestPageState extends State<TestPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('2-state buttons'), centerTitle: true,),
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ButtonTwoStates(
              index: 0,
              isOn: false,
              onChange: (bool isOn) {
                debugPrint('My first button is on ? $isOn');
              },
            ),
            const SizedBox(width: 8),
            ButtonTwoStates(
              index: 1,
              isOn: false,
              onChange: (bool isOn) {
                debugPrint('My second button is on ? $isOn');
              },
            ),
          ],
        ),
      ),
    );
  }
}

Nous obtenons l'écran suivant:

Basic Screen
Basic Screen

Lorsque l’utilisateur tape sur l’un des boutons, la couleur de celui correspondant bascule.


Comment créer une relation entre les 2 boutons?

Supposons maintenant que nous devons avoir le comportement suivant: “quand un bouton est ‘activé’, l’autre est ‘désactivé’". Comment pourrions-nous y parvenir?

À première vue, nous pourrions mettre à jour le code de la page comme suit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class _TestPageState extends State<TestPage> {
  final List<bool> _buttonIsOn = [true, false];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('2-state buttons'),
        centerTitle: true,
      ),
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ButtonTwoStates(
              index: 0,
              isOn: _buttonIsOn[0],
              onChange: (bool isOn) {
                _onButtonValueChange(index: 0, isOn: isOn);
              },
            ),
            const SizedBox(width: 8),
            ButtonTwoStates(
              index: 1,
              isOn: _buttonIsOn[1],
              onChange: (bool isOn) {
                _onButtonValueChange(index: 1, isOn: isOn);
              },
            ),
          ],
        ),
      ),
    );
  }

  void _onButtonValueChange({int index, bool isOn}) {
    final int indexOtherButton = (index + 1) % _buttonIsOn.length;

    _buttonIsOn[index] = isOn;
    _buttonIsOn[indexOtherButton] = !isOn;

    setState(() {});
  }
}

Cependant, malgré cette modification qui semble logique, cela ne fonctionne pas… pourquoi?

Le problème vient du bouton ButtonTwoStates qui est un StatefulWidget.

Si vous vous référez à mon précédent article sur la notion de Widget - État - Contexte, vous vous souviendrez que:

  • Dans un StatefulWidget, la partie Widget est immuable (donc, ne change jamais) et doit être considérée comme une sorte de configuration
  • la méthode initState() est uniquement n’est exécutée QU’UNE SEULE FOIS pour la vie entière du StatefulWidget

Alors pourquoi cela ne marche pas?

Tout simplement parce que, en passant simplement une “nouvelle valeur” au ButtonState.isOn, son State correspondant ne changera pas car la méthode initState ne sera pas appelée une seconde fois.

Comment pouvons-nous résoudre ce problème?

Toujours en référence à mon article précédent ( Widget - État - Contexte ), j’y ai mentionné une seule méthode surchargeable: didUpdateWidget sans l’expliquer…

didUpdateWidget

Cette méthode est invoquée lorsque le Widget ‘parent’ est reconstruit et fournit différents arguments à ce Widget (bien sûr, ce Widget doit conserver les mêmes Key et runtimeType).

Dans ce cas, Flutter appelle la méthode didUpdateWidget(oldWidget), fournissant en argument l’ ancien Widget.

Il appartient donc à l’instance du State de prendre les mesures appropriées en fonction des variations potentielles entre les arguments de l’ancien Widget et du nouveau.

… et c’est exactement ce dont nous avons besoin puisque:

  • nous reconstruisons le Widget parent (ligne: 42)
  • nous fournissons aux widgets enfants de nouvelles valeurs (lignes: 17 et 25)

Appliquons la modification nécessaire à notre code source _ButtonTwoStatesState comme suit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class _ButtonTwoStatesState extends State<ButtonTwoStates> {
  //
  // Etat interne
  //
  bool _isOn;

  @override
  void initState() {
    super.initState();
    _isOn = widget.isOn;
  }

  @override
  void didUpdateWidget(ButtonTwoStates oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.isOn != oldWidget.isOn){
      _isOn = widget.isOn;
    }
  }

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: _onTap,
      child: Container(
        width: 80.0,
        height: 56.0,
        color: _isOn ? Colors.red : Colors.green,
      ),
    );
  }

  //
  // L'utilisateur tappe sur le bouton
  //
  void _onTap() {
    _isOn = !_isOn;
    if (mounted) {
      setState(() {});
    }
    widget.onChange?.call(_isOn);
  }
}

Grâce à la ligne #17, la valeur interne “_isOn” est maintenant mise à jour et le bouton sera reconstruit en utilisant cette nouvelle valeur.

OK, maintenant cela fonctionne cependant, toute la logique métier liée aux boutons est prise en compte par la TestPage, qui fonctionne très bien avec 2 boutons mais si vous devez maintenant envisager des boutons supplémentaires, cela pourrait devenir un cauchemar plus encore si les boutons ne sont pas des enfants directs du même parent!


Notion de GlobalKey

La classe GlobalKey génère une clé unique pour l’ensemble de l’application . Mais le principal intérêt d’utiliser une GlobalKey dans le contexte de cet article est que

La GlobalKey donne accès au State d’un StatefulWidget, à l’aide du getter currentState.

Qu’est-ce que cela signifie?

Appliquons d’abord une modification du code source d’origine des ButtonTwoStates pour rendre son State public. C’est très simple: nous supprimons simplement le signe “_” du nom de la classe “_ButtonTwoStatesState". Cela rend la classe publique. On obtient alors:

class ButtonTwoStates extends StatefulWidget {

  ...

  @override
  ButtonTwoStatesState createState() => ButtonTwoStatesState();
}

class ButtonTwoStatesState extends State<ButtonTwoStates> {
  ...
}

Maintenant, utilisons la GlobalKey dans notre TestPage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class _TestPageState extends State<TestPage> {
  final List<bool> _buttonIsOn = [true, false];
  final List<GlobalKey<ButtonTwoStatesState>> _buttonKeys = [
    GlobalKey<ButtonTwoStatesState>(),
    GlobalKey<ButtonTwoStatesState>(),
  ];
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('2-state buttons'),
        centerTitle: true,
      ),
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ButtonTwoStates(
              key: _buttonKeys[0],
              index: 0,
              isOn: _buttonIsOn[0],
              onChange: (bool isOn) {
                _onButtonValueChange(index: 0, isOn: isOn);
              },
            ),
            const SizedBox(width: 8),
            ButtonTwoStates(
              key: _buttonKeys[1],
              index: 1,
              isOn: _buttonIsOn[1],
              onChange: (bool isOn) {
                _onButtonValueChange(index: 1, isOn: isOn);                
              },
            ),
          ],
        ),
      ),
    );
  }

  void _onButtonValueChange({int index, bool isOn}) {
    final int indexOtherButton = (index + 1) % _buttonIsOn.length;

    _buttonIsOn[index] = isOn;
    _buttonIsOn[indexOtherButton] = !isOn;

    // setState(() {});
    _buttonKeys[indexOtherButton].currentState.resetState();
  }
}

Explication

  • lignes 3-6: nous générons un tableau de GlobalKey faisant référence au ButtonTwoStatesState
  • ligne 20: on dit au premier bouton quelle est sa clé
  • ligne 29: idem pour le bouton 2
  • ligne 49: comme nous allons le voir ci-dessous, nous appelons le bouton qui doit être réinitialisé
  • ligne 48: nous n’avons plus besoin de reconstruire complètement la page.

Ainsi, la modification à appliquer au code ButtonTwoStatesState se limite à ajouter une nouvelle méthode comme suit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class ButtonTwoStatesState extends State<ButtonTwoStates> {
  ...
  //
  // Réinitialiser l'état
  //
  void resetState(){
    _isOn = false;
    if (mounted) {
      setState(() {});
    }
  }
}

Explication

  • ligne 6: Comme vous pouvez le voir, la méthode est publique (pas de préfixe “_"), ce qui est nécessaire pour être accessible à partir de la TestPage (car son code source ne réside pas dans le même fichier physique)
  • ce code réinitialise simplement l'état de ce bouton et procède à une reconstruction.

Comme on peut le voir, cette solution fonctionne également mais là encore, toute la logique métier liée aux boutons est prise en compte par la TestPage


Autre solution: un Controller

Une autre solution consisterait à utiliser une classe Controller. En d’autres termes, cette classe serait utilisée pour contrôler la logique et les boutons.

L’idée d’un tel contrôleur est que chaque bouton indique au contrôleur quand il est sélectionné. Ensuite, le contrôleur prendra la décision sur les actions à entreprendre.

Voici une implémentation très basique possible d’un tel contrôleur:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class ButtonTwoStateControllers {
  ButtonTwoStateControllers({
    this.buttonsCount,
    this.onChange,
    int selectedIndex,
  })  : assert(buttonsCount != null && buttonsCount > 0),
        assert((selectedIndex == null || (selectedIndex != null && selectedIndex >= 0 && selectedIndex < buttonsCount))) {
    _selectedIndex = selectedIndex ?? -1;
    _registeredButtons = List.generate(buttonsCount, (index) => null);
  }

  //
  // Nombre total de boutons
  //
  final int buttonsCount;

  //
  // Routine à appeler quand la sélection change
  //
  final ValueChanged<int> onChange;

  // Index du bouton sélectionné
  int _selectedIndex;

  // Liste des State des boutons enregistrés
  List<ButtonTwoStatesState> _registeredButtons;

  //
  // Enregistre un bouton et retourne son état
  //
  //
  bool registerButton(ButtonTwoStatesState button) {
    final int buttonIndex = button.index;

    assert (buttonIndex >= 0 && buttonIndex < buttonsCount);
    assert (_registeredButtons[buttonIndex] == null);

    _registeredButtons[buttonIndex] = button;

    return _selectedIndex == buttonIndex;
  }

  //
  // Quand un bouton est sélectionné, on enregistre son index
  // et nous désélectionnons les autres boutons
  //
  void setButtonSelected(int buttonIndex) {
    assert (buttonIndex >= 0 && buttonIndex < buttonsCount);
    assert (_registeredButtons[buttonIndex] != null);

    if (_selectedIndex != buttonIndex) {
      _selectedIndex = buttonIndex;
      for (int index = 0; index < buttonsCount; index++){
        _registeredButtons[index]?.isOn(index == _selectedIndex);
      }

      // Notify about the change
      onChange?.call(buttonIndex);
    }
  }
}

J’espère que le code est explicite:

  • les assert sont là pour s’assurer pendant le temps de développement que les limites sont respectées
  • lignes 32-40: lorsqu’un bouton s’enregistre, son State est enregistré dans le tableau _registeredButtons
  • lignes 47-60: lorsque nous modifions le bouton sélectionné, nous informons les boutons enregistrés de leur nouvel état

Jetons un coup d'œil aux modifications à appliquer à la fois à TestPage et à ButtonTwoStatesState:

TestPage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class _TestPageState extends State<TestPage> {
  final ButtonTwoStateControllers controller = ButtonTwoStateControllers(
    buttonsCount: 2,
    selectedIndex: 0,
    onChange: (int selectedIndex){
      // code à exécuter
    },
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('2-state buttons'),
        centerTitle: true,
      ),
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ButtonTwoStates(
              controller: controller,
              index: 0,
            ),
            const SizedBox(width: 8),
            ButtonTwoStates(
              controller: controller,
              index: 1,
            ),
          ],
        ),
      ),
    );
  }
}

Explication

Le TestPage initialise simplement le ButtonTwoStatesController, mentionne le nombre de boutons à considérer, quel bouton est sélectionné ainsi qu’une méthode de rappel à invoquer lorsque la sélection du bouton change.

Ce contrôleur est passé en arguments à chaque bouton (lignes 22 et 27).

ButtonTwoStatesState:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class ButtonTwoStates extends StatefulWidget {
  const ButtonTwoStates({
    Key key,
    this.controller,
    this.index,
  })  : assert(controller != null),
        super(key: key);

  final ButtonTwoStateControllers controller;
  final int index;

  @override
  ButtonTwoStatesState createState() => ButtonTwoStatesState();
}

class ButtonTwoStatesState extends State<ButtonTwoStates> {
  bool _isOn;

  @override
  void initState() {
    super.initState();
    _isOn = widget.controller.registerButton(this);
  }

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: _onTap,
      child: Container(
        width: 80.0,
        height: 56.0,
        color: _isOn ? Colors.red : Colors.green,
      ),
    );
  }

  //
  // L'utilisateur appuie sur le bouton
  //
  void _onTap() {
    widget.controller.setButtonSelected(index);
  }

  //
  // Définit l'état
  //
  void isOn(bool isOn) {
    if (_isOn != isOn) {
      if (mounted) {
        setState(() {
          _isOn = isOn;
        });
      }
    }
  }

  //
  // Getter pour obtenir l'index
  //
  int get index => widget.index ?? -1;
}

Explication

  • ligne # 22: Au moment de l’initialisation, le bouton s’enregistre ("this") auprès du contrôleur qui renvoie l'état (= _isOn) de ce bouton.
  • ligne # 41: Lorsque l’utilisateur appuie sur le bouton, il informe le contrôleur en fournissant son propre “index
  • ligne # 60: moyen pratique pour le bouton de donner son numéro d’index
  • lignes # 47-55: utilisées par le contrôleur pour informer le bouton de son état.

Cette solution est déjà bien meilleure car la logique métier liée aux boutons a été externalisée vers le contrôleur (même pour les boutons eux-mêmes).

Il est également beaucoup plus facile d’ajouter un bouton à la page.

Même si cette solution fonctionne, ce n’est cependant pas idéal car dans une application réelle, les boutons ne seront probablement pas tous insérés dans l’arborescence par le même parent.


Provider

Afin de résoudre ce problème, mettons le contrôleur à la disposition de tous les widgets, qui font partie de la TestPage. Pour ce faire, utilisons un Provider.

Voyons les changements à appliquer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class TestPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Provider<ButtonTwoStatesController>(
      create: (BuildContext context) => ButtonTwoStatesController(
        buttonsCount: 3,
        selectedIndex: 0,
        onChange: (int selectedIndex) {
          // Code à éxécuter quand la sélection change
          print('selectedIndex: $selectedIndex');
        },
      ),
      child: Scaffold(
        appBar: AppBar(
          title: Text('2-state buttons'),
          centerTitle: true,
        ),
        body: Center(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ButtonTwoStates(
                index: 0,
              ),
              const SizedBox(width: 8),
              ButtonTwoStates(
                index: 1,
              ),
              const SizedBox(width: 8),
              ButtonTwoStates(
                index: 2,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Comme nous pouvons le voir, la Page peut maintenant devenir un StatelessWidget et nous n’avons plus besoin de passer le contrôleur à chaque bouton.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class ButtonTwoStates extends StatefulWidget {
  const ButtonTwoStates({
    Key key,
    this.index,
  }) : super(key: key);

  final int index;

  @override
  ButtonTwoStatesState createState() => ButtonTwoStatesState();
}

class ButtonTwoStatesState extends State<ButtonTwoStates> {
  bool _isOn;
  ButtonTwoStatesController controller;

  @override
  void initState() {
    super.initState();
    controller = Provider.of<ButtonTwoStatesController>(context, listen: false);
    
    _isOn = controller.registerButton(this);
  }

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: _onTap,
      child: Container(
        width: 80.0,
        height: 56.0,
        color: _isOn ? Colors.red : Colors.green,
      ),
    );
  }

  //
  // L'utilisateur appuie sur le bouton
  //
  void _onTap() {
    controller.setButtonSelected(index);
  }

  //
  // Définit l'état interne
  //
  void isOn(bool isOn) {
    if (_isOn != isOn) {
      if (mounted) {
        setState(() {
          _isOn = isOn;
        });
      }
    }
  }

  //
  // Getter pour obtenir l'index du bouton
  //
  int get index => widget.index ?? -1;
}

Maintenant, c’est au bouton de récupérer le contrôleur, via un appel au Provider (ligne 20).

Avantages de cette solution:

  • la TestPage est maintenant un StatelessWidget
  • les boutons peuvent être n’importe où dans l’arborescence du widget, dont la racine est TestPage, et cela fonctionnera.

C’est beaucoup mieux, cependant, nous exposons le ButtonTwoStatesState des boutons au monde extérieur… N’y a-t-il pas d’autre solution?


Réaction au changement (Reactive)

Une autre approche consisterait à laisser les boutons réagir à tout changement. Cela pourrait être réalisé de différentes manières. Jetons un coup d'œil à certaines d’entre elles…

BLoC

Oui, je sais … encore une fois la notion de BLoC et … pourquoi pas? ( cette fois, j’utiliserai le Provider, pour changer un peu).

Si vous vous souvenez de mes articles sur le sujet (voir ici et ici), nous utilisons des Streams.

Le code d’un tel BLoC pourrait ressembler à ceci:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class ButtonTwoStatesControllerBloc {
  //
  // Stream pour gérer l'index du bouton sélectionné
  //
  BehaviorSubject<int> _selectedButtonStreamController = BehaviorSubject<int>();
  Stream<int> get outSelectedButtonIndex => _selectedButtonStreamController.stream;
  Function(int) get inSelectedButtonIndex => _selectedButtonStreamController.sink.add;

  //
  // Nombre total de boutons
  //
  final int buttonsCount;

  //
  // Routine à appeler en cas de modification
  //
  final ValueChanged<int> onChange;

  //
  // Constructeur
  //
  ButtonTwoStatesControllerBloc({
    this.buttonsCount,
    int selectedIndex,
    this.onChange,
  })  : assert(buttonsCount != null && buttonsCount > 0),
        assert((selectedIndex == null || (selectedIndex != null && selectedIndex >= 0 
          && selectedIndex < buttonsCount))) {

    //
    // Propagation de l'index sélectionné (si mentionné)
    //
    if (selectedIndex != null) {
      inSelectedButtonIndex(selectedIndex);
    }

    //
    // Réagit aux changements et invoque le callback
    //
    outSelectedButtonIndex.listen((int index) => onChange?.call(index));
  }

  void dispose() {
    _selectedButtonStreamController?.close();
  }
}

Comme vous pouvez le voir, au moment de l’initialisation:

  • si nous fournissons un selectedIndex valide, nous l’envoyons au stream afin d'être intercepté plus tard
  • nous commençons à écouter le stream, qui sera utilisé par les boutons (voir plus loin) et nous invoquons le callback “onChange” (ligne # 40)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class TestPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Provider<ButtonTwoStatesControllerBloc>(
      create: (BuildContext context) => ButtonTwoStatesControllerBloc(
        buttonsCount: 3,
        selectedIndex: 0,
        onChange: (int selectedIndex) {
          // Faire ce qui doit être fait avec cet index
          print('selectedIndex: $selectedIndex');
        },
      ),
      dispose: (BuildContext context, ButtonTwoStatesControllerBloc bloc) => bloc?.dispose(),
      child: Scaffold(
        appBar: AppBar(
          title: Text('2-state buttons'),
          centerTitle: true,
        ),
        body: Center(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ButtonTwoStates(
                index: 0,
              ),
              const SizedBox(width: 8),
              ButtonTwoStates(
                index: 1,
              ),
              const SizedBox(width: 8),
              ButtonTwoStates(
                index: 2,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Au niveau du TestPage, nous initialisons simplement le ButtonTwoStatesControllerBloc et l’injectons dans l’arborescence.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class ButtonTwoStates extends StatelessWidget {
  const ButtonTwoStates({
    Key key,
    this.index,
  }) : super(key: key);

  final int index;

  @override
  Widget build(BuildContext context) {
    final ButtonTwoStatesControllerBloc controllerBloc = 
        Provider.of<ButtonTwoStatesControllerBloc>(context, listen: false);

    return StreamBuilder<int>(
      stream: controllerBloc.outSelectedButtonIndex,
      initialData: -1,
      builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
        final bool isOn = snapshot.data == index;

        return InkWell(
          onTap: () => controllerBloc.inSelectedButtonIndex(index),
          child: Container(
            width: 80.0,
            height: 56.0,
            color: isOn ? Colors.red : Colors.green,
          ),
        );
      },
    );
  }
}

Et enfin, pour les boutons:

  • lignes # 11-12: nous récupérons le ButtonTwoStatesControllerBloc
  • nous utilisons un StreamBuilder qui:    * écoute le flux (ligne 15)    * reconstruit lorsqu’un nouvel index est émis    * injecte l’index du bouton lorsque celui-ci est tapoté (ligne 21)

Cette solution fonctionne également mais tous les boutons sont systématiquement reconstruits… ce qui a un impact sur les performances.

Pour limiter le nombre de reconstructions, nous pourrions adapter le BLoC pour n'émettre que des événements liés aux changements (on => off, off => on) mais nous aurions alors besoin de lier le nouvel état à l’index du bouton.

Bien sûr, cela a un prix…

  • nous complexifions le BLoC    * un nouveau contrôleur de flux (si nous voulons vraiment nous en tenir à la théorie: BLoC => flux uniquement)    * nous devons maintenir l'état du bouton actuellement sélectionné

… mais ce n’est pas difficile à faire. C’est un compromis à faire.


ValueNotifier

Nous pourrions également utiliser un ValueNotifier complémenté par un ValueListenableBuilder.

ValueNotifier

Un ValueNotifier écoute les variations d’une valeur interne. Lorsqu’une variation se produit, elle avertit tout ce qui l'écoute. Il implémente un ValueListenable.

ValueListenableBuilder

Un ValueListenableBuilder est un Widget qui écoute les notifications émises par un ValueListenable, et reconstruit en fournissant la valeur émise à sa méthode builder.

La solution, basée sur ces 2 notions, consiste en des boutons qui s’enregistrent auprès d’un contrôleur. Ce dernier indique à chaque bouton quel ValueNotifier écouter.

Une telle solution pourrait s'écrire comme suit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class ButtonTwoStatesControllerListenable {
  //
  // Liste des ValueNotifier liés aux boutons
  //
  List<ValueNotifier<bool>> _registeredButtons;

  //
  // Index sélectionné
  //
  int _selectedIndex = -1;

  //
  // Nombre total de boutons
  //
  final int buttonsCount;

  //
  // Routine à appeler à chaque variation de sélection
  //
  final ValueChanged<int> onChange;

  //
  // Constructeur
  //
  ButtonTwoStatesControllerListenable({
    this.buttonsCount,
    int selectedIndex,
    this.onChange,
  })  : assert(buttonsCount != null && buttonsCount > 0),
        assert((selectedIndex == null || (selectedIndex != null && selectedIndex >= 0 && selectedIndex < buttonsCount))) {

    //
    // Préparation du tableau
    //
    _registeredButtons = List.generate(buttonsCount, (index) => null);

    // Sauvegarde de la sélection initiale (si mentionnée)
    _selectedIndex = selectedIndex ?? -1;
  }

  //
  // Enregistrement des boutons, via leur index
  //
  ValueNotifier<bool> registerButton(int buttonIndex){
    assert (buttonIndex >= 0 && buttonIndex < buttonsCount);
    assert (_registeredButtons[buttonIndex] == null);

    ValueNotifier<bool> valueNotifier = _registeredButtons[buttonIndex] 
                                      = ValueNotifier<bool>(buttonIndex == _selectedIndex);

    return valueNotifier;
  }

  //
  // Quand un bouton est sélectionné, on enregistre l'information
  // et l'on désélectionne les autres
  //
  void setButtonSelectedIndex(int buttonIndex){
    assert (buttonIndex >= 0 && buttonIndex < buttonsCount);
    assert (_registeredButtons[buttonIndex] != null);

    if (buttonIndex != _selectedIndex){
      if (_selectedIndex != -1){
        _registeredButtons[_selectedIndex].value = false;
      }
      _selectedIndex = buttonIndex;
      _registeredButtons[_selectedIndex].value = true;

      // appel du callback
      onChange?.call(_selectedIndex);
    }
  }
}

Ce code est très similaire à notre version du premier contrôleur, plus haut dans l’article.

Les parties intéressantes sont:

  • lignes # 48-49: nous initialisons un ValueNotifier<bool> pour gérer l'état du bouton d’un certain index.
  • ligne # 64: s’il y avait un bouton précédemment sélectionné, nous le marquons comme non sélectionné (= false)
  • ligne # 67: nous marquons l’index actuellement sélectionné

Le simple fait de changer la valeur interne d’un ValueNotifier (lignes # 64 & 67) entraîne automatiquement l’appel du builder du ValueListenableBuilder correspondant (voir plus loin)

En ce qui concerne le TestPage, nous injectons uniquement le nouveau contrôleur, comme suit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class TestPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Provider<ButtonTwoStatesControllerListenable>(
      create: (BuildContext context) => ButtonTwoStatesControllerListenable(
        buttonsCount: 3,
        selectedIndex: 0,
        onChange: (int selectedIndex) {
          // Code à exécuter en réponse à un changement
          print('selectedIndex: $selectedIndex');
        },
      ),
      child: Scaffold(
        appBar: AppBar(
          title: Text('2-state buttons'),
          centerTitle: true,
        ),
        body: Center(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ButtonTwoStates(
                index: 0,
              ),
              const SizedBox(width: 8),
              ButtonTwoStates(
                index: 1,
              ),
              const SizedBox(width: 8),
              ButtonTwoStates(
                index: 2,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Le code lié au bouton doit être modifié comme suit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ButtonTwoStates extends StatefulWidget {
  const ButtonTwoStates({
    Key key,
    this.index,
  }) : super(key: key);

  final int index;

  @override
  _ButtonTwoStatesState createState() => _ButtonTwoStatesState();
}

class _ButtonTwoStatesState extends State<ButtonTwoStates> {
  ValueNotifier<bool> valueNotifier;
  ButtonTwoStatesControllerListenable controllerListenable;

  @override
  void initState() {
    super.initState();
    controllerListenable = 
        Provider.of<ButtonTwoStatesControllerListenable>(context, listen: false);
    valueNotifier = controllerListenable.registerButton(widget.index);
  }

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<bool>(
      valueListenable: valueNotifier,
      builder: (BuildContext context, bool isOn, Widget child) {
        return InkWell(
          onTap: () => controllerListenable.setButtonSelectedIndex(widget.index),
          child: Container(
            width: 80.0,
            height: 56.0,
            color: isOn ? Colors.red : Colors.green,
          ),
        );
      },
    );
  }
}

Explication:

  • ligne # 20: on récupère le contrôleur
  • ligne # 21: nous enregistrons ce bouton qui se traduit par l’obtention d’un ValueNotifier, généré par le contrôleur
  • ligne # 27: nous utilisons un ValueListenableBuilder qui,
    • ligne # 28: écoute les variations de la valeur de ValueNotifier
    • ligne # 31: lorsque l’utilisateur appuie sur le bouton, nous en informons le contrôleur

C’est tout. Cette solution fonctionne également.


Conclusion

Lorsque nous commençons à développer en Flutter, la notion de StatefulWidget n’est pas si facile à maîtriser, et quand vient le moment d’appliquer des règles entre plusieurs instances, il est très courant de se demander comment faire les choses.

Cet article essaie de donner quelques conseils à travers l’exemple de boutons à 2 états.

Des dizaines d’autres solutions existent. Selon moi, il n’existe pas de solution idéale. Cela dépend fortement de votre cas d’utilisation.

J’espère que cet article vous donnera au moins un aperçu de ce sujet.

Restez à l'écoute pour de nouveaux articles et comme d’habitude, je vous souhaite un bon codage!