Merida Design Blog

Publicado el | Tutoriales / , , ,

Como crear un efecto de transición entre listado y perfil estilo Material Design con AngularJS

Una de las especificaciones de Material Design de Google respecto a las animaciones, es que éstas deben tener un significado, es decir, deben informar al usuario de lo que está sucediendo y cual será el resultado de la acción realizada, así como el orden y la jerarquía de los elementos. Todo esto a través de animaciones que también deleiten al usuario, logrando con esto una mejor experiencia de uso.

Un listado de usuarios que al seleccionar un elemento muestre la pantalla de detalles es una buena oportunidad para hacer uso de esta especificación, y es el ejemplo que realizaremos en este tutorial.

Esta es la vista previa del resultado final.

También puedes ver el demo funcional en el siguiente enlace:
Demo


El módulo

Para hacer una aplicación con AngularJS primero debemos crear el módulo principal que se encargará de iniciar la aplicación y su contexto de ejecución a través de la directiva ng-app.

Abre tu editor de preferencia y crea un archivo con el nombre md.module.js y coloca el siguiente código.


    (function () {
        "use strict";
        angular.module("mdApp", []);
    } ());

A lo largo del tutorial vamos a crear en total 3 archivos javascript que podrían colocarse en uno solo ya que el demo no es muy complejo, pero con el fin de seguir las buenas prácticas sugeridas en la guía de estilos de John Papa crearemos un archivo por cada objeto.


La vista

A continuación vamos a crear el HTML que servirá como plantilla para el listado incluyendo las directivas que servirán para mostrar los datos que posteriormente vamos a obtener a través de un servicio.


    <!DOCTYPE html>
    <html>
        <head>
            <title>Material Design Profile</title>
            <link href='https://fonts.googleapis.com/css?family=Passion+One|Roboto+Condensed:400,300,700|Roboto:400,300,500' rel='stylesheet' type='text/css'>
            <link rel="stylesheet" href="styles.css">
        </head>
        
        <body ng-app="mdApp">
            <section ngController="mdController as Ctrl">
                <ul>
                    <li ng-repeat="user in Ctrl.users">
                        <a>
                            <div class="imageWrapper">
                                <img src="{{user.avatar}}" />
                            </div>
                            
                            <div class="info">
                                <h3>{{user.name}}</h3>
                                <p>{{user.title}}</p>
                            </div>
                        </a>
                    </li>
                </ul>
            </section>
            
            <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.min.js"></script>
            <script type="text/javascript" src="md.module.js"></script>
        </body>
    </html>

Como se puede ver en el código, asignamos el controlador mdController a la etiqueta section que servirá como su ámbito de ejecución, posteriormente pintamos la lista de los usuarios a través de un ng-repeat obteniendo los datos de la variable users que se encuentra en el controlador.

Ya que aún no hemos creado el controlador ni obtenido los datos, el código anterior no mostrará nada aún, asi que a continuación vamos a crear el controlador y obtener los datos.


Los Datos

Antes de continuar con el controlador, primero vamos a crear el servicio web con los datos que van a cargarse en el listado, para ello usaremos la librería json-server, si aún no la conoces, te recomiendo que antes de continuar leas éste artículo y la pongas en marcha en tu equipo.

Para crear el web-service primero vamos a definir la estructura de los datos, para ello crea un archivo con el nombre db.js y coloca el siguiente código.


    module.exports = function () {
        "use strict";
        
        var faker = require("faker"),
            _ = require("lodash");
            
        return {
            users: _.times(50, function (n) {
                return {
                    id: n,
                    address: faker.address.streetAddress(),
                    avatar: faker.internet.avatar(),
                    color: faker.internet.color(),
                    description: faker.hacker.phrase(),
                    email: faker.internet.email(),
                    name: faker.name.findName(),
                    phone: faker.phone.phoneNumber(),
                    title: faker.name.title()
                }
            })
        }
    };

Los módulos faker y lodash deben estar instalados en la carpeta del proyecto, si aún no los instalas solo ejecuta el siguiente comando en tu terminal (consola de comandos).

 $ npm install faker lodash 

Ahora ejecuta json-server para crear la API que nos servirá para cargar los datos.


    $ json-server db.js

Después de ejecutar el comando en la terminal debe aparecer el siguiente texto.


     \{^_^}/ hi!

    Loading users-db.js
    Done

    Resources
    http://localhost:3000/users

    Home
    http://localhost:3000

Ahora que tenemos listo ser web-service vamos a crear un servicio de angular para consumir los datos.

Crea un archivo con el nombre md.api.js y coloca el siguiente código:


    (function () {
        "use strict";
        
        angular.module("mdApp")
            .factory('MdApi', MdApi);
            
        MdApi.$inject = ["$http"];
            
        function MdApi($http) {
            var fn = {},
                baseUrl = "http://localhost:3000/";
                
            fn.query = query;
            
            function query(endPoint) {
                return $http.get(baseUrl + endPoint);
            }
            
            return fn;
        }
    } ());

Este servicio nos permite extraer la definición de las rutas de la api y las distintas funciones para consultar la api aunque para este ejemplo solo creamos la función query que recibe como parámetro la ruta del objeto que queremos consultar y regresa la promesa (promise) que nos devuelve el servicio $http de angular.


El controlador

Con los datos y el servicio para consultarlos ahora solo nos resta crear el controlador.
Crea un archivo con el nombre md.controller.js y coloca el siguiente código.


    (function () {
        "use strict";
        
        angular.module("mdApp")
            .controller("mdController", mdController);
            
        mdController.$inject = ["MdApi"];
            
        function mdController(MdApi) {
            var vm = this;
            
            vm.users = [];
            
            // Obtenemos los datos
            MdApi.query("users")
                .then(function(response) {
                    vm.users = response.data;
                });
        }
    } ());

Ahora necesitamos modificar el archivo index.html para indicarle que cargue los nuevos archivos y la aplicación comience a funcionar.


    ...
    <script type="text/javascript" src="md.module.js"></script>
    <script type="text/jacascript" src="md.api.js"></script>
    <script type="text/javascript" src="md.controller.js"></script>

Ahora, si recargas el index.html la página debe verse como en la imagen (por supuesto con distintos datos):

image-1


Estilos

A continuación crea el archivo styles.css y coloca el siguiente código.


    * { box-sizing: border-box; }
    
    body, h3, h4, p, ul {
        list-style: none;
        margin: 0;
        padding: 0;
    }
    
    body {
        background-color: #232323;
        color: #333;
        font-family: 'Roboto', sans-serif;
    }
    
    a {
        background-color: ghostwhite;
        cursor: pointer;
        display: flex;
        flex-direction: row;
        padding: 10px;
        text-decoration: none;
    }
    
    a:active {
        background-color: whitesmoke;
    }
    
    .imageWrapper {
        flex-grow: 0;
        flex-shrink: 0;
        margin-right: 10px;
        overflow: hidden;
    }
    
    .imageWrapper img {
        border-radius: 50%;
        height: 50px;
        width: 50px;
    }
    
    .info {
        flex: 1;
    }
    
    section {
        background-color: whitesmoke;
        border-radius: 15px;
        box-shadow: 4px 4px 3px rgba(0,0,0,0.3);
        border: 2px solid #999;
        height: 520px;
        left: 50%;
        max-width: 100%;
        overflow: hidden;
        position: absolute;
        top: 50%;
        transform: translate(-50%,-50%);
        width: 320px;
    }
    
    section > ul {
        bottom: 0;
        left: 0;
        overflow-y: scroll;
        -webkit-overflow-scrolling: touch;
        -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
        position: absolute;
        right: 0;
        top: 0;
        z-index: 0;
    }
    
    section > ul li {
        display: block;
        border-bottom: 1px solid rgba(0,0,0,0.1);
    }

El código anterior es bastante sencillo, al elemento section le damos un ancho y alto fijos y lo centramos en la pantalla para simular como se vería en un entorno móvil.

Las líneas 64 y 65 son las que tal vez puedan resultar extrañas, la 64 activa el scroll nativo en iOS y la 65 elimina el resaltado que genera el navegador en los móviles para indicar que el elemento ha sido presionado, en su lugar, asignamos un color de fondo ligeramente mas opaco a través de la pseudo-clase :active del elemento a que rodea cada item de la lista.

Refresca el index.html en tu navegador y observa los cambios. La página ahora debe verse similar a la siguiente imagen.

image-2


Vista de perfil

Lo siguiente es crear la vista del perfil que aparecerá desde la parte inferior de la pantalla para colocarse sobre el listado al mismo tiempo que la imagen del usuario se desprende del listado y se coloca en el área de la cabecera de la vista del perfil (como se muestra en el video al principio del artículo).

Abre de nuevo el archivo index.html y modificalo de la siguiente forma:


    <ul>
        <li ng-repeat="users in Ctrl.users">
            <a ng-click="Ctrl.showProfile($event, $index)">
                ...
    <div class="profile">
        <header ng-style="{backgroundColor: Ctrl.user.color}">
            <h3>{{Ctrl.user.name}}</h3>
            <small>{{Ctrl.user.title}}</small>
        </header>
        
        <ul>
            <li>
                <quote>{{Ctrl.user.description}}</quote>
            </li>
            <li>
                <h4>Address:</h4>
                <p>{{Ctrl.user.address}}</p>
            </li>
            <li>
                <h4>Phone:</h4>
                <p>{{Ctrl.user.phone}}</p>
            </li>
            <li>
                <h4>Email:</h4>
                <p>{{Ctrl.user.email}}</p>
            </li>
        </ul>
        
        <a class="close-button"
            ng-style="{backgroundColor:Ctrl.user.color}"
            ng-Click="Ctrl.closeProfile($event)">x</a>
    </div>

Los datos del usuario se obtendrán de la variable Ctrl.user que asignaremos a través de la función showProfile que se ejecuta al presionar cualquier elemento de la lista (línea 13), pero antes de agregar esa función al controlador, vamos a asignarle los estilos a esta pantalla.

Abre el archivo styles.css y modifícalo agregando el siguiente código.


    ...
    
    .profile {
        background-color: ghostwhite;
        bottom: 0;
        left: 0;
        position: absolute;
        right: 0;
        top: 0;
        transition: all 400ms cubic-bezier(0.785, 0.135, 0.150, 0.860);
    }
    
    .profile .close-button {
        bottom: 1em;
        border: none;
        border-radius: 50%;
        box-shadow: 2px 2px 2px rgba(0,0,0,0.3);
        color: ghostwhite;
        display: block;
        height: 40px;
        position: absolute;
        right: 1em;
        text-align: center;
        width: 40px;
    }
    
    .profile li h4 {
        border-bottom: 1px solid #ddd;
        font-weight: 400;
        margin-bottom: .3em;
    }

    .profile li p {
        color: #666;
        font-weight: 300;
        margin: 0 0 1em 0;
    }

    .profile header {
        background-color: #efefef;
        height: 200px;
        overflow: hidden;
    }

    .profile header h3,
    .profile header small {
        color: ghostwhite;
        left: 0;
        position: absolute;
        text-align: center;
        width: 100%;
    }

    .profile header h3 {
        font-weight: 400;
        top: 140px;
    }

    .profile header small {
        font-weight: 300;
        top: 165px;
    }

    .profile li {
        opacity: 0;
        transition: all 200ms ease-out;
        transform: translateY(20px);
    }

    .profile li.show {
        opacity: 1;
        transform: translateY(0);
    }

    .profile ul {
        display: block;
        font-size: 0.9em;
        padding: 1.5em;
    }

    .profile quote {
        color: #444;
        display: block;
        font-weight: 300;
        margin-bottom: 1.5em;
        padding: 0 0 0 2.2em;
        position: relative;
    }
    
    .profile quote:before {
        color: #666;
        content: '"';
        font-size: 4.5em;
        font-family: 'Passion One', cursive;
        left: -.3rem;
        position: absolute;
        top: -.5rem;
    }
    
    section {
        ...

No hay nada realmente nuevo en el código, posicionamos la pantalla de perfil de manera absoluta, ocupando el área disponible en su contenedor, en este caso el section.

Si todo esta correcto al refrescar el index.html ahora debe verse de la siguiente forma:

image-3

Los datos y el color del header se mostrarán cuando asignemos el valor a la variable Ctrl.user, pero ántes de eso, vamos a hacer un par de ajustes a los estilos para que ésta pantalla permanezca oculta y debajo del listado, de manera que al mostrarla se deslice hacia arriba con un pequeño efecto de fade-in.

Ubica las siguientes líneas en el archivo styles.css y modificalo para que quede de la siguiente forma:


    .profile {
        background-color: ghostwhite;
        bottom: 0;
        left: 0;
        opacity: 0;
        position: absolute;
        right: 0;
        top: 0;
        transition: all 400ms cubic-bezier(0.785, 0.135, 0.150, 0.860);
        transform: translate3d(0,100%,0);
    }

Refresca de nuevo y ahora la pantalla de perfil ya no debe mostrarse.


Seleccionar elemento y ejecutar animación

Para ejecutar las animaciones, primero debemos añadir algunas clases al archivo de estilos.

Abre el archivo styles.css y coloca el siguiente código.


    .floating-item {
        display: block;
        padding: 10px;
        width: 100%;
        will-change: left, top;
        z-index: 2;
    }
    .floating-item,
    .floating-item .imageWrapper,
    .floating-item .info {
        position: absolute;
        transition: all 400ms cubic-bezier(0.785, 0.135, 0.150, 0.860);
        will-change: left, top;
    }

    .floating-item .imageWrapper {
        height: 100px;
        left: -15px;
        top: -15px;
        width: 100px;
        transform: scale(0.5);
    }
    .floating-item .imageWrapper img {
        height: 100%;
        width: 100%;
    }

    .floating-item .info {
        left: 70px;
        right: 0;
    }

    .floating-item h3 { display: inline-block; }
    .floating-item h3,
    .floating-item p {
        transition: all 100ms ease-in;
        will-change: left, top;
    }

    .floating-item.centered {
        
    }
    .floating-item.centered .imageWrapper {
        left: 50%;
        transform: translate3d(-50%, 0, 0) scale(1);
    }
    .floating-item.centered h3,
    .floating-item.centered p {
        opacity: 0;
    }
    
    .profile.show {
        opacity: 1;
        transform: translate3d(0,0,0);
    }

En la linea 96 se crea la clase .show que servirá para crear la transición de la pantalla de perfil.

La clase .floating-item se la asignaremos a un elemento creado e insertado de manera dinámica, ese elemento es el que creará el efecto a la imagen del perfil de moverse de su posición actual en la lista, hasta la posición final en el perfil del usuario.


Abre el archivo md.controller.js y modifícalo como se muestra en el código de abajo. Voy a colocar todo el código y posteriormente explicaré las partes mas importantes.


    function mdController(MdApi) {
        var vm = this,
            $itemFloat, 
            $itemSelected, 
            $profile = document.querySelector('.profile'),
            $section = $profile.parentNode,
            $list = $section.querySelector('ul'),
            pos,
            contentSpeed = 50;
        
        vm.closeProfile = closeProfile;
        vm.showProfile = showProfile;
        vm.user = null;
        vm.users = [];
        
        MdApi.query('users')
            .then(function(response) {
                vm.users = response.data;
            });
        
        function closeProfile(e) {
            e.preventDefault();
            
            var $profile = document.querySelector('.profile'),
                $section = $profile.parentNode;
            
            _animateInfo(false)
                .then(function(){
                
                setTimeout(function(){
                    $profile.classList.remove('show');
                    $itemFloat.style.top = pos + 'px';
                    $itemFloat.classList.remove('centered');
                }, contentSpeed * 2);

                setTimeout(function() {
                    $itemSelected.style.opacity = 1;
                }, 500);

                setTimeout(function() {
                    $section.removeChild($itemFloat);
                }, 600); 
            });
        }
        
        function showProfile(e, i) {
            e.preventDefault();
            
            var $selectedItem = e.target.closest('a'),
                $floatingItem = document.createElement('div');
            
            vm.user = vm.users[i];
            
            pos = ($selectedItem.offsetTop - $list.scrollTop);
            
            $floatingItem.innerHTML = $selectedItem.innerHTML;
            $floatingItem.classList.add('floating-item');
            $floatingItem.style.height = $selectedItem.offsetHeight + 'px';
            $floatingItem.style.top = pos + 'px';
            $floatingItem.style['will-change'] = 'left, top';
            
            $section.appendChild($floatingItem);
            $itemFloat = $floatingItem;
            $itemSelected = $selectedItem;
            $selectedItem.style.opacity = 0;
            
            setTimeout(function() {
                var $imageWrapper = $floatingItem.querySelector('.imageWrapper');
                $profile.style.zIndex = 1;
                $profile.classList.add('show');
                $floatingItem.style.top = '50px';
                $floatingItem.classList.add('centered');
            }, 50);
            
            setTimeout(function () {
                _animateInfo(true);
            }, 600);

        }
        
        function _animateInfo(show) {
            return new Promise(function(resolve, reject) {
                var $contentItems = document.querySelectorAll('.profile li'),
                i = 0, length = $contentItems.length, arr = [];
            
                if (length === 0) return;

                for (i = 0; i < length; i++) {
                    arr.push($contentItems[i]);
                }

                i = show ? 0 : length;
                arr.forEach(function(item) {
                    var delay = (show ? i++ : i--) * contentSpeed;

                    setTimeout(function(){
                        if (show)
                            item.classList.add('show');
                        else
                            item.classList.remove('show');
                        
                        hasFinished(delay);
                    }, delay);
                });
                
                function hasFinished(d) {
                    if (d === ((length - 1) * contentSpeed))
                        resolve();
                }
            });
        }
    }

Líneas 11 y 12: $itemFloat e $itemSelected sirven para poder acceder al elemento seleccionado y al creado dinámicamente a través de las distintas funciones.

En la línea 60 dentro la función showProfile asignamos el valor del elemento seleccionado para que la información se muestre en la pantalla del perfil de usuario.

En la línea 58 creamos el elemento flotante $floatingItem para posteriormente asignarle el mismo contenido, posición y tamaño que el elemento de la lista seleccionado, y como este elemento se encuentra al mismo nivel que la pantalla del perfil, y tiene una posición absoluta, se puede mover con libertad sin afectar la distribución del resto de los elementos.

En la línea 75 asignamos las clases y atributos necesarios para hacer el cambio de posición, pero hacemos esto dentro de un setTimeout para dar un pequeño delay y permitir que los cambios sean visibles, de lo contrario las clases y estilos se asignarían inmediatamente y no se crearía la animación.

La función _animateInfo en la línea 89, sirve para recorrer los elementos del listado de la pantalla del perfil asignando o removiendo las clases que permiten que estos elementos hagan una aparición o desvanecimiento de manera secuencial, dandole una mayor armonía al conjunto de las animaciones.

El resultado final puede verlo y editarlo en el siguiente enlace:
Demo


Conclusión

Este tutorial tuvo como finalidad mostrar como crear el efecto que permite a dos elementos fusionarse de manera armónica a través de una animación, creando un sentido de dirección que informa al usuario sobre el origen y destino de la información, lo cual ayuda a crear una buena experiencia de usuario.

Si te interesa ver algún otro ejemplo de las guías de diseño de materiales (Material Design) de google, no olvides solicitarlo a través de los comentarios.



Publicaciones que pueden interesarte

    Deja un comentario

      tope
    Derechos Reservados, Merida Design 2017
    %d bloggers like this: