StatefulWidget - Interaction(s)

Compatibilité
Date révision
24 mars 2023
Publié le
03 juin 2020
Flutter
v 3.13.x
Dart
v 3.1.x

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
3class ButtonTwoStates extends StatefulWidget {
4    const ButtonTwoStates({
5      super.key,
6      required this.index,
7      required this.onChange,
8      this.isOn = false,
9    });
10
11    final bool isOn;
12    final int index;
13    final ValueChanged<bool> onChange;
14
15    
16    _ButtonTwoStatesState createState() => _ButtonTwoStatesState();
17}
18
19class _ButtonTwoStatesState extends State<ButtonTwoStates> {
20    //
21    // Etat interne
22    //
23    late bool _isOn;
24
25    
26    void initState() {
27      super.initState();
28      _isOn = widget.isOn;
29    }
30
31    
32    Widget build(BuildContext context) {
33    return InkWell(
34        onTap: _toggleState,
35        child: Container(
36          width: 80.0,
37          height: 56.0,
38          color: _isOn ? Colors.red : Colors.green,
39        ),
40    );
41    }
42
43    //
44    // L'utilisateur appuie sur le bouton et nous voulons basculer
45    // son état interne
46    //
47    void _toggleState() {
48      _isOn = !_isOn;
49      if (mounted) {
50        setState(() {});
51      }
52      widget.onChange?.call(_isOn);
53    }
54}
55

Explication:

  • ligne 23: état interne de ce bouton
  • ligne 28: nous initialisons l'état, sur la base des informations fournies au Widget
  • ligne 34: lorsque nous tapons sur le bouton, nous appelons la méthode _toggleState
  • ligne 48: nous basculons simplement l'état interne du bouton,
  • lignes 49-51: nous demandons au bouton de reconstruire, en s'assurant (ligne 104) que le bouton est toujours là
  • ligne 52: 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:

1
2class TestPage extends StatefulWidget {
3  const TestPage({
4    super.key,
5  });
6
7  
8  _TestPageState createState() => _TestPageState();
9}
10
11class _TestPageState extends State<TestPage> {
12  
13  Widget build(BuildContext context) {
14    return Scaffold(
15      appBar: AppBar(title: const Text('2-state buttons'), centerTitle: true,),
16      body: Center(
17        child: Row(
18          mainAxisAlignment: MainAxisAlignment.center,
19          children: [
20            ButtonTwoStates(
21              index: 0,
22              isOn: false,
23              onChange: (bool isOn) {
24                debugPrint('My first button is on ? $isOn');
25              },
26            ),
27            const SizedBox(width: 8),
28            ButtonTwoStates(
29              index: 1,
30              isOn: false,
31              onChange: (bool isOn) {
32                debugPrint('My second button is on ? $isOn');
33              },
34            ),
35          ],
36        ),
37      ),
38    );
39  }
40}
41

Nous obtenons l'écran suivant:

example

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

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: 44)
  • nous fournissons aux widgets enfants de nouvelles valeurs (lignes: 18 et 26)

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

1
2class _ButtonTwoStatesState extends State<ButtonTwoStates> {
3  //
4  // Etat interne
5  //
6  late bool _isOn;
7
8  
9  void initState() {
10    super.initState();
11    _isOn = widget.isOn;
12  }
13
14  
15  void didUpdateWidget(ButtonTwoStates oldWidget) {
16    super.didUpdateWidget(oldWidget);
17    if (widget.isOn != oldWidget.isOn){
18      _isOn = widget.isOn;
19    }
20  }
21
22  
23  Widget build(BuildContext context) {
24    return InkWell(
25      onTap: _onTap,
26      child: Container(
27        width: 80.0,
28        height: 56.0,
29        color: _isOn ? Colors.red : Colors.green,
30      ),
31    );
32  }
33
34  //
35  // L'utilisateur tappe sur le bouton
36  //
37  void _onTap() {
38    _isOn = !_isOn;
39    if (mounted) {
40      setState(() {});
41    }
42    widget.onChange?.call(_isOn);
43  }
44}
45                

Grâce à la ligne #18, 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 {
  ...

  
  ButtonTwoStatesState createState() => ButtonTwoStatesState();
}
  
class ButtonTwoStatesState extends State<ButtonTwoStates> {
  ...
}
                

Maintenant, utilisons la GlobalKey dans notre TestPage...

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

Explication

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

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

1
2class ButtonTwoStatesState extends State<ButtonTwoStates> {
3  ...
4  //
5  // Reset the state
6  //
7  void resetState(){
8    _isOn = false;
9    if (mounted) {
10      setState(() {});
11    }
12  }
13}
14                

Explication

  • ligne 7: 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
2class ButtonTwoStatesControllers {
3  ButtonTwoStatesControllers({
4    required this.buttonsCount,
5    this.onChange,
6    int? selectedIndex,
7  })  : assert(buttonsCount > 0),
8        assert((selectedIndex == null ||
9            (selectedIndex >= 0 &&
10                selectedIndex < buttonsCount))) {
11    _selectedIndex = selectedIndex ?? -1;
12    _registeredButtons = List.generate(buttonsCount, (index) => null);
13  }
14
15  //
16  // Nombre total de boutons
17  //
18  final int buttonsCount;
19
20  //
21  // Routine à appeler quand la sélection change
22  //
23  final ValueChanged<int>? onChange;
24
25  // Index du bouton sélectionné
26  late int _selectedIndex;
27
28  // Liste des State des boutons enregistrés
29  late List<ButtonTwoStatesState?> _registeredButtons;
30
31  //
32  // Enregistre un bouton et retourne son état
33  // 
34  //
35  bool registerButton(ButtonTwoStatesState button) {
36    final int buttonIndex = button.index;
37
38    assert(buttonIndex >= 0 && buttonIndex < buttonsCount);
39    assert(_registeredButtons[buttonIndex] == null);
40
41    _registeredButtons[buttonIndex] = button;
42
43    return _selectedIndex == buttonIndex;
44  }
45
46  //
47  // Quand un bouton est sélectionné, on enregistre son index
48  // et nous désélectionnons les autres boutons
49  //
50  void setButtonSelected(int buttonIndex) {
51    assert(buttonIndex >= 0 && buttonIndex < buttonsCount);
52    assert(_registeredButtons[buttonIndex] != null);
53
54    if (_selectedIndex != buttonIndex) {
55      _selectedIndex = buttonIndex;
56      for (int index = 0; index < buttonsCount; index++) {
57        _registeredButtons[index]?.isOn(index == _selectedIndex);
58      }
59
60      // Notifie à propos du changement
61      onChange?.call(buttonIndex);
62    }
63  }
64}                
65                

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 35-44: lorsqu'un bouton s'enregistre, son State est enregistré dans le tableau _registeredButtons
  • lignes 50-63: 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
2class _TestPageState extends State<TestPage> {
3    final ButtonTwoStatesControllers controller = ButtonTwoStatesControllers(
4      buttonsCount: 2,
5      selectedIndex: 0,
6      onChange: (int selectedIndex){
7        // Do whatever needs to be done
8      },
9    );
10  
11    
12    Widget build(BuildContext context) {
13      return Scaffold(
14        appBar: AppBar(
15          title: const Text('2-state buttons'),
16          centerTitle: true,
17        ),
18        body: Center(
19          child: Row(
20            mainAxisAlignment: MainAxisAlignment.center,
21            children: [
22              ButtonTwoStates(
23                controller: controller,
24                index: 0,
25              ),
26              const SizedBox(width: 8),
27              ButtonTwoStates(
28                controller: controller,
29                index: 1,
30              ),
31            ],
32          ),
33        ),
34      );
35    }
36  }
37                

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 23 et 28).

ButtonTwoStatesState:

1
2
3class ButtonTwoStates extends StatefulWidget {
4  const ButtonTwoStates({
5    super.key,
6    required this.controller,
7    this.index,
8  });
9
10  final ButtonTwoStatesControllers controller;
11  final int? index;
12
13  
14  ButtonTwoStatesState createState() => ButtonTwoStatesState();
15}
16
17class ButtonTwoStatesState extends State<ButtonTwoStates> {
18  late bool _isOn;
19
20  
21  void initState() {
22    super.initState();
23    _isOn = widget.controller.registerButton(this);
24  }
25
26  
27  Widget build(BuildContext context) {
28    return InkWell(
29      onTap: _onTap,
30      child: Container(
31        width: 80.0,
32        height: 56.0,
33        color: _isOn ? Colors.red : Colors.green,
34      ),
35    );
36  }
37
38  //
39  // L'utilisateur appuie sur le bouton
40  //
41  void _onTap() {
42    widget.controller.setButtonSelected(index);
43  }
44
45  //
46  // Définit l'état
47  //
48  void isOn(bool isOn) {
49    if (_isOn != isOn) {
50      if (mounted) {
51        setState(() {
52          _isOn = isOn;
53        });
54      }
55    }
56  }
57
58  //
59  // Getter pour obtenir l'index
60  //
61  int get index => widget.index ?? -1;
62}               
63                

Explication

  • ligne 23: Au moment de l'initialisation, le bouton s'enregistre ("this") auprès du contrôleur qui renvoie l'état (= _isOn) de ce bouton.
  • ligne 42: Lorsque l'utilisateur appuie sur le bouton, il informe le contrôleur en fournissant son propre "index"
  • ligne 61: moyen pratique pour le bouton de donner son numéro d'index
  • lignes 48-56: 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
2class TestPage extends StatelessWidget {
3  const TestPage({super.key});
4
5  
6  Widget build(BuildContext context) {
7    return Provider<ButtonTwoStatesController>(
8      create: (BuildContext context) => ButtonTwoStatesController(
9        buttonsCount: 3,
10        selectedIndex: 0,
11        onChange: (int selectedIndex) {
12          // Do whatever needs to be done
13          print('selectedIndex: $selectedIndex');
14        },
15      ),
16      child: Scaffold(
17        appBar: AppBar(
18          title: const Text('2-state buttons'),
19          centerTitle: true,
20        ),
21        body: Center(
22          child: Row(
23            mainAxisAlignment: MainAxisAlignment.center,
24            children: const [
25              ButtonTwoStates(
26                index: 0,
27              ),
28              SizedBox(width: 8),
29              ButtonTwoStates(
30                index: 1,
31              ),
32              SizedBox(width: 8),
33              ButtonTwoStates(
34                index: 2,
35              ),
36            ],
37          ),
38        ),
39      ),
40    );
41  }
42}
43

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
2class ButtonTwoStates extends StatefulWidget {
3  const ButtonTwoStates({
4    super.key,
5    this.index,
6  });
7
8  final int? index;
9
10  
11  ButtonTwoStatesState createState() => ButtonTwoStatesState();
12}
13
14class ButtonTwoStatesState extends State<ButtonTwoStates> {
15  late bool _isOn;
16  late ButtonTwoStatesController controller;
17
18  
19  void initState() {
20    super.initState();
21    controller = Provider.of<ButtonTwoStatesController>(context, listen: false);
22
23    _isOn = controller.registerButton(this);
24  }
25
26  
27  Widget build(BuildContext context) {
28    return InkWell(
29      onTap: _onTap,
30      child: Container(
31        width: 80.0,
32        height: 56.0,
33        color: _isOn ? Colors.red : Colors.green,
34      ),
35    );
36  }
37
38  //
39  // L'utilisateur appuie sur le bouton
40  //
41  void _onTap() {
42    controller.setButtonSelected(index);
43  }
44
45  //
46  // Définit l'état interne
47  //
48  void isOn(bool isOn) {
49    if (_isOn != isOn) {
50      if (mounted) {
51        setState(() {
52          _isOn = isOn;
53        });
54      }
55    }
56  }
57
58  //
59  // Getter pour obtenir l'index du bouton
60  //
61  int get index => widget.index ?? -1;
62}              
63

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

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
2class ButtonTwoStatesControllerBloc {
3  //
4  // Stream pour gérer l'index du bouton sélectionné
5  //
6  final BehaviorSubject<int> _selectedButtonStreamController = BehaviorSubject<int>();
7  Stream<int> get outSelectedButtonIndex => _selectedButtonStreamController.stream;
8  Function(int) get inSelectedButtonIndex => _selectedButtonStreamController.sink.add;
9
10  //
11  // Nombre total de boutons
12  //
13  final int buttonsCount;
14
15  //
16  // Routine à appeler en cas de modification
17  //
18  final ValueChanged<int>? onChange;
19
20  //
21  // Constructeur
22  //
23  ButtonTwoStatesControllerBloc({
24    required this.buttonsCount,
25    this.onChange,
26    int? selectedIndex,
27  })  : assert(buttonsCount > 0),
28        assert((selectedIndex == null || (selectedIndex >= 0 && selectedIndex < buttonsCount))) {
29    //
30    // Propagation de l'index sélectionné (si mentionné)
31    //
32    if (selectedIndex != null) {
33      inSelectedButtonIndex(selectedIndex);
34    }
35
36    //
37    // Réagit aux changements et invoque le callback
38    //
39    outSelectedButtonIndex.listen((int index) => onChange?.call(index));
40  }
41
42  void dispose() {
43    _selectedButtonStreamController.close();
44  }
45}               
46

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
2class TestPage extends StatelessWidget {
3  const TestPage({super.key});
4
5  
6  Widget build(BuildContext context) {
7    return Provider<ButtonTwoStatesControllerBloc>(
8      create: (BuildContext context) => ButtonTwoStatesControllerBloc(
9        buttonsCount: 3,
10        selectedIndex: 0,
11        onChange: (int selectedIndex) {
12          // Do whatever needs to be done
13          debugPrint('selectedIndex: $selectedIndex');
14        },
15      ),
16      dispose: (BuildContext context, ButtonTwoStatesControllerBloc bloc) => bloc.dispose(),
17      child: Scaffold(
18        appBar: AppBar(
19          title: const Text('2-state buttons'),
20          centerTitle: true,
21        ),
22        body: Center(
23          child: Row(
24            mainAxisAlignment: MainAxisAlignment.center,
25            children: const [
26              ButtonTwoStates(
27                index: 0,
28              ),
29              SizedBox(width: 8),
30              ButtonTwoStates(
31                index: 1,
32              ),
33              SizedBox(width: 8),
34              ButtonTwoStates(
35                index: 2,
36              ),
37            ],
38          ),
39        ),
40      ),
41    );
42  }
43}
44

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

1
2class ButtonTwoStates extends StatelessWidget {
3  const ButtonTwoStates({
4    super.key,
5    required this.index,
6  });
7
8  final int index;
9
10  
11  Widget build(BuildContext context) {
12    final ButtonTwoStatesControllerBloc controllerBloc =
13        Provider.of<ButtonTwoStatesControllerBloc>(context, listen: false);
14
15    return StreamBuilder<int>(
16      stream: controllerBloc.outSelectedButtonIndex,
17      initialData: -1,
18      builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
19        final bool isOn = snapshot.data == index;
20
21        return InkWell(
22          onTap: () => controllerBloc.inSelectedButtonIndex(index),
23          child: Container(
24            width: 80.0,
25            height: 56.0,
26            color: isOn ? Colors.red : Colors.green,
27          ),
28        );
29      },
30    );
31  }
32}
33

Et enfin, pour les boutons:

  • lignes 12-13: 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 unValueListenableBuilder.

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
2class ButtonTwoStatesControllerListenable {
3  ButtonTwoStatesControllerListenable({
4    required this.buttonsCount,
5    this.onChange,
6    int? selectedIndex,
7  })  : assert(buttonsCount > 0),
8        assert((selectedIndex == null ||
9            (selectedIndex >= 0 &&
10                selectedIndex < buttonsCount))) {
11    //
12    // Préparation du tableau
13    //
14    _registeredButtons = List.generate(buttonsCount, (index) => null);
15
16    // Save the initial selected index (if any)
17    _selectedIndex = selectedIndex ?? -1;
18  }
19
20  //
21  // Liste des ValueNotifier liés aux boutons
22  //
23  late List<ValueNotifier<bool>?> _registeredButtons;
24
25  //
26  // Index sélectionné
27  //
28  int _selectedIndex = -1;
29
30  //
31  // Nombre total de boutons
32  //
33  final int buttonsCount;
34
35  //
36  // Routine à appeler à chaque variation de sélection
37  //
38  final ValueChanged<int>? onChange;
39
40  //
41  // Enregistrement des boutons, via leur index
42  //
43  ValueNotifier<bool> registerButton(int buttonIndex) {
44    assert(buttonIndex >= 0 && buttonIndex < buttonsCount);
45    assert(_registeredButtons[buttonIndex] == null);
46
47    ValueNotifier<bool> valueNotifier = _registeredButtons[buttonIndex] =
48        ValueNotifier<bool>(buttonIndex == _selectedIndex);
49
50    return valueNotifier;
51  }
52
53  //
54  // Quand un bouton est sélectionné, on enregistre l'information
55  // et l'on désélectionne les autres
56  //
57  void setButtonSelectedIndex(int buttonIndex) {
58    assert(buttonIndex >= 0 && buttonIndex < buttonsCount);
59    assert(_registeredButtons[buttonIndex] != null);
60
61    if (buttonIndex != _selectedIndex) {
62      if (_selectedIndex != -1) {
63        _registeredButtons[_selectedIndex]?.value = false;
64      }
65      _selectedIndex = buttonIndex;
66      _registeredButtons[_selectedIndex]?.value = true;
67
68      // appel du callback
69      onChange?.call(_selectedIndex);
70    }
71  }
72}                      
73

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

Les parties intéressantes sont:

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

Le simple fait de changer la valeur interne d'un ValueNotifier (lignes 63 et 68) 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
2class TestPage extends StatelessWidget {
3  const TestPage({super.key});
4
5  
6  Widget build(BuildContext context) {
7    return Provider<ButtonTwoStatesControllerListenable>(
8      create: (BuildContext context) => ButtonTwoStatesControllerListenable(
9        buttonsCount: 3,
10        selectedIndex: 0,
11        onChange: (int selectedIndex) {
12          // Do whatever needs to be done
13          debugPrint('selectedIndex: $selectedIndex');
14        },
15      ),
16      child: Scaffold(
17        appBar: AppBar(
18          title: const Text('2-state buttons'),
19          centerTitle: true,
20        ),
21        body: Center(
22          child: Row(
23            mainAxisAlignment: MainAxisAlignment.center,
24            children: const [
25              ButtonTwoStates(
26                index: 0,
27              ),
28              SizedBox(width: 8),
29              ButtonTwoStates(
30                index: 1,
31              ),
32              SizedBox(width: 8),
33              ButtonTwoStates(
34                index: 2,
35              ),
36            ],
37          ),
38        ),
39      ),
40    );
41  }
42}
43

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

1
2class ButtonTwoStates extends StatefulWidget {
3  const ButtonTwoStates({
4    super.key,
5    required this.index,
6  });
7
8  final int index;
9
10  
11  _ButtonTwoStatesState createState() => _ButtonTwoStatesState();
12}
13
14class _ButtonTwoStatesState extends State<ButtonTwoStates> {
15  late ValueNotifier<bool> valueNotifier;
16  late ButtonTwoStatesControllerListenable controllerListenable;
17
18  
19  void initState() {
20    super.initState();
21    controllerListenable = Provider.of<ButtonTwoStatesControllerListenable>(
22        context,
23        listen: false);
24
25    valueNotifier = controllerListenable.registerButton(widget.index);
26  }
27
28  
29  Widget build(BuildContext context) {
30    return ValueListenableBuilder<bool>(
31      valueListenable: valueNotifier,
32      builder: (BuildContext context, bool isOn, Widget? child) {
33        return InkWell(
34          onTap: () =>
35              controllerListenable.setButtonSelectedIndex(widget.index),
36          child: Container(
37            width: 80.0,
38            height: 56.0,
39            color: isOn ? Colors.red : Colors.green,
40          ),
41        );
42      },
43    );
44  }
45}
46

Explication:

  • lignes 21-23: on récupère le contrôleur
  • ligne 25: nous enregistrons ce bouton qui se traduit par l'obtention d'un ValueNotifier, généré par le contrôleur
  • ligne 30: nous utilisons un ValueListenableBuilder qui,
    • ligne 31: écoute les variations de la valeur de ValueNotifier
    • ligne 35: 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!

0 Commentaires
Soyez le premier à faire un commentaire...
© 2024 - Flutteris
email: info@flutteris.com

Flutteris



Quand l'excellence rencontre l'innovation
"votre satisfaction est notre priorité"

© 2024 - Flutteris
email: info@flutteris.com