Gestión simple de estado en Flutter

Siguiendo con la serie de artículos sobre frameworks declarativos, en esta ocasión vamos a ver como realizar el mismo ejemplo de gestión simple de estado en un shopping cart como ya hicimos en el anterior artículo en ReactJS pero en ahora en Flutter.

Contexto

En un anterior artículo vimos un wireframe de una lista de productos y un carrito de la compra.

Vamos a hacer este ejemplo también en Flutter, porque un carrito de la compra es un buen ejemplo de un estado compartido entre varios componentes o widgets como se conocen en Flutter.

Problema a resolver

Debemos poder añadir al carrito nuevos items desde el listado de productos.

En la barra de la aplicación vamos a poder ver el número de elementos que tenemos el carrito.

Al pulsar sobre el icono del carrito de la compra aparece un contenedor flotante con el contenido del carrito.

flutter-shopping-cart

Clean Architecture

Una vez más voy a utilizar Clean Architecture y por lo tanto vamos a tener una capa de dominio, una datos o infraestructura y otra de presentación donde el dominio estará aislado del resto de capas.

En la capa de presentación en el caso de productos, voy a utilizar una versión modificada de MVP (Model View Presenter), diferente a la utilizada en el ejemplo que vimos en el artículo de ReactJS. Esto es debido a que Flutter nos proveé de unos tipos para programación asíncrona que nos ayuda a simplificar los presenters y las vistas.

En futuras iteraciones y artículos sobre el ejemplo, aplicaremos diferentes patrones de presentación que encajan mejor con otros tipos de estado más complejos y su gestión en Flutter en concreto.

Dónde se gestiona el estado

Como vimos en anteriores artículos, los frameworks declarativos se basan en la creación de componentes o widgets inmutables y cuando su estado cambia, se renderizan completamente de nuevo junto a sus hijos.

En Flutter el reto de gestión de estado compartido entre widgets, como ya vimos en React, nos lo propone la renderización declarativa del framework y por lo tanto es en la capa de presentación donde se debe resolver.

La capa de dominio y datos no debería verse afectada por la estrategia que se decida para la gestión de estado entre widgets, ya que nos estaríamos acoplando al framework declarativo de presentación, en este caso Flutter, indirectamente.

En esta ocasión vamos a devolver desde la capa de dominio Future, pero es un tipo del lenguaje de dart para trabajar con llamadas asíncronas.

La gestión de estado, debería ser ajena al dominio de la aplicación y este no debería verse contaminado por la estrategia que utilicemos, porque la necesidad de gestionar el estado compartido entre widgets es algo impuesto por el framework de presentación..

Estructura de widgets

En nuestro ejemplo los widgets quedarían desglosados de esta manera:

flutter-widgets-gestion-simple-estado

En el diagrama se aprecia el levantamiento del estado al widget HomePage y la perforación del estado del carrito donde se incluyen también las funciones que se van a encargar de manejar las diferentes acciones.

Vamos al código

En el código hay dos features destacadas como es la carga del listado de productos y el carrito de la compra.

Vamos a ver algunos widgets desde la perspectiva del carrito de la compra y solo la capa de presentación que es donde se gestiona el estado, dejando a un lado dominio y datos.

Levantando el estado

Recordad que la estrategía que vamos a seguir en este artículo es levantar el estado y perforación de propiedades.

Por lo tanto home que es el widget superior más cercano a los widgets que deben acceder al estado del carrito y va a contener el estado del carrito.

class HomePage extends StatefulWidget {
  final CartPresenter _cartPresenter;

  HomePage() : _cartPresenter = getIt<CartPresenter>();

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  CartState _cartState;

  @override
  void initState() {
    super.initState();
    _cartState = CartState.createEmpty();

    widget._cartPresenter.init(updateState);
  }

  void updateState(CartState cartState) {
    setState(() {
      _cartState = cartState;
    });
  }

  ...

}

Fijaros que interactua con cartPresenter que es el que contiene la lógica de presentación en lo que se refiere al carrito y por lo tanto el que modifica su estado.

CartPresenter es el encargado de interactuar con la capa de dominio y cuando el estado del carrito cambia se lo comunica a la vista, en este caso, utilizando la función updateState que a su vez cambia el estado de HomePage utilizando el metodo setState.

Perforación de propiedades o estado

El concepto de un argumento props que contiene toda la información a pasar a un widget no existe en Flutter, aquí esta información se pasa como argumentos en el constructor del widget.

Ya tenemos el estado del carrito levantado y ahora necesitamos pasarselo a los widgets hijos utilizando el constructor de cada hijo.

Pero no solo el estado, también las funciones que van a manejar cuando hay que añadir un elemento al carrito, cuando hay que eliminarlo y cuando se modifica la cantidad de un elemento del carrito.

Estas funciones estan definidas en HomePage y se pasan sus hijos jerárquicamente, junto al estado del carrito, hasta donde deben ser ejecutadas.


  ...
  
  void _addProductToCart(ProductItemState productItemState) {
    widget._cartPresenter.addProductToCartCart(productItemState);
  }

  void _removeItemFromCart(CartItemState cartItemState) {
    widget._cartPresenter.removeCartItemOfCart(cartItemState);
  }

  void _editQuantityOfCartItem(CartItemState cartItemState, int quantity) {
    widget._cartPresenter.editQuantityOfCartItem(cartItemState, quantity);
  }
    
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: MyAppBar(_cartState.totalItems),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: ProductList(_addProductToCart),
      ),
      endDrawer:
          CartDrawer(_cartState, _editQuantityOfCartItem, _removeItemFromCart),
    );
  }
}

ProductItem

El widget que representa los elementos del listado de productos participa en la modificación del estado del carrito al invocar la función _addProductToCart, por lo tanto recibe la función como argumento en el constructor:

class ProductItem extends StatelessWidget {
  final ProductItemState _productItem;

  final void Function(ProductItemState productItemState) _addProductToCartCallback;

  const ProductItem(this._productItem, this._addProductToCartCallback);

  @override
  Widget build(BuildContext context) {
    return Card(
        child: Column(
      children: <Widget>[
        Expanded(
            flex: 5, 
            child: Image.network(
              _productItem.image,
              fit: BoxFit.fitWidth,
            )),
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Text(
            _productItem.title,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: Theme.of(context).textTheme.body1,
          ),
        ),
        Text(_productItem.price, style: Theme.of(context).textTheme.headline),
        RawMaterialButton(
          child: Text(
            'Add to cart'.toUpperCase(),
            style: Theme.of(context)
                .textTheme
                .button
                .copyWith(color: Theme.of(context).primaryColor),
          ),
          onPressed: ()=> _addProductToCartCallback(_productItem),
        )
      ],
    ));
  }
}

CartContentitem

Igualmente el widget que representa los elementos que ya existen en el carrito debe invocar la acción de modificar la cantidad de un elemento existente dentro del carrito y la acción de eliminar:

class CartContentItem extends StatelessWidget {
  final CartItemState _cartItemState;
  final TextEditingController _quantityController = TextEditingController();
  final void Function(CartItemState cartItemState) _removeItemFromCartCallback;
  final void Function(CartItemState cartItemState, int quantity)
      _editQuantityOfCartItemCallback;

  CartContentItem(
    this._cartItemState,
    this._editQuantityOfCartItemCallback,
    this._removeItemFromCartCallback,
  );

  @override
  Widget build(BuildContext context) {
    _quantityController.text = _cartItemState.quantity.toString();

    _quantityController.addListener(() {
      final int quantity = int.tryParse(_quantityController.text) ?? 0;

      if (quantity != _cartItemState.quantity) {
        _editQuantityOfCartItemCallback(_cartItemState, quantity);
      }
    });

    final imageWidget = Image.network(
      _cartItemState.image,
      height: 120.0,
    );

    final descriptionWidget = Column(children: <Widget>[
      Text(
        _cartItemState.title,
        style: Theme.of(context).textTheme.subhead,
        maxLines: 2,
        overflow: TextOverflow.ellipsis,
      ),
      Row(
        children: <Widget>[
          Expanded(
              child: TextField(
                  controller: _quantityController,
                  decoration: const InputDecoration(labelText: 'Quantity'),
                  keyboardType: TextInputType.number,
                  inputFormatters: [
                WhitelistingTextInputFormatter.digitsOnly
              ])),
          Expanded(
              child: Padding(
                  padding: const EdgeInsets.only(left: 8.0),
                  child: Text(_cartItemState.price,
                      style: Theme.of(context).textTheme.subhead))),
        ],
      )
    ]);

    return Container(
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        child: Card(
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              Expanded(
                  flex: 2,
                  child: Padding(
                      padding: const EdgeInsets.only(
                          left: 16.0, top: 16.0, right: 8.0, bottom: 16.0),
                      child: imageWidget)),
              Expanded(flex: 3, child: descriptionWidget),
              IconButton(
                icon: Icon(Icons.clear),
                onPressed: () => _removeItemFromCartCallback(_cartItemState),
              )
            ],
          ),
        ));
  }
}

Limitaciones

Al igual que vimos en el ejemplo de React, con Flutter conviene recordar que esta estrategía de levantar el estado tiene sus limitaciones:

  • A medida que una aplicación crece puede ser un problema utilizar esta técnica por toda la aplicación porque cada cambio de estructura en los componentes supone reajustar el paso de estado y funciones que manejan eventos de padres a hijos en la parte de la jerarquía afectada.

  • Además en nuestro caso, con cada cambio de estado se reconstruye el widget home y todos sus hijos, es decir casi prácticamente toda la aplicación. Se reconstruyen widgets de forma innecesaria como el listado de productos.

Para una parte pequeña de la jerarquía de widgets de una aplicación pienso que esta opción es perfectamente válida.

Artículos Relacionados

Código fuente

Os dejo el link al repositorio de GitHub donde podeís ver el código completo del ejemplo.

Conclusiones

En este artículo hemos visto como aplicar la estrategía de levantar estado en una aplicación Flutter.

También hemos visto que si utilizamos una arquitectura tipo Clean Architecture o Hexagonal Architecture, el dominio no debería verse contaminado por las decisiones de gestión de estado.

Y el dominio no debe verse afectado primero porque el reto de la gestión de estado no nos lo plantea el dominio de la aplicación sino el framework de presentación seleccionado al ser declarativo, en este caso Flutter y segundo porque esto nos va a permitir tener una aplicación más escalable y testable.

Aplicando la misma solución en dos tecnologías declarativas diferentes, ReactJS en un anterior artículo y ahora en Flutter el código resultante es bastante similar, salvo por las particularidades de cada lenguaje.