Flutter 2. ValueNotifier, AnimatedBuilder e o ValueListenableBuilder

Este artigo é a parte [part not set] de 2 na série Flutter

Este artigo continua a discussão do artigo anterior agora implementando a responsividade com o ValueNotifier e posteriormente aplicando o AnimatedBuilder e o ValueListenableBuilder, o que permite reconstruir apenas a fração da tela alterada pela modificação.

O código base está disponível no arquivo zip counter-01.zip. Caso deseje iniciar por ele basta criar um novo projeto flutter e copiar a pasta lib do arquivo zip para a pasta lib do novo projeto.

ValueNotifier

A classe ValueNotifier herda da classe ChangeNotifier e implementa o ValueListenable internamente. O construtor da classe ValueNotifier inicia armazenando o valor passado em this._value, o atributo interno para armazenar o valor a ser gerenciado, linha 8 no código abaixo. O getter value, linha 25 à 33 abaixo, fica responsável por alterar o valor do _value e dispara um notifyListeners() ao final, notificando a todos os ouvintes uma alteração no seu atributo interno.

/// A [ChangeNotifier] that holds a single value.
///
/// When [value] is replaced with something that is not equal to the old
/// value as evaluated by the equality operator ==, this class notifies its
/// listeners.
class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> {
  /// Creates a [ChangeNotifier] that wraps this value.
  ValueNotifier(this._value) {
    if (kFlutterMemoryAllocationsEnabled) {
      MemoryAllocations.instance.dispatchObjectCreated(
        library: _flutterFoundationLibrary,
        className: '$ValueNotifier',
        object: this,
      );
    }
    _creationDispatched = true;
  }

  /// The current value stored in this notifier.
  ///
  /// When the value is replaced with something that is not equal to the old
  /// value as evaluated by the equality operator ==, this class notifies its
  /// listeners.
  @override
  T get value => _value;
  T _value;
  set value(T newValue) {
    if (_value == newValue) {
      return;
    }
    _value = newValue;
    notifyListeners();
  }

  @override
  String toString() => '${describeIdentity(this)}($value)';
}

Desta forma o emprego do ValueNotifier dispensa as chamadas ao notifyListeners(), mas impõe que o valor escutado seja o seu _value, acessível pelo método getter.

Implantando o ValueNotifier no app_settings.dart

A classe AppSettings não necessita mais estender o ChangeNotifier, e a declaração dos atributos _counter, _selection e _state se tornam ValueNotifier como mostrado nas linhas abaixo.

...
class AppSettings {
  ValueNotifier<int> counter$ = ValueNotifier(0);
  ValueNotifier<bool> selection$ = ValueNotifier(false);
  ValueNotifier<ThreeState> state$ = ValueNotifier(ThreeState.first);
...

Como será necessário acessar os métodos addListener destas variáveis em home_page.dart, estas não poderão mais serem privadas. É conveniente adicionar um “$” ao final do nome destas variáveis, uma vez que os getter destas serão úteis para acessar os seus values, já que estas agora são ValueNotifier.

...
  int get counter => counter$.value;

  bool get selection => selection$.value;

  ThreeState get state => state$.value;
...

Como pode observar acima os métodos de alteração agora devem mudar os valores em ‘.value‘ de cada atributo, e os notifyListeners() não são mais necessários, visto que as alterações nos valores dos _value‘s são gerenciados pelo ValueNotifier. Desta forma os métodos de alteração dos atributos ficam assim:

...  
  void increment() => counter$.value++;

  void toggleSelection() => selection$.value = !selection$.value;

    switch (state$.value) {
      case ThreeState.first:
        state$.value = ThreeState.second;
        break;
      case ThreeState.second:
        state$.value = ThreeState.third;
        break;
      case ThreeState.third:
        state$.value = ThreeState.first;
        break;
    }
...

O código completo do novo app_settings.dart é apresentado a seguir:

import 'package:flutter/material.dart';

enum ThreeState { first, second, third }

class AppSettings {
  ValueNotifier<int> counter$ = ValueNotifier(0);
  ValueNotifier<bool> selection$ = ValueNotifier(false);
  ValueNotifier<ThreeState> state$ = ValueNotifier(ThreeState.first);

  int get counter => counter$.value;

  bool get selection => selection$.value;

  ThreeState get state => state$.value;

  void increment() => counter$.value++;

  void toggleSelection() => selection$.value = !selection$.value;

  void toggleState() {
    switch (state$.value) {
      case ThreeState.first:
        state$.value = ThreeState.second;
        break;
      case ThreeState.second:
        state$.value = ThreeState.third;
        break;
      case ThreeState.third:
        state$.value = ThreeState.first;
        break;
    }
  }
}

As explicitações dos tipos nas linhas 6 à 8 não são necessárias, e as fiz apenas para deixar mais explicito os tipos no código.

Aplicando ao HomePage

A mudança na classe HomePage ser apenas no initState. O settings.addListener será removido e três novos addListener serão adicionados, visto que agora as alterações ouvidas são ativadas por settings.counter$.addListener, settings.selection$.addListener e o settings.state$.addListener. Bom, isto resulta na alteração do método initState para o código a seguir:

...
  @override
  void initState() {
    super.initState();
    settings.counter$.addListener(() {
      setState(() {});
    });
    settings.selection$.addListener(() {
      setState(() {});
    });
    settings.state$.addListener(() {
      setState(() {});
    });
  }
...

Devido aos getters criados na classe AppSettings o restante do código permanece inalterado e neste ponto o código deve retornar a funcionar.

O código completo do home_page.dart é apresentado a seguir.

import 'package:flutter/material.dart';

import '../settings/app_settings.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key, required this.title});

  final String title;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final settings = AppSettings();

  @override
  void initState() {
    super.initState();
    settings.counter$.addListener(() {
      setState(() {});
    });
    settings.selection$.addListener(() {
      setState(() {});
    });
    settings.state$.addListener(() {
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.increment,
                  child: const Icon(Icons.add),
                ),
                const SizedBox(width: 30),
                Text(
                  'Counter: ${settings.counter}',
                ),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.toggleSelection,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                Text(
                  'Selection: ${settings.selection}',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.toggleState,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                Text(
                  'State: ${settings.state.name}',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Este código está disponível em counter-02.zip.

Além de uma certa simplicidade, da forma como está este código não apresenta nenhuma grande vantagem. No entanto, este possui uma grande diferença com respeito ao código anterior: neste momento as alterações foram individualizadas, gerando notificações independentes a cada atributo do AppSettings. Isto pode ser bem aproveitado com o emprego do widget AnimatedBuilder.

AnimatedBuilder

Embora não seja focado para o uso proposto aqui, este builder pode ser empregado para renderizar a porção da tela que foi alterada, mantendo o restante da tela inalterada.

Inserindo no HomePage

Neste caso não necessitará mais de sobrescrever o método initState na classe HomePage, de forma que este pode ser totalmente removido do código. O Listener será passado pelo atributo animation do AnimatedBuilder, fazendo-o renderizar a porção da tela em particular.

O passo seguinte é adicionar os AnimatedBuilder onde estão os widgets Text() com as mensagens dos valores das variáveis. Estes widgets serão usados como retorno do builders dos AnimatedBuilders. O código para o atributo settings.counter$ do settings é apresentado a seguir:

...
                AnimatedBuilder(
                  animation: settings.counter$,
                  builder: (context, child) {
                    return Text(
                      'Counter: ${settings.counter}',
                    );
                  },
                ),
...

O atributo animation, do AnimatedBuilders, pede um Listenable que neste caso é o settings.counter$. O AnimatedBuilders possui um atributo child que se for passado pode ser empregado pelo builder para construir o seu retorno. No entanto, como o child é declarado fora do builder ele não será renderizado novamente, mantendo o mesmo conteúdo da primeira renderização, o que não serve para o propósito aqui.

Agora é repetir esta mudança para os demais listenables: settings.selection$ e settings.state$. O código final é apresentado a seguir:

import 'package:flutter/material.dart';

import '../settings/app_settings.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key, required this.title});

  final String title;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final settings = AppSettings();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.increment,
                  child: const Icon(Icons.add),
                ),
                const SizedBox(width: 30),
                AnimatedBuilder(
                  animation: settings.counter$,
                  builder: (context, child) {
                    return Text(
                      'Counter: ${settings.counter}',
                    );
                  },
                ),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.toggleSelection,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                AnimatedBuilder(
                  animation: settings.selection$,
                  builder: (context, child) {
                    return Text(
                      'Selection: ${settings.selection}',
                      style: Theme.of(context).textTheme.titleMedium,
                    );
                  },
                ),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.toggleState,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                AnimatedBuilder(
                  animation: settings.state$,
                  builder: (context, child) {
                    return Text(
                      'State: ${settings.state.name}',
                      style: Theme.of(context).textTheme.titleMedium,
                    );
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Do AnimatedBuilder o que de fato está sendo empregado é a sua capacidade de renderizar apenas uma porção da tela. O foco do AnimatedBuilder é para widgets mais complexos que desejam incluir uma animações: AnimatedBuilder-class.

ValueListenableBuilder

O ValueListenableBuilder é o mais adequado para o problema aqui apresentado. Como o AnimatedBilder ele irá renderizar apenas uma porção da tela quando um Value de um Listenable for alterado.

Ajustando o HomePage Novamente

Para inserir o ValueListenableBuilder ao código siga as instruções a seguir:

  • primeiro substitua os AnimatedBuilder por ValueListenableBuilder.
  • em seguida troque o atributo animation por valueListenable. Seu argumento é o mesmo.
  • por fim, adicione o value como o segundo argumento do builder.

Repita isto para cada elemento do AnimatedBuilder e o código já estará funcional novamente. O home_page.dart final está abaixo para verificação.

import 'package:flutter/material.dart';

import '../settings/app_settings.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key, required this.title});

  final String title;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final settings = AppSettings();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.increment,
                  child: const Icon(Icons.add),
                ),
                const SizedBox(width: 30),
                ValueListenableBuilder<int>(
                    valueListenable: settings.counter$,
                    builder: (context, value, child) {
                      return Text(
                        'Counter: ${settings.counter}',
                      );
                    }),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.toggleSelection,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                ValueListenableBuilder<bool>(
                  valueListenable: settings.selection$,
                  builder: (context, value, child) {
                    return Text(
                      'Selection: ${settings.selection}',
                      style: Theme.of(context).textTheme.titleMedium,
                    );
                  },
                ),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: settings.toggleState,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                ValueListenableBuilder<ThreeState>(
                  valueListenable: settings.state$,
                  builder: (context, value, child) {
                    return Text(
                      'State: ${settings.state.name}',
                      style: Theme.of(context).textTheme.titleMedium,
                    );
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

As explicitações de tipo nas linhas 35, 53 e 73, nas chamadas dos ValueListenableBuilder não são necessárias, as coloquei apenas para deixar mais evidente o tipo empregado.

Conclusões

Este artigo conclui a ideia inicial desta série que era cobrir o ChangeNotifier, e sua extensão ValueNotifier, bem como o AnimatedBuilder e o ValueListenableBuilder.

Embora tenha concordado que o ValueListenableBuilder é mais adequado para a maioria de minhas aplicações, recentemente encontrei uma situação em que o AnimatedBuilder foi mais adequado. O problema ocorreu quanto houve a necessidade de ouvir vários Listenable para renderizar uma barra de status no aplicativo. O problema é que o ValueListenableBuilder aceita apenas um ValueListenable como argumento no atributo valueListenable sem a possibilidade de passar uma lista dos Listenable. Já o AnimatedBuilder aceita um Listenable no atributo animation, e a classe Listenable possui o construtor nomeado Listenable.merge que permite passar uma lista de Listenable, conveniente para ouvir a mais de um Listenable ao mesmo tempo.

No próximo artigo pretendo estender os atributos do settings para uma página de configuração e tratá-los como elementos de configuração do aplicativo de fato.

Deixe um comentário

Esse site utiliza o Akismet para reduzir spam. Aprenda como seus dados de comentários são processados.