Flutter: Explorando extension methods

Carlos Costa • 25/10/2024 Extension methods é uma ferramenta poderosa que, quando usada corretamente, pode tornar seu código Flutter mais limpo, mais expressivo e mais fácil de manter.

Tópicos


Introdução


Primeiramente vamos entender o que de fato são Extension Methods.

Extension Methods permitem adicionar funcionalidades a tipos existentes sem criar uma nova classe derivada ou modificar a classe original. Isso é particularmente útil quando trabalhamos com classes que não podemos modificar diretamente, como as classes built-in do Dart ou widgets do Flutter.

Exemplo:

String truncate(int maxLength) {
  if (length <= maxLength) return this;
  return '${substring(0, maxLength)}...';
}

Nesse exemplo, a classe String é estendida por StringExtension que adiciona um novo método chamado truncate que limita o tamanho da string.

Usaremos a nossa extension da seguinte forma:

import './extensions.dart';
...
void main() {
  var name = 'Lorem Ipsum is simply dummy text of the printing and typesetting';
  print(name.truncate(10)); // Lorem Ipsu...
}

Mais um exemplo, agora vamos adicionar um novo método ao objeto int.

extension IntExtension on int {
  bool get isPrime {
    if (this <= 1) return false;
    if (this == 2) return true;
    if (isEven) return false;

    for (int i = 3; i <= sqrt(this); i += 2) {
      if (this % i == 0) return false;
    }
    return true;
  }
}

void main() {
  var value = 100;
  print(value.isPrime); // false

  value = 101;
  print(value.isPrime); // true
}

Aqui, adicionamos um método chamado isPrime que verifica se um número é primo ou não.

Seguindo esse mesmo padrão é possível modificar qualquer classe primitiva do Dart ou classes próprias do nosso projeto. Agora vamos explorar as extensions aplicadas ao Flutter.

Projeto para exemplos


Antes de continua, vamos criar um projeto Flutter com o seguinte comando:

flutter create -e --platforms=windows example

Para facilitar a criação dos nossos exemplos, vamos simplificar ao máximo a estrutura do nosso projeto organizando da seguinte forma:

📂 lib — 📄 extensions.dart — 📄 main.dart

Usando extensions para estilização e formatação


Aqui temos um código bem simples, é um Scaffold com AppBar que tem um título estilizado.

Scaffold(
  appBar: AppBar(
    title: Text(
      'Home page'.toUpperCase(),
      textAlign: TextAlign.center,
      style: const TextStyle(
        fontSize: 20,
        fontWeight: FontWeight.bold,
        color: Colors.black,
        letterSpacing: 2,
      ),
    ),
  ),
)

Para melhorar esse código, podemos criar um novo método na classe String que retornará o widget Text com a devida formatação e estilo. Vamos ao código:

import 'package:flutter/material.dart';

extension StringExtension on String {
  Text pageTitle() {
    return Text(
      toUpperCase(),
      textAlign: TextAlign.center,
      style: const TextStyle(
        fontSize: 20,
        fontWeight: FontWeight.bold,
        color: Colors.black,
        letterSpacing: 2,
      ),
    );
  }
}

No código anterior criamos o método pageTitle vai retornar o nosso Text fomatado e estilizado. Aqui notamos uma característica muito importante das extensions.

O novo método que será aplicado ao objeto original pode ter o seu retorno de qualquer tipo. Ex: Um método aplicado a classe String pode retornar um widget Text ou qualquer outro tipo que seja.

Voltando ao código principal, vamos aplicar o método pageTitle ao nosso título.

import './extensions.dart';
...
Scaffold(
  appBar: AppBar(
    title: 'home'.pageTitle(),
  ),
)

Ficou bem mais limpo e simplificado. Isolamos nosso estilo e formação e também podemos reaproveitar o método pageTitle em outras telas. Seguindo essa mesma estratégia fica fácil de padronizar os nossos elementos Text para evitar duplicações ou diferenças de estilo.

Usando extensions para adicionar novas funcionalidades


Agora vamos tentar algo um pouco mais complexo, adicionaremos um novo recurso a classe Column.

Algo que sinto bastante falta quando estou usando Column ou Row no Fultte, é a possibilidade de aplicar espaçamentos entre os itens sem a necessidade de usar SizedBox, Spacer ou margins manualmente em cada item.

O que vamos fazer é estender a classe Column e adicionar um método chamado gap que retorna o Column com os devidos espaçamentos entre os elementos. Segue o exemplo.

Flutter: Explorando extension methods

Vamos ao código:

extension ColumnExtension on Column {
  Column gap(double size) {
    final List<Widget> items = [];

    for (int i = 0; i < children.length; i++) {
      items.add(children[i]);

      if (i < children.length - 1) {
        items.add(SizedBox(
          key: Key("gap-$i"),
          height: size,
        ));
      }
    }

    return Column(
      children: items,
    );
  }
}

No exemplo anterior fizemos o seguinte:

1 • Criamos uma nova extensão chamada ColunmExtension que estende a classe Column.

2 • Adicionamos um método chamado gap que recebe um double como parâmetro e retorna um Column com os itens e os espaçamentos entre eles. O parâmetro size vai definir o height do SizedBox.

3 • No método gap criamos uma lista chamada items que armazena os itens do Column original.

4 • No loop for, adicionamos os itens do Column original e, se o índice for menor que o número de itens, adicionamos um SizedBox com o espaçamento desejado.

5 • Finalmente, retornamos um Column com os itens e os espaçamento entre eles.

Ótimo! Agora podemos usar a nossa extension apenas chamando o método gap e passando o valor do espaçamento desejado.

Column(
  children: [
    Text('Item 1'),
    Text('Item 2'),
    Text('Item 3'),
    Text('Item 4'),
  ].gap(10),
)

Também podemos fazer algo parecido com a classe Row ou qualquer outro widget do Flutter, as possibilidades são infinitas.

Usando extensions para compartilhar recursos


Em muitos casos, precisamos compartilhar recursos em vários lugares do nosso front. Por exemplo, podemos querer definir um padrão de cores para os nossos widgets, padrões para margins, paddings, font sizes, etc. Para isso, podemos extender a classe BuildContext e ter acessos a esses recursos em nossos widgets sem muita dificuldade.

O BuildContext é uma referência para o local na árvore de widgets onde um determinado widget foi construído. Cada widget tem seu próprio BuildContext, o que permite acessar o contexto em que ele está inserido na árvore de widgets.

class Resources {
  //colors
  Color get primary => const Color(0xFF00A0FF);
  Color get secondary => const Color(0xFF09FF00);
  Color get tertiary => const Color(0xFFFF0000);

  //images
  String get logo => 'assets/images/logo.png';
  String get background => 'assets/images/background.png';
  String get texture => 'assets/images/texture.png';

  //common margins
  EdgeInsets get margin => const EdgeInsets.all(16);
  EdgeInsets get marginSmall => const EdgeInsets.all(8);
  EdgeInsets get marginMedium => const EdgeInsets.all(24);
  EdgeInsets get marginLarge => const EdgeInsets.all(32);
  EdgeInsets get marginXLarge => const EdgeInsets.all(64);

  //common paddings
  EdgeInsets get padding => const EdgeInsets.all(16);
  EdgeInsets get paddingSmall => const EdgeInsets.all(8);
  EdgeInsets get paddingMedium => const EdgeInsets.all(24);
  EdgeInsets get paddingLarge => const EdgeInsets.all(32);

  //common borderRadius
  BorderRadius get borderRadius => BorderRadius.circular(16);
  BorderRadius get borderRadiusSmall => BorderRadius.circular(8);
  BorderRadius get borderRadiusMedium => BorderRadius.circular(24);
  BorderRadius get borderRadiusLarge => BorderRadius.circular(32);
}

extension ResourcesExtension on BuildContext {
  Resources get resources => Resources();
}

Agora podemos acessar os nossos recursos através do objeto context em qualquer widget.

Widget build(BuildContext context) {
  print(context.resources.logo);
  print(context.resources.background);
  print(context.resources.texture);
  print(context.resources.margin);
  print(context.resources.padding);
  print(context.resources.borderRadius);
  print(context.resources.primary);
  print(context.resources.secondary);

  return Placeholder();
}

Usando extensions para componentização


Vamos experimentar usar as extesions para componentização. Criaremos um componente Card baseado no widget Container, nosso card terá o seguinte visual:

Cards

Vamos ao código.

extension CardExtension on Container {
  Container card({
    required String title,
    required String description,
    required String imageUrl,
    required void Function()? onTap,
  }) {
    return Container(
      padding: const EdgeInsets.all(15),
      width: 300,
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: const BorderRadius.all(Radius.circular(16)),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.2),
            blurRadius: 10,
            spreadRadius: 5,
            offset: const Offset(0, 5),
          ),
        ],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisAlignment: MainAxisAlignment.start,
        children: [
          Container(
            clipBehavior: Clip.antiAlias,
            decoration: const BoxDecoration(
              borderRadius: BorderRadius.all(Radius.circular(10)),
            ),
            width: double.infinity,
            child: Image.network(
              imageUrl,
              fit: BoxFit.cover,
            ),
          ),
          SizedBox(
            width: double.infinity,
            child: Text(
              title,
              style: const TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
                color: Colors.black,
              ),
            ),
          ),
          Text(
            description,
          ),
          SizedBox(
            width: double.infinity,
            child: FilledButton(
              style: ButtonStyle(
                backgroundColor: WidgetStateProperty.all(Colors.black),
              ),
              onPressed: () {},
              child: const Text('Action'),
            ),
          )
        ],
      ).gap(10),
    );
  }
}

A implementaçã do método card é bem trivial, ele recebe os parâmetros title, description, imageUrl e onTap e retorna um Container com o layout desejado.

Feito isso, podemos criar um componente card a partir do widget Container.

Container().card(
  title: "Title",
  description: "This is a short description about the card",
  imageUrl: "https://picsum.photos/536/354",
  onTap: () {},
),

Podemos criar vários componentes customizados seguindo esse mesmo padrão, isso pode ser muito eficiente para manter o nosso código mais organizado.

Usando extensions para criar estilos encadeados


Aqui vamos buscar inspiração no Tailwind CSS para estilizar o widget Text.

Tailwind CSS é um framework de CSS utilitário que facilita a criação de interfaces de usuário ao fornecer uma vasta coleção de classes pré-definidas, permitindo que você construa rapidamente estilos diretamente no HTML sem precisar escrever CSS personalizado.

Usando o Tailwind podemos podemos estilizar um elemento HTML desta forma: 👇

<p class="font-bold text-red-500 uppercase tracking-wider text-center">
  Hello, World!
</p>

Usaremos isso como inspiração para fazer algo semelhante no Flutter: 👇

Container(
  child: "Hello, World!"
    .upperCase
    .bold
    .color(Colors.red)
    .letterSpacing(2)
    .textCenter,
)

Primeiro, vamos criar uma extensão chamada StringToTextExtension que vai retornar um Text com o conteúdo do String original.

extension StringToTextExtension on String {
  Text get text {
    return Text(
      this,
      style: const TextStyle(),
    );
  }
}

Segundo, vamos criar uma extensão chamada TextStyleExtension que vai disponibilizar alguns métodos para aplicar estilos e formatações ao widget Text.

Segue o código:

extension TextStyleExtension on Text {
  Text _baseText({
    String? data,
    TextStyle? style,
    Locale? locale,
    bool? softWrap,
    TextOverflow? overflow,
    TextAlign? textAlign,
    TextDirection? textDirection,
    int? maxLines,
    String? semanticsLabel,
    StrutStyle? strutStyle,
    TextWidthBasis? textWidthBasis,
    TextHeightBehavior? textHeightBehavior,
    Color? selectionColor,
    TextScaler? textScaler,
  }) {
    return Text(
      data ?? this.data as String,
      locale: locale ?? this.locale,
      softWrap: softWrap ?? this.softWrap,
      overflow: overflow ?? this.overflow,
      textAlign: textAlign ?? this.textAlign,
      textDirection: textDirection ?? this.textDirection,
      maxLines: maxLines ?? this.maxLines,
      selectionColor: selectionColor ?? this.selectionColor,
      semanticsLabel: semanticsLabel ?? this.semanticsLabel,
      strutStyle: strutStyle ?? this.strutStyle,
      textWidthBasis: textWidthBasis ?? this.textWidthBasis,
      textHeightBehavior: textHeightBehavior ?? this.textHeightBehavior,
      textScaler: textScaler ?? this.textScaler,
      key: key,
      style: style ?? const TextStyle(),
    );
  }

  Text color(Color color) {
    return _baseText(
      style: style?.copyWith(color: color),
    );
  }

  Text get bold {
    return _baseText(
      style: style?.copyWith(fontWeight: FontWeight.bold),
    );
  }

  Text fontWeight(FontWeight weight) {
    return _baseText(
      style: style?.copyWith(fontWeight: weight),
    );
  }

  Text get italic {
    return _baseText(
      style: style?.copyWith(fontStyle: FontStyle.italic),
    );
  }

  Text fontSize(double size) {
    return _baseText(
      style: style?.copyWith(fontSize: size),
    );
  }

  Text height(double height) {
    return _baseText(
      style: style?.copyWith(height: height),
    );
  }

  Text get underline {
    return _baseText(
      style: style?.copyWith(decoration: TextDecoration.underline),
    );
  }

  Text get lineThrough {
    return _baseText(
      style: style?.copyWith(decoration: TextDecoration.lineThrough),
    );
  }

  Text get overline {
    return _baseText(
      style: style?.copyWith(decoration: TextDecoration.overline),
    );
  }

  Text get uppercase {
    return _baseText(
      data: data?.toUpperCase(),
      style: style?.copyWith(),
    );
  }

  Text get lowercase {
    return _baseText(
      data: data?.toLowerCase(),
      style: style?.copyWith(),
    );
  }

  Text get firstUpper {
    String firstLetter = data![0].toUpperCase();
    String rest = data!.substring(1);

    return _baseText(
      data: firstLetter + rest,
      style: style?.copyWith(),
    );
  }

  Text letterSpacing(double spacing) {
    return _baseText(
      style: style?.copyWith(letterSpacing: spacing),
    );
  }

  Text get ellipsis {
    return _baseText(
      style: style?.copyWith(overflow: TextOverflow.ellipsis),
    );
  }

  Text get fade {
    return _baseText(
      maxLines: 1,
      softWrap: false,
      style: style?.copyWith(overflow: TextOverflow.fade),
    );
  }

  Text get center {
    return _baseText(
      textAlign: TextAlign.center,
      style: style?.copyWith(),
    );
  }

  Text get right {
    return _baseText(
      textAlign: TextAlign.right,
      style: style?.copyWith(),
    );
  }

  Text get left {
    return _baseText(
      textAlign: TextAlign.left,
      style: style?.copyWith(),
    );
  }

  Text get justify {
    return _baseText(
      textAlign: TextAlign.justify,
      style: style?.copyWith(),
    );
  }

  Text background(Color color) {
    return _baseText(
      style: style?.copyWith(background: Paint()..color = color),
    );
  }
}

Inicialmente criamos um método chamado _baseText que recebe os parâmetros base do objeto Text, esse método serve para presevar o objeto original a medida que incrementamos o estilo do Text com outros métodos.

Usando a nossa extensão:

Scaffold(
  appBar: AppBar(
    title: "Home Page"
          .text
          .bold
          .fontSize(16)
          .uppercase
          .color(Colors.red)
          .letterSpacing(2)
          .center,
  ),
)

Testando extensões


Finalizando a nossa exploração, vamos criar alguns tests unitários. Para esse exemplo vamos testar o ColumnExtension que usamos neste tópico.

Vamos ao código:

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

void main() {
  group("ColumnExtension", () {
    const column = Column(
      children: [
        Text('foo'),
        Text('bar'),
        Text('span'),
      ],
    );

    test('Check children length', () {
      expect(column.gap(10).children.length, 5);
    });

    test('Check gap keys', () {
      expect(
        column.gap(10).children[1].key.toString(),
        const Key("gap-0").toString()
      );
      expect(
        column.gap(10).children[3].key.toString(),
        const Key("gap-1").toString()
      );
    });
  });
}

No primeiro caso verificamos se o número de itens na Column é igual ao esperado, no segundo caso verificamos se os gaps estão sendo aplicados corretamente.

Conclusão


As extension methods no Flutter são uma forma eficiente de estender a funcionalidade de classes e APIs, permitindo um desenvolvimento mais organizado e eficiente. Se usados de forma consciente, os extension methods podem ser uma excelente ferramenta para melhorar a produtividade e a qualidade do código no desenvolvimento de apps com Flutter.

Referências