Flutter para desarrollo móvil multiplataforma

Flutter, desarrollado por Google es un framework reactivo de desarrollo de aplicaciones móviles para Android e iOS, viene a intentar lo que otros muchos ya han intentado antes, proporcionar la capacidad de crear una aplicación para los dos sistemas operativos móviles más utilizados a partir de un único código fuente. Flutter promete traer un desarrollo rápido y flexible, un rendimiento y una interfaz a la altura de una app desarrollada de forma nativa.

Aun estando en alpha/beta ya podemos utilizarlo para desarrollar nuestras aplicaciones, utiliza Dart como lenguaje, también creado por Google, un lenguaje reactivo, con una sintaxis clara y concisa a la que podrás acostumbrarte rápidamente si vienes de otros como C++, C# o Java.

Personalmente veo un gran futuro en Flutter ya no sólo por tener detrás a un grande como Google apoyándolo fuertemente si no por su consistencia y la rapidez que nos otorga para crear aplicaciones, además tiene una comunidad detrás que cada día adopta más seguidores, por eso quiero traer aquí un tutorial en el que podamos ver aquellos puntos de mayor conflicto que me he encontrado.

Veremos un poco cómo funciona Flutter y sus componentes principales creando una aplicación/juego sencillo pero con lo suficiente para poder empezar. Crearemos un juego de estilo trivial (preguntas y respuestas) que hace uso de la API de OpenTrivia para obtener la información. Pulsa en la imagen para ver el resultado final en vídeo.

He utilizado Android Studio, en la documentación de Flutter tenéis descritos los pasos a seguir para configurarlo todo, una vez terminado flutter doctor hará un chequeo y nos dirá si todo está correcto.

En este caso el chequeo nos devuelve una advertencia y es porque no hay ningún dispositivo iniciado en el que lanzar la aplicación, bastará con arrancar el emulador o conectar nuestro dispositivo de desarrollo para solventarlo.

En Flutter, todo son widgets, las pantallas se construyen utilizando widgets, la idea detrás de esto es hacer todo reusable, si desgranamos un poco la ventana principal de nuestra aplicación que hemos visto en el video podemos diferenciar cuatro widgets claramente, aunque hay más.

Como vemos en la imagen rodeado por el recuadro verde tenemos un widget que se encarga de mostrar la categoría a la que pertenece la pregunta, el azul contiene la pregunta, el amarillo las respuestas y el violeta una respuesta. La ventana principal, la selección de respuesta, la separación vertical que hay entre la categoría y la pregunta, el botón para cargar una nueva pregunta también son widgets. He marcado sólo los que construiremos por nuestra parte ya que Flutter nos proporciona una gran variedad de widgets (para layouts, textos, imágenes, efectos, estilos, etc) predefinidos con los que construir la aplicación, aunque como vemos también podemos construir los nuestros.

En Flutter hay dos tipos de widgets que tenemos que conocer ya que al ser el pilar fundamental vamos a tener que saber distinguir en qué momento utilizar cada uno de ellos, hay una diferencia principal y es que uno mantiene el estado, de ahí su nombre StatefulWidget (widget con estado) y el otro no StatelessWidget (widget sin estado). Controlar el estado de cada widget y su interacción entre ellos es algo que debemos controlar correctamente a lo que nos iremos haciendo con la práctica, según la documentación oficial con estado nos referimos a

El estado es la información que puede ser leída de forma asíncrona cuando el widget se construye y que puede cambiar durante su ciclo de vida

De ahí deducimos que un widget con estado es aquel cuyas propiedades son alteradas durante su ciclo de vida, aquellos que tienen interacción con el usuario (o consigo mismos), esto no significa que un widget sin estado no tenga interacción ya que uno sin estado puede contener otro que sí lo tiene, por tanto el widget padre no tendría estado pero sí lo tendrían sus hijos. Nada mejor que la documentación oficial para conocer los detalles intrínsecos a cada una de las partes del framework, aquí podemos leer más información sobre el estado.

Ahora que sabemos la diferencia entre los dos tipos de widgets empleados por Flutter podemos empezar a desarrollar nuestro juego.

Lo primero es crear el modelo, con dos clases es suficiente, una para la pregunta y otra para la respuesta.

import 'package:meta/meta.dart';
import 'package:html/parser.dart';
 
class Answer{
  String text;
  bool correct;
 
  Answer({@required text, @required this.correct}){
    this.text = parse(text).firstChild.text;
  }
 
  @override
  String toString() {
    return 'Answer{text: $text, correct: ${correct ? 'Yes' : 'No'}';
  }
}

Este es el modelo de la respuesta concretamente, no tiene nada especial más que un campo para conservar el texto de la respuesta y otro que determina si es correcta o no (que nos vendrá en la respuesta a la solicitud a la API de OpenTrivia), el constructor y un parseador del texto del paquete HTML ya que algunas vienen en este formato y si no lo parseamos se mostrarían caracteres raros, el método toString nos mostrará ese texto al imprimir un objeto de esta clase.

El modelo para la pregunta es el siguiente

import 'package:html/parser.dart';
import 'package:meta/meta.dart';
import 'package:trivia_flutter_demo/helper/question_helper.dart';
import 'package:trivia_flutter_demo/model/answer.dart';
 
class Question {
  String category;
  String difficulty;
  String question;
  List answersList;
 
  Question(
      {@required this.question,
      @required this.category,
      @required this.difficulty,
      @required this.answersList});
 
  Question.fromJson(Map<String, dynamic> json, List receivedAnswersList)
      : question = parse(json[KEY_RESULTS][0][KEY_QUESTION]).firstChild.text,
        category = json[KEY_RESULTS][0][KEY_CATEGORY],
        difficulty = json[KEY_RESULTS][0][KEY_DIFFICULTY],
        answersList = receivedAnswersList;
 
  @override
  String toString() {
    return 'Question{category: $category, difficulty: $difficulty\n\tquestion: $question\n\tanswers: $answersList}';
  }
}

En esta clase tenemos los datos de la pregunta como son la categoría, la dificultad y el texto además de una lista de respuestas (de la clase creada anteriormente), el método estático fromJson recibe el JSON que nos devolverá la API de OpenTrivia y se encarga de parsear cada uno de los elementos correspondientes a la pregunta, recibe también la lista de respuestas ya creada.

Tenemos una clase llamada question_helper.dart que es la que contiene las claves de cada uno de los elementos JSON y además se encarga de realizar la solicitud a OpenTrivia, recoger la respuesta, instanciar los objetos necesarios y devolver la pregunta.

import 'dart:async';
import 'dart:math';
import 'package:trivia_flutter_demo/model/answer.dart';
import 'package:trivia_flutter_demo/model/question.dart';
import 'package:http/http.dart' as http;
import 'dart:convert' as JSON;
 
const String KEY_RESULTS = "results";
const String KEY_CORRECT_ANSWER = "correct_answer";
const String KEY_INCORRECT_ANSWER = "incorrect_answers";
const String KEY_CATEGORY = "category";
const String KEY_DIFFICULTY = "difficulty";
const String KEY_QUESTION = "question";
 
final String url = "https://opentdb.com/api.php?amount=1&type=multiple";
 
Future retrieveQuestion() async {
  Question question;
  try {
    final response = await http.get(url);
    final jsonResponse = JSON.jsonDecode(response.body);
    Answer correctAnswer = new Answer(
        text: jsonResponse[KEY_RESULTS][0][KEY_CORRECT_ANSWER], correct: true);
    Answer incorrectAnswer1 = new Answer(
        text: jsonResponse[KEY_RESULTS][0][KEY_INCORRECT_ANSWER][0], correct: false);
    Answer incorrectAnswer2 = new Answer(
        text: jsonResponse[KEY_RESULTS][0][KEY_INCORRECT_ANSWER][1], correct: false);
    Answer incorrectAnswer3 = new Answer(
        text: jsonResponse[KEY_RESULTS][0][KEY_INCORRECT_ANSWER][2], correct: false);
    List answersList = [
      correctAnswer,
      incorrectAnswer1,
      incorrectAnswer2,
      incorrectAnswer3
    ];
    answersList.shuffle(new Random());
    question = Question.fromJson(jsonResponse, answersList);
  } catch (error) {
    return error;
  }
  return question;
}

Con esto ya tendríamos el modelo construido y el helper que nos hará la función de recoger las preguntas por lo que podemos empezar a construir los widgets que compondrán la interfaz de usuario y gestionarán los datos implicados.

Empezamos por el widget contenedor del texto de la pregunta

import 'package:flutter/material.dart';
 
class QuestionContainer extends StatelessWidget {
  final String question;
 
  QuestionContainer({@required this.question});
 
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Expanded(
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: Center(
            child: Text(
              question,
              textAlign: TextAlign.center,
              style: TextStyle(
                fontSize: 22.0,
                fontWeight: FontWeight.bold,
                color: Colors.blueGrey,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Aquí ya empezamos a ver cosas de Flutter, se trata de un widget sin estado ya que no requiere interacción alguna, simplemente nos muestra la pregunta y se compone de

  • Un widget Container (contenedor) principal: es un widget que combina pintados comunes, posicionamiento y widgets de tamaño. Se suele utilizar como elemento para contener a otros elementos.
  • Expanded: Se encarga de gestionar los espacios que ocupan los widgets que contiene en base a ciertos parámetros.
  • Padding: Se utiliza para determinar la distancia de los hijos que respecto a los bordes, (distancia interna). En este caso se está aplicando un margen interno de 8 píxeles a todos los lados, aquí puedes leer más sobre el EdgeInsets.
  • Center: Widget empleado para centrar los elementos en base a los parámetros indicados.
  • Text: Un widget para mostrar textos. Este texto se estiliza a través del parámetro style que recibe un widget TextStyle, utilizado para aplicar estilos como colores, pesos, tamaños, lineados, etc. En este caso es un texto a 22 píxeles en negrita y color azúl grisáceo.

Veamos ahora el widget para una respuesta

import 'package:flutter/material.dart';
import 'package:trivia_flutter_demo/model/answer.dart';
 
class AnswerContainer extends StatelessWidget {
  final String title;
  final Answer answer;
  final Color color;
 
 
  AnswerContainer({this.title, @required this.answer, @required this.color});
 
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Container(
        color: color,
        child: SizedBox(
          width: double.maxFinite,
          height: 60.0,
          child: Center(
            child: Text(
              answer.text,
              textAlign: TextAlign.center,
              style: TextStyle(
                fontSize: 18.0,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

La única novedad en este widget es el SizedBox que es un elemento que nos permite determinar unos tamaños fijos, en este caso se le está dando todo el ancho posible y una altura de 60 píxeles.

Veamos ahora el widget encargado de agrupar los widgets de las respuestas a la pregunta y mostrarlos.

import 'package:flutter/material.dart';
import 'package:trivia_flutter_demo/model/question.dart';
import 'package:trivia_flutter_demo/widget/asnwer_container.dart';
import 'package:trivia_flutter_demo/widget/correct_answer.dart';
import 'package:trivia_flutter_demo/widget/incorrect_answer.dart';
 
class AnswersContainer extends StatefulWidget {
  AnswersContainer(
      {Key key, @required this.question, this.onQuestionAnswered, this.title})
      : super(key: key);
  final String title;
  final Question question;
  final OnQuestionAnsweredCallback onQuestionAnswered;
 
  @override
  _BodyState createState() => new _BodyState();
}
 
class _BodyState extends State {
  @override
  Widget build(BuildContext context) {
    return new Column(
      children: [
        Container(
          child: Column(
              children: widget.question.answersList.map((questionAnswer) {
            Color color = Colors.green;
            if (widget.question.difficulty == 'medium') {
              color = Colors.orangeAccent;
            } else if (widget.question.difficulty == 'hard') {
              color = Colors.redAccent;
            }
            return GestureDetector(
              onTapDown: (tapDownDetails) {
                widget.onQuestionAnswered();
                setState(() {
                  showDialog(
                    context: context,
                    builder: (BuildContext context) => questionAnswer.correct ? CorrectAnswer() : IncorrectAnswer(),
                  );
                });
              },
              child: AnswerContainer(
                answer: questionAnswer,
                color: color,
              ),
            );
          }).toList()),
        ),
      ],
    );
  }
}
 
typedef OnQuestionAnsweredCallback = void Function();

Aquí tenemos el primero de nuestros widgets con estado y es que necesitamos actualizarlo cuando el usuario selecciona una de las respuestas para determinar si es correcta o incorrecta y actuar en consecuencia, y de paso cargar una nueva pregunta

  • El primer elemento nuevo que podemos observar es el Column: Éste se encarga de distribuir a sus componentes en formato vertical.
  • GestureDetector escucha los gestos aplicados sobre sus elementos como una pulsación, pulsación doble, larga, etc.
  • AnswerContainer es el widget que hemos creado antes y muestra cada una de las respuestas.

La llámada a setState es la que se encarga de actualizar el estado del widget, en ella mostramos un dialog que contiene un widget de respuesta correcta o incorrecta según el caso. Podemos ver también un Callback que se recibe del widget padre ya que allí es dónde se encuentra el método encargado de recoger la pregunta y que también es llamado al gestionar la respuesta utilizando el widget.onQuestionAnswered();

Ya sólo nos queda por ver los widgets que muestran el dialog para indicar si se ha respondido correctamente y el widget principal.

Respuesta correcta Respuesta incorrecta
import 'package:flutter/material.dart';
 
class CorrectAnswer extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(color: Colors.greenAccent),
      child: Center(
        child: SizedBox(
          height: 600.0,
          width: double.maxFinite,
          child: SimpleDialog(
            children: [
              Icon(
                Icons.check,
                size: 150.0,
                color: Colors.greenAccent,
              ),
              Text(
                'Yeahhhhh!!',
                textAlign: TextAlign.center,
                style: TextStyle(
                  fontSize: 30.0,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
import 'package:flutter/material.dart';
 
class IncorrectAnswer extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(color: Colors.redAccent),
      child: Center(
        child: SizedBox(
          height: 600.0,
          width: double.maxFinite,
          child: SimpleDialog(
            children: [
              Icon(
                Icons.close,
                size: 150.0,
                color: Colors.redAccent,
              ),
              Text(
                'Wronnggg!!',
                textAlign: TextAlign.center,
                style: TextStyle(
                  fontSize: 30.0,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Las novedades aquí son

  • BoxDecoration, una clase que nos da diferentes opciones de dibujado, en este ejemplo estamos pintando el fondo de verde o rojo.
  • SimpleDialog nos permite mostrar una ventana contextual por encima de la ventana actual, nosotros le hemos añadido un icono y un texto.
  • Icon es una clase que nos permite dibujar iconos, asignarle un color, tamaño y algunos parámetros a mayores.

Antes de ver el widget principal vamos a crear el repositorio de preguntas, esta clase en este proyecto no tiene mucho sentido ya que sólo se obtienen las preguntas de una única fuente, pero este patrón de diseño está pensado para que al programador le sea indiferente de dónde se obtiene la información, el repositorio es el encargado de recogerla de donde sea necesario. Abstracción y centralización.

import 'dart:async';
 
import 'package:trivia_flutter_demo/helper/question_helper.dart';
import 'package:trivia_flutter_demo/model/question.dart';
 
class Repository{
  static final Repository _repo = new Repository._internal();
 
  static Repository get() {
    return _repo;
  }
 
  Repository._internal();
 
  Future fetchQuestion(){
    return retrieveQuestion();
  }
}

Simplemente se crea un singleton (patrón de diseño) accesible a cualquier parte del código y este nos permite solicitar la pregunta a la API de OpenTrivia, como digo podría suprimirse ya que es la única fuente de preguntas, aunque tampoco sobra, si más adelante quisiéramos almacenarlas en una base de datos el repositorio sería el encargado de gestionar los accesos a cada uno de los puntos según el caso.

Procedamos entonces a crear el widget principal

import 'package:flutter/material.dart';
import 'package:trivia_flutter_demo/model/question.dart';
import 'package:trivia_flutter_demo/repository.dart';
import 'package:trivia_flutter_demo/widget/answers_container.dart';
import 'package:trivia_flutter_demo/widget/question_container.dart';
 
void main() => runApp(new MyApp());
 
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Open Trivia',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(title: 'Open Trivia'),
    );
  }
}
 
class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;
 
  @override
  _MyHomePageState createState() => new _MyHomePageState();
}
 
class _MyHomePageState extends State {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: null,
      body: _newQuestion(),
      floatingActionButton: new FloatingActionButton(
        backgroundColor: Colors.blueGrey,
        onPressed: () {
          setState(() {
            _newQuestion();
          });
        },
        tooltip: 'Next question',
        child: new Icon(
          Icons.skip_next,
          color: Colors.white,
        ),
      ),
    );
  }
 
  @override
  void initState() {
    super.initState();
  }
 
  Widget _newQuestion() {
    return FutureBuilder(
        future: Repository.get().fetchQuestion(),
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          switch (snapshot.connectionState) {
            case ConnectionState.none:
            case ConnectionState.waiting:
            case ConnectionState.active:
              return _getLoadingWidget();
            case ConnectionState.done:
              return _getMainWidget(snapshot.data);
          }
        });
  }
 
  Widget _getMainWidget(question) {
    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.fromLTRB(0.0, 40.0, 0.0, 0.0),
        child: Column(
          children: [
            Text(
              question.category,
              textAlign: TextAlign.center,
              style: TextStyle(
                color: Colors.blueGrey,
                fontSize: 25.0,
                fontWeight: FontWeight.bold,
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(0.0, 10.0, 0.0, 20.0),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  QuestionContainer(question: question.question),
                ],
              ),
            ),
            AnswersContainer(
              question: question,
              onQuestionAnswered: () {
                setState(() {
                  _newQuestion();
                });
              },
            ),
          ],
        ),
      ),
    );
  }
 
  Widget _getLoadingWidget() {
    return SafeArea(
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            SizedBox(
              width: 40.0,
              height: 40.0,
              child: CircularProgressIndicator(
                valueColor: new AlwaysStoppedAnimation(Colors.blueGrey),
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(0.0, 20.0, 0.0, 0.0),
              child: Text(
                'Loading',
                style: TextStyle(
                  color: Colors.blueGrey,
                  fontSize: 26.0,
                  fontWeight: FontWeight.w400,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

La primera parte simplemente es la creación de la aplicación, el método principal runApp lo llevará toda aplicación Flutter por defecto y es el encargado de lanzarla, ejecuta el widget sin estado llamado MyApp que establece el tema, nombre y demás parámetros básicos de una aplicación. MyHomePage y su estado componen el widget principal de la aplicación en el podemos ver un Scaffold que es la estructura visual básica con la interfaz de usuario Material en él que se crea la barra principal de la aplicación que hemos suprimido para este ejemplo, se indica el cuerpo y se añade un FloatingActionButton que utilizamos para cargar una nueva pregunta.

El método _newQuestion (el guión bajo indica que se trata de un método privado) se encarga de solicitar una nueva pregunta al repositorio utilizando una clase asíncrona FutureBuilder que muestra un widget de carga hasta que se termina el proceso.

Los métodos _getMainWidget y _getLoadingWidget crean y devuelven un widget de carga o la ventana principal según el FutureBuilder se lo requira, podrían crearse en clases separadas pero por agilidad a la hora de construir el tutorial los he dejado en la misma clase. En ellos como novedad podemos distinguir un widget SafeArea que hace que nuestra aplicación se empiece a dibujar por debajo de la barra del sistema y un CircularProgressIndicator que muestra el indicador de carga tan característico de Android.

Con esto ya tenemos nuestra primera aplicación/juego, hemos dado un repaso a los pilares fundamentales de una aplicación desarrollada con Flutter y ya podemos seguir indagando por nuestra cuenta para construir nuestras propias aplicaciones.

En el ejemplo aquí expuesto se pueden realizar multitud de mejoras tanto de código como de interfaz como funcionalidades, animaciones, diseño, almacenamiento en base de datos, creación de nuevas preguntas, puntuación de usuario. Si quieres empezar con algo para seguir introduciéndote en el framework de Flutter te invito a continuar con el ejemplo, ya tenemos una base construida sobre la que trabajar. En mi Github está el  proyecto listo para descargar y compilar o consultar si tienes alguna duda.

Cualquier cosa no dudes dejarla en los comentarios

Relacionado:

By |2018-09-30T17:57:30+00:00septiembre 29th, 2018|Android, Desarrollo, Informática, Móvil, Móvil, Videojuegos|0 Comments

About the Author:

Desarrollador de software de profesión, apasionado de la informática y todo lo relacionado con la tecnología

Leave A Comment