Como escribir un widget multi plataforma y sus test en Flutter

Flutter es un frameowrk multi plataforma y por lo tanto es habitual que nos creemos widgets o componentes que tienen el estilo Material o Cupertino según la plataforma.

Vamos a ver en este artículo como podemos escribir un widget multiplataforma y también como podemos escribir test para cada plataforma.

Creando la infraestructra

Para escribir un widget multiplataforma es suficiente con acceder a la plataforma actual a través del contexto y devolver un widget Material o Cupertino según corresponda.

El SDK de Flutter nos proporciona una API de widgets Material y otros Cupertino.

Vamos a ver esquemáticamente como sería un widget multiplataforma.

class PlatformWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Theme.of(context).platform == TargetPlatform.iOS
        ? CupertinoWidget(child: child)
        : MaterialWidget(child: child);
  }
}

Es interesante que nos creemos un widget base que se encargue de gestionar el widget de la plataforma a crear.

Es importante para evitar duplicar código o evitar problemas si en un futuro ampliamos plataformas soportadas y no nos queremos olvidar de actualizar ningún widget.

abstract class PlatformWidget<I extends Widget, A extends Widget>
    extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    if (Theme.of(context).platform == TargetPlatform.iOS) {
      return createIosWidget(context);
    } else {
      return createAndroidWidget(context);
    }
  }

  I createIosWidget(BuildContext context);

  A createAndroidWidget(BuildContext context);
}

Nuestro ejemplo

El ejemplo que vamos a ver es un dropdown multiplataforma.

En el caso de Android sera un dropdown. En el caso de iOS sera un botón que muestra un picker cacterístico de la plataforma al pulsar el botón.

Creando un widget para Android e iOS

Por un lado nos vamos a definir un modelo que contiene la información del dropdown:

class Option {
  final String id;
  final String name;

  Option(this.id, this.name);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Option &&
          runtimeType == other.runtimeType &&
          id == other.id &&
          name == other.name;

  @override
  int get hashCode => id.hashCode ^ name.hashCode;
}

Ahora vamos a ver como sería el código del widget:

class PlatformDropdown extends PlatformWidget<CupertinoButton, DropdownButton> {
  final List<Option> options;
  final Option? value;
  final ValueChanged<Option?>? onChanged;
  final String? hint;

  PlatformDropdown(
      {required this.options, this.onChanged, this.value, this.hint});

  @override
  DropdownButton createAndroidWidget(BuildContext context) {
    return DropdownButton<Option>(
      isExpanded: true,
      value: value,
      onChanged: onChanged,
      hint: Text(hint ?? ''),
      items: options.map<DropdownMenuItem<Option>>((Option value) {
        return DropdownMenuItem<Option>(
          value: value,
          child: Text(value.name),
        );
      }).toList(),
    );
  }

  @override
  CupertinoButton createIosWidget(BuildContext context) {
    return CupertinoButton(
        minSize: 32.0,
        padding: const EdgeInsets.symmetric(vertical: 0.0, horizontal: 16.0),
        color: Theme.of(context).colorScheme.secondary,
        child: Text(value?.name ?? hint ?? '',
            style: Theme.of(context).textTheme.subtitle1),
        onPressed: () {
          showModalBottomSheet(
              context: context,
              builder: (BuildContext context) {
                return Container(
                    height: 200.0,
                    child: CupertinoPicker(
                      itemExtent: 32.0,
                      onSelectedItemChanged: (int index) {
                        if (onChanged != null) {
                          onChanged!(options[index]);
                        }
                      },
                      scrollController: value != null
                          ? FixedExtentScrollController(
                              initialItem: options.indexOf(value!))
                          : null,
                      children: options.map((Option option) {
                        return Center(
                          child: Text(option.name),
                        );
                      }).toList(),
                    ));
              });
        });
  }
}

¿Como podemos probar el widget en cada plataforma?

Cuando ejecutamos un test de widget la plataforma por defecto es en la que te encuentras. En mi caso como estoy en un Mac, será macOS.

Debemos tener cuidado con esto a la hora de escribir nuestros widgets multiplataforma.

Por ejemplo si la lógica del widget base fuera así:

if (Theme.of(context).platform == TargetPlatform.iOS) {
  return createIosWidget(context);
} else if (Theme.of(context).platform == TargetPlatform.android) {
  return createAndroidWidget(context);
}else {
  return Container();
}

Tendríamos problemas en los test de widget, porque en mi caso al ser un Mac nunca se renderizaria el código que espero sino un simple Container, los test fallarían y nos puede llevar tiempo dar con la solución.

Para establecer la plataforma en la que queremos probar el componente tenemos debugDefaultTargetPlatformOverride.

Al iniciar el test debemos asignar la plataforma a probar y al finalizar el test debemos asignar a null, sino nos dará el siguiente error:

══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown running a test:
The value of a foundation debug variable was changed by the test.

Vamos a ver como serían unos test muy simples donde comprobamos que se haya renderizado inicialmente el widget de Material o el widget de Cupertino.

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:karate_stars_app/src/common/presentation/widgets/platform/platform_dropdown.dart';

void main() {
  group('Android dropdown', () {
    setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);

    testWidgets('should show material dropdown', (WidgetTester tester) async {
      final options = givenAnOptions();
      final selectedOption = options[3];

      await renderWidget(tester, options, selectedOption);

      expect(find.byType(typeOf<DropdownButton<Option>>()), findsOneWidget);
      expect(find.byType(CupertinoButton), findsNothing);

      debugDefaultTargetPlatformOverride = null;
    });
  });
  group('iOS dropdown', () {
    setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.iOS);

    testWidgets('should show cupertino dropdown', (WidgetTester tester) async {
      final options = givenAnOptions();
      final selectedOption = options[3];

      await renderWidget(tester, options, selectedOption);

      expect(find.byType(CupertinoButton), findsOneWidget);
      expect(find.byType(typeOf<DropdownButton<Option>>()), findsNothing);

      debugDefaultTargetPlatformOverride = null;
    });
  });
}

Future<void> renderWidget(WidgetTester tester, List<Option> options, Option selectedOption) async {
  await tester.pumpWidget(MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
          body:
              PlatformDropdown(options: options, value: selectedOption))));
}

Type typeOf<T>() => T;

List<Option> givenAnOptions() {
  return Iterable<int>.generate(10)
      .map((int n) => Option(n.toString(), 'Name $n'))
      .toList();
}

A partir de aquí, podríamos ampliar los test de este dropdown multiplataforma con las funcionalidades existentes en cada plataforma.

Conclusiones

Hemos visto en este artículo como podemos escribir widgets multiplataforma y también como podemos escribir test que verifiquen el comportamiento en cada plataforma.

Es bastante útil crear un widget base a modo de infraestructura para evitar código duplicado y problemas a futuro si decicimos ampliar plataformas.