latest

Recreando Interfaces de Usuario populares en Flutter: Clonando la UI de Whatsapp. Parte 1

En las siguientes sesiones de este curso de Flutter vamos a tratar de reproducir / clonar algunas Interfaces de Usuario, a partir de ahora "UI", de aplicaciones móviles famosas como Whatsapp e Instagram. Comenzaremos tratando de clonar de manera fiel parte de la UI de Whatsapp.

Partimos de una estructura básica de proyecto constituida por el archivo lib/main.dart y por un widget con estado (que hemos denominado WhatsAppRutaHome), ubicado en un paquete de rutas, concretamente en lib/rutas/whatsapp_ruta_home.dart.

En lib/main.dart, tan sólo invocamos el widget WhatsAppRutaHome en la propiedad home (ruta inicial) de un MaterialApp. Además, hacemos uso de la propiedad theme , que no habíamos utilizado hasta ahora, para definir parte de la paleta de colores de la aplicación:

 Color primario   Color de accentuado 

También, la propiedad debugShowCheckedModeBanner: false, para quitar la etiqueta de Debug de la esquina superior derecha de la pantalla de la aplicación. El código de lib/main.dart en esta primera iteración es el siguiente:

import "package:flutter/material.dart";
// Sustituir "clon_whatsapp" por el nombre de vuestro proyecto
import "package:clon_whatsapp/rutas/whatsapp_ruta_home.dart";

void main() {

  runApp(new MaterialApp(
      home: new WhatsAppRutaHome(),
      title: "Clon de WhatsApp",
      theme: new ThemeData(
          primaryColor: new Color(0xff075E54),
          accentColor: new Color(0xff25D366)
          ),
      debugShowCheckedModeBanner: false,    
    ));    
}

El sencillo código de partida de lib/rutas/whatsapp_ruta_home.dart también se expone a continuación:

import "package:flutter/material.dart";

class WhatsAppRutaHome extends StatefulWidget {

  @override
  _WhatsAppRutaHomeState createState() => new _WhatsAppRutaHomeState();
}

class _WhatsAppRutaHomeState extends State<WhatsAppRutaHome> {

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text("WhatsApp")
      ),
      body: new Container(

      ),
    );
  }
}

Como veis, por ahora no tiene nada en especial. Si bien, si nos fijamos en la captura de ejemplo de la UI de Whatsapp (en la figura mostrada a continuación) podemos ver como está basada en navegación en pestañas. Con un TabBar en la parte inferior de un AppBar (dentro de un widget Scaffold).

Así pues, la siguiente iteración empieza modificando el widget WhatsAppRutaHome. El nuevo código para lib/rutas/whatsapp_ruta_home.dart es el siguiente:

import "package:flutter/material.dart";

// Sustituir "clon_whatsapp" por el nombre de vuestro proyecto
import "package:clon_whatsapp/rutas/camara_tab.dart";
import "package:clon_whatsapp/rutas/chats_tab.dart";
import "package:clon_whatsapp/rutas/estados_tab.dart";
import "package:clon_whatsapp/rutas/llamadas_tab.dart";

class WhatsAppRutaHome extends StatefulWidget {

  @override
  _WhatsAppRutaHomeState createState() => new _WhatsAppRutaHomeState();
}

class _WhatsAppRutaHomeState extends State<WhatsAppRutaHome> 
  with SingleTickerProviderStateMixin{

  TabController _controladorTabs;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    _controladorTabs = new TabController(
      vsync: this,
      initialIndex: 1,
      length: 4
    );
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text("WhatsApp"),
        bottom: new TabBar(
          tabs: <Widget>[
            new Tab(
              icon: new Icon(Icons.camera_alt)
            ),
            new Tab(
              text: "CHATS"
            ),
            new Tab(
              text: "ESTADOS",
            ),
            new Tab(
              text: "LLAMADAS",
            )
          ],
          controller: _controladorTabs,
        )
      ),
      body: new TabBarView(
        children: <Widget>[
          new Camara(),
          new Chats(),
          new Estados(),
          new Llamadas() 
        ],
        controller: _controladorTabs,
      )
    );
  }
}

Como podéis comprobar, el código es el mismo que el que ya implementamos con anterioridad para lograr la navegación por pestañas. Utilizamos un TabBar y en su propiedad tabs añadimos nuevos widgets Tab con los iconos y textos correspondientes. Luego también necesitamos un TabController, para gestionar la navegación por las pestañas. Dicho TabController _controladorTabs lo hemos inicializado en el método sobreescrito initState(), además como hicimos en su día, hemos añadido el mixin SingleTickerProviderStateMixin para la propiedad vsync que se encarga del control de las animaciones entre cambios de pestañas. Por último, necesitamos un TabBarView en la propiedad body del Scaffold para decir qué widget se debe mostrar al pulsar sobre cada pestaña. Por ahora, se han implementado cuatro widgets básicos en archivos .dart separados. Os mostramos como ejemplo uno de ellos. Si bien el paso siguiente es ir dando forma a cada uno de estos widgets.

El contenido de /lib/rutas/chats_tab.dart, por ahora, es el que se expone a continuación. De manera similar para el resto de widgets asociados al TabBarView.

import "package:flutter/material.dart";

class Chats extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      
    );
  }
}

Para crear el widget Chats en su pestaña correspondiente, vamos a utilizar una estructura List<ModeloContactoChat>  con la que poblaremos un widget ListView, de manera similar a como lo hicimos en la sesión introductoria de ListView. No obstante, construiremos dicho ListView usando ListView.Builder, tal y como lo hicimos en la sesión en la que emulábamos un ListView con scroll infinito. En este curso de introducción a  Flutter no vamos a usar persistencia en archivos o bases de datos, dado que el contenido se centra en el diseño de Interfaces de Usuario. Por tanto, un modelo de datos pequeño en memoria principal nos basta para poder focalizar en la parte de la UI.

Así, pues vamos a continuar con la implementación construyendo un paquete modelos para albergar los distintos modelos de datos de nuestro clon de WhatsApp. En particular, vamos a implementar la clase ModeloContactoChat con todo aquello que necesitamos para recrear cada contacto en la pestaña de Chats de nuestra aplicación. Para ello, desarrollamos el código siguiente en lib/modelos/modelo_contacto_chat.dart:

class ModeloContactoChat{
  String _nombre;
  String _fecha;
  String _imagenContacto;
  String _mensaje;

  ModeloContactoChat({String nombre, String fecha,
    String imagenContacto, String mensaje}){
      this._nombre = nombre;
      this._fecha = fecha;
      this._imagenContacto = imagenContacto;
      this._mensaje = mensaje;
  }

  String get nombre => _nombre;
  String get fecha => _fecha;
  String get imagenContacto => _imagenContacto;
  String get mensaje => _mensaje;
}

Con este modelo de contacto para la pestaña de chats vamos a reescribir lib/rutas/chats_tab.dart:

import "package:flutter/material.dart";

// Sustituir "clon_whatsapp" por el nombre de vuestro proyecto
import "package:clon_whatsapp/modelos/modelo_contacto_chat.dart";
import "package:clon_whatsapp/vistas/item_contacto_chat.dart";

class Chats extends StatefulWidget {
  @override
  _ChatsState createState() => _ChatsState();
}

class _ChatsState extends State<Chats> {
  List<ModeloContactoChat> chats;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    chats = <ModeloContactoChat>[
      ModeloContactoChat(
          nombre: "David Gilmour",
          fecha: "19:33",
          imagenContacto: "imagenes/gilmour.jpg",
          mensaje: "Iván, nos vemos en la gira!"),
      ModeloContactoChat(
          nombre: "Roger Waters",
          fecha: "18:32",
          imagenContacto: "imagenes/waters.jpg",
          mensaje: "Roger Waters: Sí ahora Trump quiere..."),
      ModeloContactoChat(
          nombre: "Richard Wright",
          fecha: "13:52",
          imagenContacto: "imagenes/wright.jpg",
          mensaje: "Wish you were here!"),
      ModeloContactoChat(
          nombre: "Nick Mason",
          fecha: "Ayer",
          imagenContacto: "imagenes/mason.jpg",
          mensaje: "Nick Mason: sí sí me llamaron"
              "de Top..."),
      ModeloContactoChat(
          nombre: "John Lennon",
          fecha: "Ayer",
          imagenContacto: "imagenes/lennon.jpg",
          mensaje: "John Lennon: imagine all the"
              "people living..."),
      ModeloContactoChat(
          nombre: "Paul McCartney",
          fecha: "Ayer",
          imagenContacto: "imagenes/mccartney.jpg",
          mensaje: "Paul McCartney: Let it be! Let it be!"),
      ModeloContactoChat(
          nombre: "George Harrison",
          fecha: "Ayer",
          imagenContacto: "imagenes/harrison.jpg",
          mensaje: "El beatle místico"),
      ModeloContactoChat(
          nombre: "Ringo Starr",
          fecha: "Ayer",
          imagenContacto: "imagenes/starr.jpg",
          mensaje: "Sir Richard Starkey"),
      ModeloContactoChat(
          nombre: "Mick Jagger",
          fecha: "5/4/19",
          imagenContacto: "imagenes/jagger.jpg",
          mensaje: "Ánimo Mick todo saldrá bien."),
      ModeloContactoChat(
          nombre: "Paul McCartney",
          fecha: "Ayer",
          imagenContacto: "imagenes/mccartney.jpg",
          mensaje: "Paul McCartney: Let it be! Let it be!"),
      ModeloContactoChat(
          nombre: "George Harrison",
          fecha: "Ayer",
          imagenContacto: "imagenes/harrison.jpg",
          mensaje: "El beatle místico"),
      ModeloContactoChat(
          nombre: "Ringo Starr",
          fecha: "Ayer",
          imagenContacto: "imagenes/starr.jpg",
          mensaje: "Sir Richard Starkey"),
      ModeloContactoChat(
          nombre: "Mick Jagger",
          fecha: "5/4/19",
          imagenContacto: "imagenes/jagger.jpg",
          mensaje: "Ánimo Mick todo saldrá bien."),
    ];
  }

  @override
  Widget build(BuildContext context) {
    return new ListView.builder(
        itemCount: chats.length,
        itemBuilder: (context, int item) => new ItemContactoChat( chats[item] )
    );
  }
}

El widget Chats es reimplementado como un StatefulWidget en cuyo estado alberga una lista de contactos de chat (List<ModeloContactoChat> chats), como los del modelo expuesto anteriormente. Prestad atención al atributo imagenContacto de ModeloContactoChat. Alberga una ruta a una carpeta que se ha creado en la raíz del proyecto de la aplicación (/imagenes) donde se han almacenado las imágenes de los contactos. Estas imágenes van a poder ser usadas programáticamente desde nuestros widgets utilizando AssetImage, como veremos a continuación. Para que está carpeta sea reconocida como parte de los assets de la aplicación debemos acceder a pubspec.yaml y buscar la línea comentada que hace referencia a los assets, sustituyéndola por lo siguiente (respetad tabulación):

assets:
    - imagenes/

Por último, observad el método build() en el StatefulWidget anterior  (lib/rutas/chats_tab.dart), emplea un ListView.builder para construir cada ítem de la ListView. Está delegando la construcción de cada fila de la lista al widget ItemContactoChat que se encuentra en un paquete de Vistas, concretamente en lib/vistas/item_contacto_chat.dart. El widget ItemContactoChat proporciona el aspecto que buscamos (similar a WhatsApp) a cada ítem de la lista de chats. La UI resultante es esta:

El contenido de lib/vistas/item_contacto_chat.dart puede verse a continuación:

import "package:flutter/material.dart";

// Sustituir "clon_whatsapp" por el nombre de vuestro proyecto
import "package:clon_whatsapp/modelos/modelo_contacto_chat.dart";

class ItemContactoChat extends StatelessWidget {

  final ModeloContactoChat _contacto;

  ItemContactoChat(this._contacto);

  @override
  Widget build(BuildContext context) {
    return new Column(children: <Widget>[
      new Divider(
        height: 18.0,
      ),
      new ListTile(
        leading: Container(
            width: 60.0,
            height: 60.0,
            decoration: BoxDecoration(
                borderRadius: BorderRadius.all(const Radius.circular(60.0)),
                color: Colors.transparent,
                image: DecorationImage(
                    fit: BoxFit.cover,
                    image: AssetImage(_contacto.imagenContacto)
                )
            )
          ),
        title: new Row(
          children: <Widget>[
            new Text(
              _contacto.nombre,
              style: new TextStyle(fontWeight: FontWeight.bold),
            ),
            new Text(
              _contacto.fecha,
              style: new TextStyle(
                fontSize: 13.5,
                color: Colors.grey,
              ),
            )
          ],
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
        ),
        subtitle: new Container(
          child: new Text(
            _contacto.mensaje,
            style: new TextStyle(
              fontSize: 15.0,
              color: Colors.grey,
            ),
          ),
          padding: const EdgeInsets.only(top: 5.0),
        ),
      )
    ]);
  }
}

Vamos a comentar un poco el código anterior. Como podéis comprobar hemos implementado ItemContactoChat como un StatelessWidget que tiene como propiedad inmutable un objeto ModeloContactoChat. Dicho objeto se usará para completar los datos del ítem. Si ahora prestamos atención al método build() del widget, podemos ver un widget Column en la raíz del árbol de widgets con dos hijos: 1) un Divider que es una línea horizontal separatoria (en este caso con un padding a su alrededor de 18px); y 2) un ListTile como los que hemos utilizado en sesiones anteriores.  

Lo más destable, dentro del ListTile es la construcción de la imagen circular del contacto de chat (asignada a la propiedad leading). En este caso, utilizamos un widget AssetImage para acceder a las imágenes que se han añadido como parte del proyecto (empleando su ruta). Intentamos crear la imagen circular del contacto con el widget CircleAvatar pero dados los problemas que nos encontramos a la hora de lograr el ajuste (fit) de la imagen de fondo con el contorno del círculo, al final optamos por un Container con un elemento de decoración BoxDecoration, que nos permitió utilizar la imagen y ajustarla adecuadamente al contorno del círculo.  

Por último, si queremos navegar a un la ruta que corresponde con un chat concreto, debemos modificar el contenido de lib/vistas/item_contacto_chat.dart de la siguiente manera:

import "package:flutter/material.dart";

// Sustituir "clon_whatsapp" por el nombre de vuestro proyecto
import "package:clon_whatsapp/modelos/modelo_contacto_chat.dart";
import "package:clon_whatsapp/rutas/chat_ruta.dart";

class ItemContactoChat extends StatelessWidget {

  final ModeloContactoChat _contacto;

  ItemContactoChat(this._contacto);

  @override
  Widget build(BuildContext context) {
    return new Column(children: <Widget>[
      new Divider(
        height: 18.0,
      ),
      new ListTile(
        leading: Container(
            width: 60.0,
            height: 60.0,
            decoration: BoxDecoration(
                borderRadius: BorderRadius.all(const Radius.circular(60.0)),
                color: Colors.transparent,
                image: DecorationImage(
                    fit: BoxFit.cover,
                    image: AssetImage(_contacto.imagenContacto)
                )
            )
        ),
        title: new Row(
          children: <Widget>[
            new Text(
              _contacto.nombre,
              style: new TextStyle(fontWeight: FontWeight.bold),
            ),
            new Text(
              _contacto.fecha,
              style: new TextStyle(
                fontSize: 13.5,
                color: Colors.grey,
              ),
            )
          ],
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
        ),
        subtitle: new Container(
          child: new Text(
            _contacto.mensaje,
            style: new TextStyle(
              fontSize: 15.0,
              color: Colors.grey,
            ),
          ),
          padding: const EdgeInsets.only(top: 5.0),
        ),
        onTap: (){
          Route ruta = new MaterialPageRoute(
            builder: (context) => new Chat(
              nombre: _contacto.nombre
            )
          );
          Navigator.push(context, ruta);
        },
      )
    ]);
  }
}

En el callback de onTap, la ruta construida sobre un objeto MaterialPageRoute es apilada con Navigator.push() como ya vimos aquí. En esta ocasión, además, pasamos la información del nombre del contacto al nuevo widget / ruta.

El widget Chat aprovecha el nombre que recibe como parámetro en su creación. Contenido en lib/rutas/chat_ruta.dart, tiene el siguiente aspecto simplificado en esta iteración de desarrollo:

import "package:flutter/material.dart";

class Chat extends StatefulWidget {
 
 // Propiedad inmutable
  final String nombre;

  Chat({this.nombre});

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

class _ChatState extends State<Chat> {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.nombre),
      ), 
      body: new Container()
    );
  }
}

Terminamos esta sesión aquí. Podéis encontrar el código del proyecto Flutter correspondiente a esta primera parte en https://github.com/ivan-gdiaz/clon_whatsapp-ipo2_parte1.

Continuaremos trabajando sobre nuestro clon de la UI de WhatsApp en la lección siguiente.

Author image
Iván González is postdoctoral researcher at the Castilla-La Mancha University.
Ciudad Real (Spain)