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

Este artigo é baseado no material na série de vídeos ‘Flutter ValueNotifier‘, do canal Fluterrando, onde exploro alguns tópicos sobre reatividade e devo me estendendo em alguns pontos que forem mais de meu interesse. No primeiro momento pretendo explorar a Reatividade com o ChangeNotifier e, na sequência, o ValueNotifier, aplicando a um exemplo com três reatividades de tipos diferentes. Num futuro pretendo integrar estes elementos como atributos de configuração do aplicativo.

O objetivo não é desenvolver um aplicativo específico, mas apenas explorar algumas características do Flutter e alguns de seus pacotes. Neste primeiro momento vou apenas cria o projeto base para o desenvolvimento da série, concluindo com o ChangeNotifier para monitorar as alterações e redesenhar a tela.

Projeto Inicial

Para inciar apenas vá para a sua pasta de trabalhos de sua preferência e crie um novo projeto Flutter de nome new_counter. Em seguida entre na pasta criada e abra o VSCode:

Bash

alves@arabel:~$ flutter create settings
alves@arabel:~$ cd settings
alves@arabel:~$ code .

Refatorando o Código

Na sequência irei refatorar o código para deixá-lo mais organizado. No entanto se desejar o código refatorado está disponível no link counter-00.zip, de forma que pode descompactado na parta lib e pular para a seção Reatividade com ChangeNotifier.

Sendo bastante direto vou refatorar o código do contador padrão do Flutter para deixá-lo um pouco mais organizado. Crie uma pasta lib/pages e um arquivo home_page.dart nela. Em seguida transfira a classe MyHomePage para este arquivo:

import 'package:flutter/material.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> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Aproveite e troque todas as ocorrências de MyHomePage para apenas HomePage.

Em seguida crie um arquivo lib/app_counter.dart e transfira para ele a classe MyApp, aqui renomeada para AppCounter:

import 'package:flutter/material.dart';

import 'pages/home_page.dart';

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

Por fim o main.dart ficará assim:

import 'package:flutter/material.dart';

import 'app_counter.dart';

void main() {
  runApp(const AppCounter());
}

O código refatorado fica organizado na pasta lib do projeto na forma:

Bash

alves@arabel:lib$ tree
.
├── app_counter.dart
├── main.dart
└── pages
└── home_page.dart

Ajustes Finais

Para melhor atender os objetivos desta série é conveniente efetuar algumas mudanças no home_page.dart adicionando um variável booleana e uma de três estados, através de um enum. O código final é apresentado a seguir, e as alterações são descritas na sequência.

import 'package:flutter/material.dart';

enum ThreeState { first, second, third }

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

  final String title;

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

class _HomePageState extends State<HomePage> {
  int _counter = 0;
  bool _selection = true;
  ThreeState _state = ThreeState.first;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  void _toggleSelection() {
    setState(() {
      _selection = !_selection;
    });
  }

  void _toggleState() {
    setState(() {
      switch (_state) {
        case ThreeState.first:
          _state = ThreeState.second;
          break;
        case ThreeState.second:
          _state = ThreeState.third;
          break;
        case ThreeState.third:
          _state = ThreeState.first;
          break;
      }
    });
  }

  @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: _incrementCounter,
                  child: const Icon(Icons.add),
                ),
                const SizedBox(width: 30),
                Text(
                  'Counter: $_counter',
                ),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _toggleSelection,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                Text(
                  'Selection: $_selection',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _toggleState,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                Text(
                  'State: ${_state.name}',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

A primeira mudança foi a adição do ThreeState na linha 3, para declarar um variável de três estados: {first, second, third}. Nas linhas 16 e 17 são adicionadas as declarações das variáveis internas _selection, uma booleana, e _state, uma ThreeState.

Em adição a função interna _incrementCounter, duas novas são adicionadas para alterar os valores das novas variáveis. São elas:

  • _toggleSelection, linhas 25 à 29: para alternar o estado de _selection entre true e false;
  • _toggleState, linhas 31 à 45: para alternar entre o estado do _state entre os três estados possíveis de ThreeState.

Para apresentar os valores destas variáveis reestruturei a apresentação para que todas ficassem com a mesma estrutura, removendo o widget FloatingActionButton e acrescentando um ElevatedButton e um Text, separados em três Rows para apresentar os resultados.

A tela final do aplicativo é apresentado na figura ao lado. Cada botão se encarrega de alterar o estado apresentado ao lado, e estes são atualizado na tela. A tela é redesenhada a cada vez que a execução do código passa por um dos setState, das linhas 20, 26 e 32, acionadas pelos respectivos botões.

Separando o Controller

O próximo passo será criar uma classe para controlar os elementos do aplicativo e assim separar as regras de controle de estado da widget principal. Isto também vai facilitar as integrações que pretendo fazer mais adiante, além desta separação deixar o código mais organizado.

Inicialmente vou criar um arquivo app_settings.dart na pasta lib/settings. Neste arquivo será criado a classe AppSettings com os três atributos _counter, _selection e _state. O conteúdo deste arquivo é apresentado a seguir:

enum ThreeState { first, second, third }

class AppSettings {
  int _counter = 0;
  bool _selection = false;
  ThreeState _state = ThreeState.first;

  int get counter => _counter;

  bool get selection => _selection;

  ThreeState get state => _state;

  void increment() => _counter++;

  void toggleSelection() => _selection = !_selection;

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

Observe que o enum ThreeState é migrado para este arquivo, visto que a ideia é passar todo o controle dos elementos que definem a tela para esta classe, de forma que este será importado pelo home_page.dart. Todos as funções para alteração e acesso às três variáveis de controle foram implementadas na classe:

  • increment(), linha 14: incrementa o _counter;
  • toggleSelection(), linha 16: altera estado do _selection;
  • toggleState(), linhas 18 à 30: altera estado do _state.

Os respectivos getters também foram criados para recuperar os valores dos atributos internos da classe.

O próximo passo é incorporar a classe AppSettings ao widget HomePage.

Inicialmente substitua a declaração do enum da linha 3, no home_page.dart, pelo import:

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

Na sequência substitua as declarações das linhas 15 à 17 pela declaração de uma variável settings:

final settings = AppSettings();

Por fim basta alterar as implementações internas às funções _incrementCounter, _toggleSelection e _toggleState para chamar as funções apropriadas da classe, sem remover os setStates. O resultado 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();

  void _incrementCounter() {
    setState(() {
      settings.increment();
    });
  }

  void _toggleSelection() {
    setState(() {
      settings.toggleSelection();
    });
  }

  void _toggleState() {
    setState(() {
      settings.toggleState();
    });
  }

  @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: _incrementCounter,
                  child: const Icon(Icons.add),
                ),
                const SizedBox(width: 30),
                Text(
                  'Counter: ${settings.counter}',
                ),
              ],
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _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: _toggleState,
                  child: const Text('Toggle'),
                ),
                const SizedBox(width: 30),
                Text(
                  'State: ${settings.state.name}',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Ainda terá de trocar as strings das linhas 54, 68 e 83 para chamarem o elemento correto do settings. Essencialmente adicionar um ‘settings.‘ à frente de cada variável.

Neste momento o código deve retornar a funcionar, com os três botões alterando e redesenhando a tela. Caso tenha alguma dificuldade pode baixar os arquivos da pasta lib do link: counter-00.zip.

Reatividade com ChangeNotifier

A ideia agora é ativar a reatividade com o ChangeNotifier, para que sempre que um dos atributos da classe AppSettings for modificada a tela seja redesenhada. Para isto vamos iniciar pelas alterações ao app_settings.dart, alterando a classe AppSettings para que ela gere uma notificação de alteração de seus atributos.

Adicionado o ChangeNotifier ao AppSettings

Primeiro faça com que a classe AppSettings estenda a classe ChangeNotifier, alterando a sua declaração para:

class AppSettings extends ChangeNotifier{
...

Isto vai te obrigar a importar o package:flutter/material.dart, onde o ChangeNotifier é declarado. Para terminar as alterações aqui basta adicionar um notifyListeners() ao final de cada função de alteração dos atributos, para que uma notificação seja criada para quem estiver ouvindo. Ao final o app_settings.dart vai ficar como segue:

import 'package:flutter/material.dart';

enum ThreeState { first, second, third }

class AppSettings extends ChangeNotifier {
  int _counter = 0;
  bool _selection = false;
  ThreeState _state = ThreeState.first;

  int get counter => _counter;

  bool get selection => _selection;

  ThreeState get state => _state;

  void increment() {
    _counter++;
    notifyListeners();
  }

  void toggleSelection() {
    _selection = !_selection;
    notifyListeners();
  }

  void toggleState() {
    switch (_state) {
      case ThreeState.first:
        _state = ThreeState.second;
        break;
      case ThreeState.second:
        _state = ThreeState.third;
        break;
      case ThreeState.third:
        _state = ThreeState.first;
        break;
    }
    notifyListeners();
  }
}

Agora é o momento de modificar o home_page.dart.

Colocar o HomePage em Escuta

No arquivo home_page.dart as linhas 6 à 14, as declarações e as funções _incrementCounter, _toggleSelection e _toggleState, pois elas não serão mais necessárias. Depois adicione a declaração da variável settings e a função initState() à classe _HomePageState com o código a seguir:

...
  final settings = AppSettings();

  @override
  void initState() {
    super.initState();
    settings.addListener(() {
      setState(() {});
    });
  }
...

O método addListener é herdado do ChangeNotifier, e é ele que vai deixar o HomePage na escuta por uma notificação enviada pelos notifyListeners() adicionados na classe AppSettings.

Para terminar, basta substituir as chamadas aos métodos, removidos do _HomePageState, pelos métodos correspondentes do settings: settings.increment, settings.toggleSelection e settings.toggleState, respectivamente.

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
  void initState() {
    super.initState();
    settings.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,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

O código completo está no link counter-01.zip. Por motivo funcionais o nome da pasta base mudou para settings no lugar de counter e pode aparecer trocado em alguns arquivos zip.

Conclusões

Após estas alterações o aplicativo deve retornar a funcionar, agora redesenhando a tela a cada notifyListeners() enviados pelos métodos de alterações dos atributos do settings, gerados ao se pressionar os botões.

No próximo texto vou refazer isto com o ValueNotifier e empregar o AnimatedBuilder e o ValueListenableBuilder, o que permite redesenhar apenas a parte alterada da tela. Isto pode ser bem útil para aplicativos mais elaborados.