How to interact with or have interactions between several StatefulWidgets?

Difficulty: Beginner

Introduction

Sometimes, it is necessary to get access to a StatefulWidget from another Widget to perform some operations or to link two or more StatefulWidgets to realize some business logic.

As an example, suppose that you have a 2-state button, which is either ‘on’ or ‘off’. This is easy to do.

However, if you now consider a series of such buttons where your business rule says: “only one of these buttons may be ‘on’ at a time". How do you achieve this? More complicated, imagine the case where these buttons would be part of different widget trees…

Several ways to handle this case exist and this article is going to present a couple of solutions. Some will be straightforward, some will be more complex and some will be better than others but the objective of this article is not to be exhaustive but to make you understand the general principle…

Let’s start…


Basic 2-state button

Let’s first write the basic code of a 2-state button. The code could look like the following:

 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> {
  //
  // Internal state
  //
  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,
      ),
    );
  }

  //
  // The user taps on the button and we want to toggle
  // its internal state
  //
  void _toggleState() {
    _isOn = !_isOn;
    if (mounted) {
      setState(() {});
    }
    widget.onChange?.call(_isOn);
  }
}

Explanation:

  • line 20: internal state of this button
  • line 25: we initialize the state, based on the information provided to the Widget
  • line 31: when we tap on the button, we call the _toggleState method
  • line 45: we simply toggle the internal state of the button,
  • lines 46-48: we ask the button to rebuild, making sure (line 104) that the button is still there
  • line 49: we invoke the callback (if mentioned)

Note about the ‘?.call()’

The following code: “widget.onChange?.call(_isOn);” is equivalent to

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


Basic page

Let’s now create a page that contains 2 of these buttons. The code could look like the following:

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');
              },
            ),
          ],
        ),
      ),
    );
  }
}

We obtain the following screen:

Basic Screen
Basic Screen

When the user taps on one of the buttons, the corresponding one’s color toggles.


How to create a relationship between the 2 buttons?

Let’s now assume that we need to have the following behavior: “when one button is ‘on’, the other is ‘off’". How could we achieve this?

At first thoughts, we could update the code of the page as follows:

 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(() {});
  }
}

However, despite this modification which seems logical, it does not work… why?

The problem comes from the ButtonTwoStates button which is a StatefulWidget.

If you refer to my past article on the notion of Widget - State - Context, you will remember that:

  • In a StatefulWidget, the Widget part is immutable (thus, never changes) and has to be seen as a kind of configuration
  • the initState() method is only run ONCE for the whole live of the StatefulWidget

So why doesn’t it work?

Simply because, by simply passing a “new value” to the ButtonState.isOn, its corresponding State will not change as the initState method will not be called a second time.

How can we solve this?

Still referring to my past article (Widget - State - Context), I did only mention one overriddable method: didUpdateWidget without explaining it…

didUpdateWidget

This method is invoked when the parent Widget rebuilds and provides different arguments to this Widget (of course, this Widget needs to keep the same Key and runtimeType).

In this case, Flutter calls the didUpdateWidget(oldWidget) method, providing the old Widget in argument.

It is therefore up to the State instance to take the appropriate actions based on the potential variations between the “old” Widget arguments and the “current” Widget arguments.

…and this is exactly what we need as:

  • we are rebuilding the parent Widget (line: 42)
  • we are providing the children widgets with new values (lines: 17 and 25)

Let’s apply the necessary modification to our _ButtonTwoStatesState source code as follows:

 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> {
  //
  // Internal state
  //
  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,
      ),
    );
  }

  //
  // The user taps on the button
  //
  void _onTap() {
    _isOn = !_isOn;
    if (mounted) {
      setState(() {});
    }
    widget.onChange?.call(_isOn);
  }
}

Thanks to line #17, the internal value “_isOn” is now updated and the button will be rebuilt using this new value.

OK, now it works, however, the whole business logic related to the buttons is taken on board by the TestPage, which works great with 2 buttons but if you now need to consider additional buttons, it might become a nightmare moreover if the buttons are not direct children of the very same parent!


Notion of GlobalKey

The GlobalKey class generates a unique key across the entire application. But the main interest of using a GlobalKey in the context of this article is that

The GlobalKey provides an access to the State of a StatefulWidget, using the currentState getter.

So, what does this mean?

Let’s first apply a change to the original source code of the ButtonTwoStates to make its State public. This is very easy: we simply remove the “_” sign from the name of the class “_ButtonTwoStatesState". This makes the class public. We then obtain:

class ButtonTwoStates extends StatefulWidget {

  ...

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

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

Now, let’s use the GlobalKey inside our 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();
  }
}

Explanation

  • lines 3-6: we generate an array of GlobalKey referring to ButtonTwoStatesState
  • line 20: we tell the first button which is its key
  • line 29: same for button 2
  • line 49: as we are going to see here below, we call the button which needs to be reset
  • line 48: we no longer need to fully rebuild the page.

So, the change to be applied to the ButtonTwoStatesState code is limited to adding a new method as follows:

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

Explanation

  • line 6: As you can see, the method is public (no “_” prefix), which is necessary to be accessed from the TestPage (as its source code does not reside in the same physical file)
  • this code simply resets the state of this button and proceeds with a rebuild.

As we can see, this solution also works however, here again, the whole business logic related to the buttons is taken on board by the TestPage


Another solution: a Controller

Another solution would consist in using a Controller class. In other words, this class would be used to control the logic and the buttons.

The idea of such a controller is that each button would tell the controller when it is selected. Then the controller will take the decision on the action(s) to take.

Here is a very basic implementation of such possible controller:

 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);
  }

  //
  // Total number of buttons
  //
  final int buttonsCount;

  //
  // Callback to be invoked when the selection changes
  //
  final ValueChanged<int> onChange;

  // Index of the currently selected button
  int _selectedIndex;

  // List of registered buttons
  List<ButtonTwoStatesState> _registeredButtons;

  //
  // Registers a button and returns whether
  // the button is selected
  //
  bool registerButton(ButtonTwoStatesState button) {
    final int buttonIndex = button.index;

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

    _registeredButtons[buttonIndex] = button;

    return _selectedIndex == buttonIndex;
  }

  //
  // When a button is selected, we record the information
  // and unselect the others
  //
  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);
    }
  }
}

I hope that the code is self-explanatory:

  • the assert are there to ensure during the development time that the boundaries are respected
  • lines #32-40: when a button registers itself, its State is recorded in the _registeredButtons array
  • lines #47-60: when we change the selected button, we inform the registered buttons about their new state

Let’s have a look at the modifications to be applied to both TestPage and to 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){
      // Do whatever needs to be done
    },
  );

  @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,
            ),
          ],
        ),
      ),
    );
  }
}

Explanation

The TestPage simply initializes the ButtonTwoStatesController, mentions the number of buttons to consider, which one is selected and a callback method to be invoked when the button selection changes.

This controller is passed in arguments to each button (lines 22 & 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,
      ),
    );
  }

  //
  // The user taps on the button
  //
  void _onTap() {
    widget.controller.setButtonSelected(index);
  }

  //
  // Sets the state
  //
  void isOn(bool isOn) {
    if (_isOn != isOn) {
      if (mounted) {
        setState(() {
          _isOn = isOn;
        });
      }
    }
  }

  //
  // Getter for the index
  //
  int get index => widget.index ?? -1;
}

Explanation

  • line #22: At initialization time, the button registers itself ("this") against the controller which returns the status (= _isOn) of this button.
  • line #41: When the user taps on the button, it informs the controller by providing its own “index
  • line #60: Convenient way for the Button to give its index number
  • lines #47-55: Used by the controller to inform the button about its status.

This solution is already much better as the business logic related to the buttons has been externalized to the controller (even for the buttons themselves).

It is also much easier to add a button to the page.

Even if this solution works, this is, however, still not ideal as in a real application, buttons will most probably not all be inserted into the Widget tree by the same parent.


Provider

In order to solve this issue, let’s make the controller available to any Widgets, part of the TestPage. To do this, let’s use a Provider.

Let’s look at the changes to apply:

 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) {
          // Do whatever needs to be done
          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,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

As we can see, the Page may now become a StatelessWidget and we do not need to pass the controller to each button.

 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,
      ),
    );
  }

  //
  // The user taps on the button
  //
  void _onTap() {
    controller.setButtonSelected(index);
  }

  //
  // Sets the state
  //
  void isOn(bool isOn) {
    if (_isOn != isOn) {
      if (mounted) {
        setState(() {
          _isOn = isOn;
        });
      }
    }
  }

  //
  // Getter for the index
  //
  int get index => widget.index ?? -1;
}

Now, it is up to the button to retrieve the controller, via a call to the Provider (line 20).

Advantages of this solution:

  • the TestPage is now a StatelessWidget
  • the buttons may be anywhere in the Widget tree, rooted by the TestPage, and this will work.

It is much better, however, we are exposing the ButtonTwoStatesState of the buttons to the outside world… isn’t there any other solution?


Reactive

Another approach would be to let the buttons react upon any changes. This could be achieved in different ways. Let’s have a look at some of them…

BLoC

Yes, I know… again the notion of BLoC and… why not? (this time I will use the Provider, to change a bit).

If you remember my articles on the topic (see here and here), we are using Streams.

The code of such BLoC could look like the following:

 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 to handle the index of the selected button
  //
  BehaviorSubject<int> _selectedButtonStreamController = BehaviorSubject<int>();
  Stream<int> get outSelectedButtonIndex => _selectedButtonStreamController.stream;
  Function(int) get inSelectedButtonIndex => _selectedButtonStreamController.sink.add;

  //
  // Total number of buttons
  //
  final int buttonsCount;

  //
  // Callback to be invoked when the selection changes
  //
  final ValueChanged<int> onChange;

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

    //
    // Propagate the current selected index (if any)
    //
    if (selectedIndex != null) {
      inSelectedButtonIndex(selectedIndex);
    }

    //
    // Listen to changes to emit to invoke the callback
    //
    outSelectedButtonIndex.listen((int index) => onChange?.call(index));
  }

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

As you can see, at initialization time:

  • if we provide a valid selectedIndex, we send it to the stream
  • we start listening to the stream, which will be used by the buttons (see later) and we invoke the “onChange” callback (line #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) {
          // Do whatever needs to be done
          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,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

As regards the TestPage, we simply initialize and inject the ButtonTwoStatesControllerBloc.

 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,
          ),
        );
      },
    );
  }
}

And finally, for the buttons:

  • lines #11-12: we retrieve the ButtonTwoStatesControllerBloc
  • we use a StreamBuilder which:
    • listens to the stream (line 15)
    • rebuilds when a new index is emitted
    • injects the index of the button when the latter is tapped (line 21)

This solution also works but all buttons are systematically rebuilt… which has impacts on the performance.

To limit the number of rebuilds, we could adapt the BLoC to only emit events related to changes (on => off, off => on) but we would then need to link the new state to the button index.

Of course, this has a price…

  • we complexify the BLoC
    • a new stream controller (if we really want to stick to the theory: BLoC => streams only)
    • we need to maintain the state of the currently selected button

…but it is not difficult to do. This is a trade-off to do.


ValueNotifier

We could also use a ValueNotifier together with a ValueListenableBuilder.

ValueNotifier

A ValueNotifier listens to variations of an internal value. When a variation happens, it notifies everything that is listening to it. It implements a ValueListenable.

ValueListenableBuilder

A ValueListenableBuilder is a Widget which listens to notifications emitted by a ValueListenable, and rebuilds providing the emitted value to its builder method.

The solution, based on these 2 notions, consists of buttons, registering themselves to a controller, which would tell each button which ValueNotifier to listen to.

Such a solution could be written as follows:

 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 {
  //
  // List of the buttons' ValueNotifier
  //
  List<ValueNotifier<bool>> _registeredButtons;

  //
  // Current selected index
  //
  int _selectedIndex = -1;

  //
  // Total number of buttons
  //
  final int buttonsCount;

  //
  // Callback to be invoked when the selection changes
  //
  final ValueChanged<int> onChange;

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

    //
    // Prepare the array
    //
    _registeredButtons = List.generate(buttonsCount, (index) => null);

    // Save the initial selected index (if any)
    _selectedIndex = selectedIndex ?? -1;
  }

  //
  // Registers a button via its 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;
  }

  //
  // When a button is selected, we record the information
  // and unselect the others
  //
  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;

      // Invoke the callback
      onChange?.call(_selectedIndex);
    }
  }
}

This code is very similar to our version of the first controller, earlier in the article.

The interesting parts are:

  • lines #48-49: we initialize a ValueNotifier<bool> to handle the state of the button of a certain index.
  • line #64: if there was a previously selected button, we mark it as not selected (= false)
  • line #67: we mark the currently selected index

The simple fact of changing the inner value of a ValueNotifier (lines #64 & 67) will result in having the ValueListenableBuilder of the corresponding buttons to rebuild (see later)

As regards the TestPage, we only inject the new controller, as follows:

 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) {
          // Do whatever needs to be done
          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,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

The code related to the button has to be changed as follows:

 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,
          ),
        );
      },
    );
  }
}

Explanation:

  • line #20: we retrieve the controller
  • line #21: we register this button which results in getting a ValueNotifier, generated by the controller
  • line #27: we use a ValueListenableBuilder which,
  • line #28: listens to variations of the value of the ValueNotifier
  • line #31: when the user taps the button, we notify the controller

That’s it. This solution also works.


Conclusion

When we start developing in Flutter, the notion of StatefulWidget is not that easy to master, and when comes the moment to apply rules between multiple instances, it is very common to wonder how to make things done.

This article tries to give some hints through the basic example of 2-state buttons.

Dozens of other solutions exist and there is NO one single and best approach. This highly depends on your use case.

I hope that this article at least gives you some insight on this topic.

Stay tuned for new articles and as usual, I wish you happy coding!