Explorando: classes Sealed em Flutter

Tempo de leitura: 6 minutes

O Dart 3 introduziu classes Sealed no Flutter. Se você vem de uma linguagem de programação moderna como Kotlin, já deve saber o quão poderosas elas são. Caso contrário, ao final deste artigo você verá do que se trata todo esse hype.

Classes sealed são um recurso poderoso que permite aos desenvolvedores criar uma hierarquia de classes restrita. Ao contrário das classes regulares, as classes sealed só podem ser estendidas dentro do mesmo arquivo, o que as torna uma excelente opção para representar conjuntos finitos de classes relacionadas.

 

Compreendendo as classes Sealed

As classes Sealed são declaradas usando o modificador selado antes da palavra-chave class. Ela serve como classe base para um conjunto finito de classes, e todas as suas subclasses devem ser declaradas no mesmo arquivo que a classe selada.

Herança limitada: as classes Sealed restringem a hierarquia de herança, o que garante que todas as subclasses possíveis sejam conhecidas em tempo de compilação. Isso os torna particularmente úteis para cenários onde você tem um conjunto fixo de classes. (Lembra dos enums?)

Por que não apenas usar enums então? Bem, além de fornecer uma lista exaustiva de cenários, as classes seladas também fornecem as habilidades de uma classe, ou seja, você pode ter variáveis e funções de membro.

 

Casos de uso

Vejamos alguns casos de uso onde classes sealed podem ser usadas:

1 – Tipos de resultados: as classes Sealed são frequentemente usadas para representar o resultado de uma operação que pode ter resultados diferentes, como sucesso ou falha.

sealed class Result {}

class Success<T> extends Result {
  final T data;
  Success(this.data);
}

class Error extends Result {
  final String errorMessage;
  Error(this.errorMessage);
}

2 – Máquinas de estado: as classes Sealed são excelentes para modelar máquinas de estado. Cada estado pode ser representado por uma subclasse sealed, e as transições entre estados podem ser modeladas por meio de funções que retornam instâncias da subclasse apropriada.

sealed class ConnectionState {}

class Connecting extends ConnectionState {}

class Connected extends ConnectionState {
  final String address;
  Connected(this.address);
}

class Disconnected extends ConnectionState {}

 

3 –  Tratamento de eventos: Ao lidar com diferentes tipos de eventos em um aplicativo, as classes sealed podem ser usadas para modelar hierarquias de eventos. Isso permite uma representação clara e concisa de vários tipos de eventos.

sealed class Event {}

class ClickEvent extends Event {
  final int buttonId;
  ClickEvent(this.buttonId);
}

class KeyEvent extends Event {
  final int keyCode;
  KeyEvent(this.keyCode);
}

 

4 – Árvores de Expressão: As classes Sealed podem ser usadas para modelar árvores de expressão em um compilador ou interpretador. Cada tipo de expressão (por exemplo, adição, subtração, multiplicação) pode ser representada como uma subclasse sealed.

sealed class Expr {}

class Const extends Expr {
  final int value;
  Const(this.value);
}

class Add extends Expr {
  final Expr left;
  final Expr right;
  Add(this.left, this.right);
}

class Multiply extends Expr {
  final Expr left;
  final Expr right;
  Multiply(this.left, this.right);
}

 

5. Configurações: as classes Sealed são úteis para modelar diferentes configurações ou opções com segurança de tipo. Cada opção de configuração pode ser representada por uma subclasse sealed.

sealed class Configuration {}

class Debug extends Configuration {}

class Release extends Configuration {}

class Custom extends Configuration {
  final String setting;
  Custom(this.setting);
}

 

6. Respostas de API: Ao trabalhar com APIs, as classes sealed podem ser usadas para modelar diferentes tipos de respostas, como sucesso, erro ou estados de carregamento.

sealed class ApiResponse {}

class Loading extends ApiResponse {}

class Success<T> extends ApiResponse {
  final T data;
  Success(this.data);
}

class Error extends ApiResponse {
  final String errorMessage;
  Error(this.errorMessage);
}

Vejamos o exemplo abaixo, onde queremos representar diferentes estados de nossa IU.

 

Pegar. Definir. CÓDIGO!!

Primeiro, certifique-se de estar usando pelo menos o Dart 3. Para verificar sua versão atual do Dart, você pode executar o seguinte comando: flutter doctor -v. Vai parecer algo assim:

Além disso, usaremos uma biblioteca de gerenciamento de estado chamada flutter_bloc.

O aplicativo terá um único botão, que escolherá aleatoriamente 2 números e os dividirá. Se o segundo número for 0, lançaremos uma exceção. Ao pressionar o botão simularemos um atraso de 2 segundos e mostraremos um carregador circular.

Primeiro, vamos criar nossa classe sealed:

sealed class UIState {
  const UIState();
}

class InitialState extends UIState {
  const InitialState();
}

class SuccessState<T> extends UIState {
  final T data;

  const SuccessState(this.data);
}

class LoadingState extends UIState {
  const LoadingState();
}

class ErrorState extends UIState {
  final String message;

  const ErrorState(this.message);
}

Aqui, declaramos uma classe selada chamada UIState e 4 subclasses que podem ser usadas para representar diferentes estados da UI.

 

Aqui está o código para as classes Cubit e RandomNumbers:

import 'dart:math';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sealed_class_tut/ui_state.dart';

class HomePageCubit extends Cubit<UIState> {
  HomePageCubit() : super(const InitialState());

  Future<void> randomDivision() async {
    try {
      final num1 = Random().nextInt(10);
      final num2 = Random().nextInt(3);
      final randomNumbers = RandomNumbers(num1: num1, num2: num2);
      emit(SuccessState<RandomNumbers>(randomNumbers));
      await Future.delayed(const Duration(milliseconds: 50));
      emit(const LoadingState());
      await Future.delayed(const Duration(seconds: 2));
      if (num2 == 0) throw UnsupportedError('Division By Zero');
      final result = num1 / num2;
      emit(SuccessState<double>(result));
    } catch (e) {
      emit(ErrorState(e.toString()));
    }
  }
}

class RandomNumbers {
  final int num1;
  final int num2;

  const RandomNumbers({required this.num1, required this.num2});
}

Aqui está um resumo do que está acontecendo neste cubit:

  1. Na inicialização do cubit, emitimos o InitialState.
  2. Quando o usuário chama o método randomDivision, escolhemos aleatoriamente 2 números e criamos um objeto da classe RandomNumbers.
  3. Emitimos este objeto no SuccessState junto com o tipo RandomNumbers. (Esta classe aceita parâmetros genéricos)
  4. Em seguida, emitimos o LoadState e simulamos um atraso de 2 segundos.
  5. Se o segundo número for 0, lançamos uma exceção, que é capturada no catch block. Lá emitimos o ErrorState com a mensagem de erro.
  6. Caso contrário, calculamos o resultado e o emitimos no SuccessState, mas desta vez com o tipo double.

 

Aqui está o código para a IU:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sealed_class_tut/home_page_cubit.dart';
import 'package:sealed_class_tut/ui_state.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sealed Class Tutorial',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Sealed Class Tutorial'),
      debugShowCheckedModeBanner: false,
    );
  }
}

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

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    RandomNumbers? nums;
    return BlocProvider<HomePageCubit>(
      create: (context) => HomePageCubit(),
      child: Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(widget.title),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              BlocBuilder<HomePageCubit, UIState>(
                builder: (context, state) {
                  if (state is SuccessState<RandomNumbers>) {
                    nums = state.data;
                  }
                  return Column(
                    children: [
                      ElevatedButton(
                        onPressed: () {
                          context.read<HomePageCubit>().randomDivision();
                        },
                        child: const Text('Divide'),
                      ),
                      const SizedBox(height: 8),
                      nums != null
                          ? Text(
                              'Division of ${nums?.num1} by ${nums?.num2} is: ',
                              style: const TextStyle(fontSize: 20),
                            )
                          : const SizedBox.shrink(),
                    ],
                  );
                },
              ),
              const SizedBox(height: 8),
              BlocBuilder<HomePageCubit, UIState>(
                builder: (context, state) {
                  return switch (state) {
                    InitialState() => const SizedBox.shrink(),
                    LoadingState() => const CircularProgressIndicator(),
                    SuccessState<double>() => Container(
                        margin: const EdgeInsets.all(12),
                        padding: const EdgeInsets.all(12),
                        decoration: BoxDecoration(
                          color: Colors.greenAccent.withOpacity(0.2),
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Text(
                          state.data.toString(),
                          style: const TextStyle(fontSize: 20),
                        ),
                      ),
                    ErrorState() => Container(
                        margin: const EdgeInsets.all(12),
                        padding: const EdgeInsets.all(12),
                        decoration: BoxDecoration(
                          color: Colors.redAccent.withOpacity(0.2),
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Text(state.message),
                      ),
                    _ => const SizedBox.shrink(),
                  };
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Vamos detalhar o código aqui:

  1. Inicializamos o cubit usando o BlocProvider.
  2. Consumimos o estado no BlocBuilder. Se o estado for SuccessState<RandomNumbers>, definimos o valor de “nums” e mostramos o texto.

3. A seguir, para a seção de resultados, temos outro BlocBuilder. Aqui com base no tipo de “state” emitido, podemos retornar diferentes tipos de widgets. Em vez de if-else aninhado, estamos retornando os widgets usando switch expression. Isso implica que temos que lidar com todos os casos possíveis.

E é isso. É assim que parece:

Obrigado por ler. Eu adoraria discutir e resolver qualquer uma de suas dúvidas. Se você gostou do artigo, bata palmas e compartilhe 🙃.

Segue o código completo do meu Github (Git)