Animación de Slideshows de productos mediante transición con WebGL

Animación dentro de modal de Slideshows de productos mediante transición con WebGL con librería curtainsjs y encolando algunas transiciones mediante await de Promesas
Animación de Slideshows de productos mediante transición con WebGL

Ejecutar el código

Si usas navegador Safari es posible que no veas correctamente la maquetación, ya que he usado la función clamp() de CSS para hacer ciertas tipografías y paddings más fluido al tamaño de la pantalla

He usado parceljs. Para arrancarlo es necesario tener parcel instalado. Se puede usar yarn o npm

yarn global add parcel-bundler
npm install -g parcel-bundler

Lanzar parcel

parcel index.html

Si no puedes/quieres probar a clonártelo, coloco aquí el pen

Controlar el flujo de las transiciones CSS desde javascript

He creado tres funciones javascript para controlar las animaciones. He creado animaciones simples desde CSS, pero quería tener controlado el flujo de transiciones desde javascript: unas empiezan cuando terminan otras. Para esto existe un evento en javascript que te indica cuando una transición dada es finalizada: transitionend

He partido de este gran artículo https://surma.dev/things/raf-promise/

Las tres funciones son las siguientes:

const requestAnimationFramePromise = () => new Promise(resolve => requestAnimationFrame(resolve))

const transitionEndPromise = (element, last) => {
    return new Promise(resolve => {
        element.addEventListener('transitionend', event => {
            if (event.propertyName !== last) return
            element.removeEventListener('transitionend', this)
            resolve()
        }, true)
    })
}

const animate = async (element, stylz, last) => {
    Object.assign(element.style, stylz)
    return transitionEndPromise(element, last).then(_ => requestAnimationFramePromise())
}

La primera función hace un return de requestAnimationFrame (rAF)

La segunda función transitionEndPromise devuelve una promesa basada en la finalización del evento transitionend sobre el elemento HTML (pasado como primer parámetro) que queramos animar. La promesa será resuelta cuando la transición de la propiedad CSS (pasada como segundo parámetro) sea finalizada

La última función animate hace uso de las dos funciones anteriores y devuelve la promesa. Esta función es la que usaremos en nuestro código javascript para cada una de las animaciones que queramos realizar

  • Parámetro element: se trara de un elemento del DOM
  • Parámetro stylz*: estilos CSS que queremos animar, pasado como objeto de propiedades-valores. Una de ellas, lógicamente, debe ser la propiedad transition con las transiciones de las propiedades que se quieran animar
  • Parámetro last**: la propiedad que queramos escuchar para dar por finlizada la promesa

(*) Las transiciones CSS necesitan la definición de un estado inicial y su estado final. La no definición de alguna de las propiedades CSS hará que la promesa no sea resuelta y por tanto el programa se quede parado

(**) Hay que tener precaución con las propiedades short-hand de CSS. Lo explico con ejemplo. Si animamos el background-color desde azul a rojo pero le pasamos como tercer parámetro la propiedad background la promesa no será resuelta ya que la transición no finaliza, en este caso debemos ser exacto y especificar la propiedad background-color. Si miramos el código de la segunda función veremos que if (event.propertyName !== last) return no se produce match entre las propiedades y nunca llega al resolve()

Anticipando un poco sobre lo que voy a hablar. La potencia es enorme, usando async / await puedes encolar una, dos, tres... transiciones a la finalización de una, dos, tres...

Ejemplo de flujo de transiciones CSS en apertura de modal

Voy a comentar todo el punto anterior mediante ejemplo. Las animaciones del texto del producto y de sus tallas paralizan las transiciones siguientes. Si, ya, realmente no hay más animaciones, pero lo que hago es cambios en el DOM para dar por finalizada la apertura del modal :). Aunque si revisamos más a fondo, veremos que estas dos transiciones no se inician hasta que esté terminada las animación de Imagen de producto await animate(cloneProductImg, {bla bla bla}, 'left')

Antes de empezar

Previamente, para la animación de los textos del producto lo que hago es un split de todo el texto en palabras. Estos nuevos elementos, con color de texto transparente y fondo blanco me sirve para que, modificando sus alturas, dejen ver el texto del producto

La animación de las tallas consiste en un elemento <rect> de SVG al que se le modifica su stroke-dashoffset

Transición para texto de productos con apertura de Modal

Parto de que ya tenemos en el DOM todas las palabras dentro de <span> y que su ubicación con CSS hace coincidir cada palabra encima de su respectiva

const descriptionSpans = [...modal.querySelectorAll('.product__description .text-animation span')]
const descriptionSpanHeight = `${Math.ceil(descriptionSpans[0].getBoundingClientRect().height)}px`
descriptionSpans.forEach(span => span.style.height = descriptionSpanHeight)

Guardamos la referencia a los <span> y establecemos la altura de todos para tener un valor inicial de modificación

const descriptionSpanPromises = descriptionSpans.map(span => {
  return animate(span, {
    transition: `height ${randomRange(200, 800)}ms linear ${randomRange(10, 1000)}ms`,
    height: `0px`
  }, 'height')
})

Recorremos cada uno de los <span> (descriptionSpans) mediante un bucle map para guardar en un array descriptionSpanPromises cada una de las promesas que nos devuelve cada palabra. Más tarde usaremos este array de promesas

A cada palabra le modificamos su altura, desde su valor inicial (el alto normal) hasta su valor final (0px). La duración y el retraso será un números random entre 200ms-800ms y 10ms-1000ms respectívamente

Transición para Inputs de tallas con apertura de Modal

Antes de empezar con javascript hago inciso con CSS, y quedándome sólo con el trozo CSS que interesa en este punto

:root {
    --label-w: 36;
    --label-h: 24;
    --dasharray: calc(calc(var(--label-w) * 2) + calc(var(--label-h) * 2))
}

Se crean dos variables CSS para la definición del tamaño del Input y una tercera variable para obtener el perímetro que es calculada con los datos ancho y alto anteriores

El objetivo es para que se puedan modificar los tamaños de los Inputs con tan sólo modificar las variables ancho y alto, y que la animación del path del elemento <rect> sea coherente

.labels {
    .label {
        width: calc(var(--label-w) * 1px);
        height: calc(var(--label-h) * 1px);
        input {
            &:checked ~ .label__checkmark .shape-rect {
                stroke-dashoffset: 0;
            }
            &:focus ~   .label__checkmark .shape-rect {
                fill: rgba(0,0,0,.1);
            }
        }
        &__checkmark {
            width: inherit;
            height: inherit;
            .shape,
            .text {
                width: inherit;
                height: inherit;
            }
            .shape-rect {
                width: inherit;
                height: inherit;
                stroke-dasharray: var(--dasharray);
                stroke-dashoffset: var(--dasharray);
                stroke-width: 1px;
                transition: stroke-dashoffset 1s;
            }
            .text {
                width: 100%;
                height: 100%;
                border: .5px solid rgba(0,0,0,.1);
                transition: color .5s ease .1s;
            }
            &:hover .shape-rect {
                stroke-dashoffset: 0;
            }
        }
    }
}

Por herencia los anchos y altos de los hijos de Label serán los mismos. Y el stroke-dasharray del SVG será la suma de todos sus lados para cerrar todo el perímetro

Ahora sí, seguimos con javascript

const labels = modal.querySelector('.labels')
const labelSizes = [...labels.querySelectorAll('.label')]
const shapesRect = [...labels.querySelectorAll('.shape-rect')]
const labelInputChecked = modal.querySelector('.labels input:checked')

Se obtienen las referencias a los elementos del DOM

let delayLabels = 0
let transtionTimeLabels = 800
const labelW = parseInt(window.getComputedStyle(labelSizes[0], null).getPropertyValue('width'), 10)
const labelH = parseInt(window.getComputedStyle(labelSizes[0], null).getPropertyValue('height'), 10)

Se crean variables para hacer cálculos de duración y retraso en las transiciones

labels.classList.add('labels--animating')
labelInputChecked.checked = false

Se añade clase para realizar pequeños ajustes desde CSS y se establece el Input checked como unchecked (para igualar la animación. Cuando termine la transición, se volverá a dejar marcado)

const labelSizesPromises = labelSizes.map(label => {
  delayLabels += (labelW/(labelW*2 + labelH*2))*transtionTimeLabels
  return animate(label.querySelector('.shape-rect'), {
    transition: `stroke-dashoffset ${transtionTimeLabels}ms linear ${delayLabels}ms`,
    strokeDashoffset: '0'
  }, 'stroke-dashoffset')
})

Igual que en el caso de la animación de palabras del producto, recorremos cada uno de los <label> (labelSizes) con un bucle map para guardar en un array labelSizesPromises cada una de las promesas.

Antes de usar la función animate realizamos cálculos para aplicar retrasos a cada Label delayLabels += (labelW/(labelW * 2 + labelH * 2)) * transtionTimeLabels. Este delay aplicado a cada Label es el necesario para que se inicie el pintado del lado superior cuando la cara superior del Label precedente ya fue terminado de pintar. Como todos los Labels están inicialmente juntos, da la sensación que se está dibujando una linea horizontal contínua. (Es complicado de explicar, si vas a ejecutar el código, prueba a subir el valor a 5 segundos, exagerando :) transtionTimeLabels = 5000. Si quieres seguir exagerando, también puedes hacer los Inputs más grandes cambiando las variables CSS --label-w: 66; --label-h: 44;)

Ahora si, que me entretengo por el camino, vamos a la función animate

Vamos a modificar el valor de la propiedad stroke-dashoffset (lo indicamos como tercer parámetro de la función, para que resuelva la Promesa. Recuerda que si ponemos stroke la promesa nunca será resuelta, por aquello de que no existe como propiedad if (event.propertyName !== last) return).

No hemos necesitado definir desde javascript la longitud inicial de <rect class="shape-rect" /> porque ya lo tenemos definido desde CSS como la suma de todos sus lados --label-w: 36; --label-h: 24; --dasharray: calc(calc(var(--label-w) * 2) + calc(var(--label-h) * 2)); stroke-dasharray: var(--dasharray);

Lo Prometido es deuda ;)

Para las dos animaciones comentadas en los dos puntos anteriores, dijimos que guardábamos en dos Arrays (descriptionSpanPromises y labelSizesPromises) las promesas de transtionend

Sólo tenemos que esperar a que terminen para seguir avanzando

await Promise.all([...descriptionSpanPromises, ...labelSizesPromises])

Flujo de transiciones CSS y movimiento de elementos en apertura de modal

La idea es la misma, realizar transición de elementos con la función animate comentado en los casos anteriores, pero añadiendo el movimiento dentro de la pantalla de una posición inicial a otra. Para esto, he creado funciones que me permitan clonar elementos del DOM, con atributos que me han interesado: left, top, width, height, font-size, position, background-size. Se podrían quitar o añadir otros atributos, o incluso refactorizar para hacerlo más "universal"

Función para punto de partida de la transición

Necesitamos una función más o menos reutilizable para DRY

const transtionFrom = (el, start, appendTo) => {
    const styles = window.getComputedStyle(el)
    const rect = start.getBoundingClientRect()
    const style = {
        left: `${rect.left}px`,
        top: `${rect.top}px`,
        width: `${rect.width}px`,
        height: `${rect.height}px`,
        fontSize: styles.getPropertyValue('font-size'),
        position: `fixed`,
        backgroundSize: `cover`
    }
    Object.assign(el.style, style)
    appendTo.parentNode.append(el)
}

La función usa tres parámetros:

  • Parámetro el: elemento DOM que queremos animar
  • Parámetro start: elemento DOM que vamos a usar para leer estilos
  • Parámetro appendTo: elemento del DOM donde vamos a insertar el nuevo nodo

Internamente usamos dos funciones javascript para poder obtener los valores que nos han interesado window.getComputedStyle(el) y .getBoundingClientRect()

Función para punto final de la transición

const transitionTo = (el, cssTransition) => {
    const styles = window.getComputedStyle(el)
    const rect = el.getBoundingClientRect()
    return {
        transition: `${cssTransition}`,
        top: `${rect.top}px`,
        left: `${rect.left}px`,
        width: `${rect.width}px`,
        height: `${rect.height}px`,
        fontSize: styles.getPropertyValue('font-size')
    }
}

Esta función devuelve un objecto con los estilos para la transición

Cuando queramos iniciar la transición, tenemos que hacer la llamada a la función animate(element, stylz, last) siendo su segundo parámetro la función que estamos comentando transitionTo(el, cssTransition)

Ejemplo de flujo de transiciones CSS y movimientos de elementos en apertura de modal

Estas dos funciones anteriores se han usado como "helper" para las animaciones de las imágenes, título de producto y precio de producto

Voy a comentar el caso de la animación del título

const openModal = event => {
  // otro código

  const product = event.target.closest('.product')
  const dataModalID = 'modal' + product.getAttribute('data-modal')
  const modal = document.querySelector('#' + dataModalID)

  // otro código
}

Inicialmente necesitamos identificar el producto sobre el que se hace click y obtener su correspondencia con el modal. El atributo data-modal del producto hace referencia con la ID del Modal

productState = {
  product,
  title: product.querySelector('.product__title'),
  // otros elementos
}
modalState = {
  modal,
  title: modal.querySelector('.product__title'),
  // otros elementos
}

Uso tres objetos para poder tener la referencias del DOM del producto Modal abierto y poder acceder fácilmente en el cierre del modal que se trate. En el código sólo muestro dos objetos, el tercero, es para guardar las referencias a las instancias creadas del contexto WebGL

Construimos los objetos con las referencias al DOM

const cloneProductTitle = productState.title.cloneNode(true)
transtionFrom(cloneProductTitle, productState.title, product)

Clonamos el título y lo insertamos dentro del DOM con los estilos de partida que necesitamos dentro del <div> del producto

animate(cloneProductTitle, transitionTo(modalState.title, 'top .8s ease-in .1s, left .8s ease-in .1s, width .8s ease-in .1s, height .8s ease-in .1s, font-size .8s ease-in .1s'), 'width')

Realizamos la animación del producto clonado, con el punto de partida (estilos) que tiene dentro del GRID de productos y con el punto final (estilos) que tiene el título dentro de su Modal, y escuchando el atributo width para dar por finalizada esta transición

Las propiedades que hemos animado son

  • top .8s ease-in .1s
  • left .8s ease-in .1s
  • width .8s ease-in .1s
  • height .8s ease-in .1s
  • font-size .8s ease-in .1s
animate(cloneProductTitle, {estilos}, 'width')

// Animación que paraliza el código siguiente
await Promise.all([...descriptionSpanPromises, ...labelSizesPromises])

// La eliminación del nodo se ubica después de otras animaciones que si paralizan
cloneProductTitle.remove()

Finalmente eliminamos el nodo DOM creado ya que no lo necesitamos para el cierre del Modal

Ojo, podría darse un conflicto al eliminar el nodo, ya que no hemos usado el await animate(...), y esto provocaría eliminarlo al poco de iniciar la animación.

Pero no existe peligro, ya que existen animaciones posteriores que si usan await y se ubican antes del .remove() del nodo

Animación de Imágenes de productos con WebGL

Esta característica es usada en la vista Modal de un producto para mostrar las imágenes relacionadas

Anteriormente hice un Slideshow de imágenes con WebGL (ahora con ligeras modificaciones) en un ejemplo aislado, y ahora quería integrarlo para este posible caso de imágenes de productos

A parte de lígeras modificaciones de vertex y fragment shaders que no voy a comentar aquí.

Lo que si he añadido es un elemento HTML <pogress> para mostrar el slide activo

Indicador de Animación WebGL con progress

this.touchStartY = 0
this.countSlides = set.planeElement.querySelectorAll("img").length - 1
this.slidesState = {
  // otros atributos
  progress: set.progress,
  value: set.progress.value,
  next: null
}

He añadido nuevos atributos a la clase WebglSlides

  • Atributo next: usado para saber si se avanza o retrocede de slide
  • Atributo value: para poder modificar el valor "value" del elemento <pogress> mediante requestAnimationFrame (rAF)
_animateProgressBar() {
  if (this.slidesState.next) {
    if (this.slidesState.value == 100) {
      this.slidesState.value = 0
    }
    this.slidesState.value += 1
  } else {
    this.slidesState.value -= 1
    if (this.slidesState.value == 0) {
      this.slidesState.value = 100
    }
  }
  this.slidesState.progress.value = `${this.slidesState.value}`

  if (this.slidesState.value !== this.slidesState.steps) requestAnimationFrame(() => this._animateProgressBar())
}

Basándonos en los atributos anteriores, actualizamos el valor del <pogress>

_animate(to, activeTexture, nextTexture) {
  if (!this.slidesState.isChanging && to) {
    // otro código

    this.slidesState.steps = Math.ceil((100 / this.slidesState.maxTextures) * this.slidesState.nextTextureIndex)
    requestAnimationFrame(() => this._animateProgressBar())

    // otro código
  }
}

La función _animateProgressBar() es llamada dentro de _animate(to, activeTexture, nextTexture)

Realizamos una operación matemática para conocer hasta donde se tiene que producir la llamada a requestAnimationFrame(() => this._animateProgressBar())

Transformamos en escala de 0 a 100 la posición que ocupa la textura siguiente. Cuando se alcance ese valor, el loop será finalizado if (this.slidesState.value !== this.slidesState.steps) requestAnimationFrame(() => this._animateProgressBar())

Evento wheel para cambio de imágenes WebGL

He registrado un nuevo manejador para los slides, el evento wheel del ratón

wheelEvent(activeTexture, nextTexture) {
  const that = this
  this.canvas.closest('.modal').addEventListener('wheel', event => {
    let to = undefined
    if (event.deltaY > 0) {
      to = 'next'
    } else if (event.deltaY < 0) {
      to = 'prev'
    }

    that._animate(to, activeTexture, nextTexture)
  })
}

Es bastante simple. Aprovecho que ya tenía la navegación mediante botones Prev/Next, en el que detectaba si se pulsa en Prev/Next. Lo que hago es simular este comportamiento en función del valor deltaY asociado al evento wheel, si es mayor a 0: to = 'next' y si es menor de 0: to = 'prev' y hago llamada a la función _animate(to, activeTexture, nextTexture) con el valor del primer parámetro decidido

Código en Github

He comentado sólo algunas cosas, puedes ver todo el código en GitHub