Flutter: Isolando estados com Mixins

Carlos Costa • 03/11/2024 Nesse tutorial, vamos explorar como utilizar mixins para isolar o estado de um widget em Flutter, essa técnica pode ser um recurso extremamente útil para manter o código mais organizado e fácil de testar.

Introdução


Primeiro vamos entender o que são Mixins no Dart.

No Dart, um mixin é uma forma de reutilizar código em várias classes sem precisar de herança. Mixins permitem definir métodos e propriedades em uma estrutura semelhante a uma classe que pode então ser “misturada” com outras classes.

Exemplo:

mixin Flyable {
  void fly() {
    print('Flying');
  }
}

class Bird with Flyable {
  void fly() {
    print('Bird is flying');
  }
}

void main() {
  Bird bird = Bird();
  bird.fly();
}

Nesse exemplo, a classe Bird usa o mixin Flyable para adicionar o método fly ao objeto bird.

StatefulWidget


Vamos criar um simple contador para ilustrar o nosso exemplo:

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

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

  void _decrementCounter() {
    setState(() {
      _counter--;
    });
  }

  void _resetCounter() {
    setState(() {
      _counter = 0;
    });
  }

  void _doubleCounter() {
    setState(() {
      _counter *= 2;
    });
  }

  void _halfCounter() {
    setState(() {
      _counter ~/= 2;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Text(
            'Count: $_counter',
          ),
          FilledButton(onPressed: _incrementCounter, child: const Text('Increment')),
          FilledButton(onPressed: _decrementCounter, child: const Text('Decrement')),
          FilledButton(onPressed: _resetCounter, child: const Text('Reset')),
          FilledButton(onPressed: _doubleCounter, child: const Text('Double')),
          FilledButton(onPressed: _halfCounter, child: const Text('Half')),
        ],
      ),
    );
  }
}

Aqui temos um StatefulWidget chamado Counter que possui um estado interno _counter e métodos para manipular esse estado. Apesar desse código ser bem simples tem duas responsabilidades importantes:

  • A lógica do contador: incrementar, decrementar, resetar e modificar o valor do contador.
  • A renderização do contador: exibir o contador e os botões para manipular o estado.

Considerando essas duas responsabilidades, podemos criar um mixin chamado CounterController que será responsável por lidar com a lógica do contador.

Controller


Vamos refatorar o código para ter a seguinte estrutura:

📂 counter — 📄 counter.dart — 📄 counter.controller.dart

  • counter: widget que renderiza o contador.
  • counter.controller: mixin que lida com a lógica do contador.

Nosso counter.controller será escrito da seguinte forma:

mixin CounterController<T extends StatefulWidget> on State<T> {
  int counter = 0;

  void incrementCounter() {
    counter++;
  }

  void decrementCounter() {
    counter--;
  }

  void resetCounter() {
    counter = 0;
  }

  void doubleCounter() {
    counter *= 2;
  }

  void halfCounter() {
    counter ~/= 2;
  }
}

Nosso CounterController foi escrito da forma mais “pura” possível, sem usar setState ou context. Isso futuramente vai facilitar os nossos testes unitários.

O tipo T é uma restrição para que o CounterController seja aplicado apenas a classes que herdam de StatefulWidget.

on State<T> permite que o mixin seja aplicado apenas a classes que herdam de State<T> e fornece acesso a todas as propriedades e métodos.

Já o nosso Counter será feito da seguinte forma:

import 'package:flutter/material.dart';
import 'counter.controller.dart';

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> with CounterController {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Text(
            'Count: $counter',
          ),
          FilledButton(
            onPressed: () {
              setState(() {
                incrementCounter();
              });
            },
            child: const Text('Increment'),
          ),
          FilledButton(
            onPressed: () {
              setState(() {
                decrementCounter();
              });
            },
            child: const Text('Decrement'),
          ),
          FilledButton(
            onPressed: () {
              setState(() {
                resetCounter();
              });
            },
            child: const Text('Reset'),
          ),
          FilledButton(
            onPressed: () {
              setState(() {
                doubleCounter();
              });
            },
            child: const Text('Double'),
          ),
          FilledButton(
            onPressed: () {
              setState(() {
                halfCounter();
              });
            },
            child: const Text('Half'),
          ),
        ],
      ),
    );
  }
}

Através do with CounterController o Counter agora possui acesso aos métodos e propriedades do CounterController. Dessa forma, podemos chamar os métodos incrementCounter, decrementCounter, resetCounter, doubleCounter e halfCounter diretamente no Counter.

Nossa estutura ficou mais simples, separamos as responsabilidades e agora podemos ter um código mais organizado e fácil de manter.

Tests


Vamos criar alguns testes unitários para garantir que nosso código está funcionando corretamente.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_articles/counter.controller.dart';

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> with CounterController {
  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

void main() {
  group("Counter", () {
    var state = const Counter().createState() as _CounterState;

    test('check incrementCounter', () {
      expect(state.counter, 0);
      state.incrementCounter();
      state.incrementCounter();
      state.incrementCounter();

      expect(state.counter, 3);
    });

    test('check decrementCounter', () {
      state.decrementCounter();
      state.decrementCounter();
      state.decrementCounter();

      expect(state.counter, 0);
    });

    test('check resetCounter', () {
      state.incrementCounter();
      state.incrementCounter();
      state.incrementCounter();

      state.resetCounter();

      expect(state.counter, 0);
    });

    test('check doubleCounter', () {
      state.incrementCounter();
      state.incrementCounter();
      state.incrementCounter();
      state.doubleCounter();

      expect(state.counter, 6);
    });

    test('check halfCounter', () {
      state.halfCounter();

      expect(state.counter, 3);
    });
  });
}

Primeiro vamos criamos um Counter que usa o nosso mixin CounterController, através dessa classe podemos usar o método createState para criar um objeto _CounterState que herda de State<Counter> e possui acesso aos métodos e propriedades do nosso mixin, dessa forma podemos interagir diretamente com o nosso estado e observar todas as mudanças.

Conclusão


Como vimos, o uso de mixins é uma ferramenta poderosa para isolar o estado de um widget, permitindo que você possa manter o código mais organizado e fácil de manter. Também facilita a criação de testes unitários já que temos a opção de não usar setState ou context.

Resources