
React-spring
es una librería fantástica basada en un física de movimiento de muelles o de oscilador armónico
Aquí vamos a utilizarla para recrear su ejemplo de la técnica masonry para posicionar elementos en una malla
Prólogo
La técnica masonry la vimos en su adaptación con css-grid
y JavaScript
en este curso
Aquí vamos a implementarla con la librería externa react-spring y siguiendo su ejemplo en codesandbox
Vamos allá
Entendiendo el código
React-spring
nos ofrece una dinámica de movimiento realista basada en muelles, donde en lugar de ajustar tiempo y distancias ajustamos masa, tensión y fricción
A partir de aquí, la librería nos ofrece distintas herramientas (yo prefiero las versiones en hook) para implementar transiciones
Para el masonry, la que utilizamos es la useTransition
El hook consta de dos partes
La primera es el hook en sí
const transitions = useTransition(items, item => item.key, {
from,
enter,
leave,
})
El hook nos devuelve un array
de transiciones, y le tenemos que dar una serie de argumentos
- ítems: una lista de elementos o de condiciones
- una función para devolver
keys
asociadas a los elementos, o si se trata de una condición el valor seránull
- un objeto para definir lo que pasa al inicio
from
que es opcional, lo que pasa cuando se entraenter
y cuando se dejaleave
la transición
La lista que devuelve la utilizamos para desplegar la animación, lo hacemos utilizando el componente <animated>
que también importaremos
Ya ya estará, todo lo que tenga que ver con la animación en sí está encapsulado en los componentes <animated>
Vamos a ver algunos ejemplos
Ejemplos
Ejemplos para implementar distintos efectos, donde la utilización de react-spring
lo que nos permite es conseguir esa física "amortiguada" que podemos modificar
Y si quieres cambiar la física, puedes modificar el objeto config
const config = { mass: 1, tension: 1000, friction: 20 }
Condición on/off
Aquí básicamente definimos dos estados en función de si el ítem que nos pasa la lista de transiciones (un único ítem) existe o no
import React, { useState } from 'react'
import { useTransition, animated } from 'react-spring'
import styled from 'styled-components'
const Main = () => {
// defino la animación
const config = { mass: 1, tension: 1000, friction: 20 }
// estado para ver si estamos en modo noche o no
const [isDark, setIsDark] = useState(false)
// defino las transiciones para que desaparezca y se vaya para arriba
const transitions = useTransition(isDark, null, {
config,
from: { transform: `translateY(-50px)`, opacity: 0 },
enter: { transform: `translateY(0px)`, opacity: 1 },
leave: { transform: `translateY(-50px)`, opacity: 0 },
})
// construyo el componente en función de si el elemento existe o no
// ..el que no existe hará a la inversa del que existe
return (
<Div>
{transitions.map(({ item, key, props }) =>
item ? (
<animated.div key={key} style={props} onClick={() => setIsDark(false)}>
🌚
</animated.div>
) : (
<animated.div key={key} style={props} onClick={() => setIsDark(true)}>
🌝
</animated.div>
)
)}
</Div>
)
}
export default Main
const Div = styled.div`
font-size: 60px;
& > div {
position: absolute;
cursor: pointer;
}
`
Lista de elementos que se añaden
Vamos añadiendo o quitando elementos a la lista manualmente
import React, { useState } from 'react'
import { useTransition, animated } from 'react-spring'
import styled from 'styled-components'
const Main = () => {
// defino la animación
const config = { mass: 1, tension: 500, friction: 50 }
// frase
const phrase = 'bienvenido'
// estado para guardar la posición de la lista
const [index, setIndex] = useState(0)
// estado para guardar la lista
const [list, setList] = useState([])
// función para añadir un nuevo carácter a la lista
const add = () => {
setList([...list, { text: phrase[index], key: index }])
setIndex(index + 1)
}
// lo mismo, pero para quitar el carácter
const remove = () => {
setList([...list.slice(0, index - 1)])
setIndex(index - 1)
}
// defino las transiciones para que desaparezca y se vaya para arriba
// .. y añado la función item => item.key para que devuelva la key
const transitions = useTransition(list, item => item.key, {
config,
from: { transform: `translateY(-10px)`, opacity: 0 },
enter: { transform: `translateY(0px)`, opacity: 1 },
leave: { transform: `translateY(-10px)`, opacity: 0 },
})
// vuelco las transiciones
return (
<Div>
<div>
<button onClick={add}>añadir</button>
<button onClick={remove}>quitar</button>
</div>
<div>
{transitions.map(({ item, key, props }) => (
<animated.span key={key} style={props}>
{item.text}
</animated.span>
))}
</div>
</Div>
)
}
export default Main
const Div = styled.div`
font-size: 60px;
& button {
cursor: pointer;
}
& > div > span {
display: inline-block;
}
`
Lista de elementos que se añaden con un sólo click
En este caso es lo mismo que el anterior, pero en lugar de añadir cada elemento manualmente, definimos una multi-transición para recrear una especie de dca.animación
Y la manera de hacerlo es con una secuencia orquestada por el await y el async
Fíjate que lo único que cambio son las transiciones
import React, { useState } from 'react'
import { useTransition, animated } from 'react-spring'
import styled from 'styled-components'
const Main = () => {
// defino la animación
const config = { mass: 1, tension: 1000, friction: 50 }
// frase de inicio
const phrase = 'bienvenido'
// estado para guardar la posición de la lista
const [index, setIndex] = useState(0)
// estado para guardar la lista
const [list, setList] = useState([])
const add = () => {
setList([...list, { text: phrase[index], key: index }])
setIndex(index + 1)
}
const remove = () => {
setList([...list.slice(0, index - 1)])
setIndex(index - 1)
}
// defino las transiciones para que desaparezca y se vaya para arriba
const transitions = useTransition(list, item => item.key, {
config,
from: { transform: `translate3d(0px,-10px,0px) scale(10)`, opacity: 0 },
enter: { transform: `translate3d(0px,0px,0px) scale(1)`, opacity: 1 },
// alternativa secuencial
// enter: item => async (next, cancel) => {
// await next({transform: `translate3d(0px,0px,0px) scale(10)`, opacity: 1 })
// await next({transform: `translate3d(0px,0px,0px) scale(1)`, opacity: 1 })
// },
leave: item => async (next, cancel) => {
await next({ transform: `translate3d(0px,-10px,0px) scale(1)` })
await next({ transform: `translate3d(0px,-10px,0px) scale(10)`, opacity: 0 })
},
})
// construyo el componente a partir de las transiciones
return (
<Div>
<div>
<button onClick={add}>añadir</button>
<button onClick={remove}>quitar</button>
</div>
<div>
{transitions.map(({ item, key, props }, i) => (
<animated.span key={key} style={props}>
{item.text}
</animated.span>
))}
</div>
</Div>
)
}
export default Main
const Div = styled.div`
font-size: 60px;
& button {
cursor: pointer;
}
& > div > span {
display: inline-block;
}
`
Lista de imágenes de las que necesitamos leer las dimensiones
Si queremos mostrar imágenes, necesitamos poder sacar las dimensiones sin necesidad de entrarlas manualmente
Esto nos irá muy bien para luego implementar lo mismo pero con la técnica masonry
Lo haremos con el custom hook useMeasure
que nos devuelve la referencia y las dimensiones
Y aquí, en lugar de devolvernos una ref
y que nosotros tengamos que poner
<div ref={ref} />
el hook ya nos devuelve un objeto {ref: ref}
, con lo cual podemos simplemente volcar lo que nos recibe con
<div {...bind} />
y así si en algún momento quisiéramos añadir alguna otra propiedad podríamos aprovechar esta estructura
Entonces, al poner la referencia en el elemento que queramos, automáticamente podremos acceder a sus dimensiones a través de la propiedad contentRect
Y esto lo hacemos evidentemente con un useState
que se actualizará en cuanto asignemos esa referencia
Para emular las imágenes lo que haremos es generar unas dimensiones random y después leerlas como si fueran imágenes reales
Y esto lo haremos a través de una distribución en columna, esto es en una sola columna
import React, { useState, useRef, useEffect } from 'react'
import { useTransition, animated } from 'react-spring'
import styled from 'styled-components'
// funciones 'helpers' que me ayudan a generar una lista de imágenes
const get_random_gradient = () => {
const randomColour = () => '#000000'.replace(/0/g, () => (~~(Math.random() * 16)).toString(16))
const randomAngle = () => Math.round(Math.random() * 360)
return 'linear-gradient(' + randomAngle() + 'deg, ' + randomColour() + ', ' + randomColour() + ')'
}
const get_random_number = (min, max) => Math.random() * (max - min) + min
// hook para leer las dimensiones de la imagen
const useMeasure = () => {
const ref = useRef()
const [bounds, set] = useState({ left: 0, top: 0, width: 0, height: 0 })
const [ro] = useState(() => new ResizeObserver(([entry]) => set(entry.contentRect)))
useEffect(() => (ro.observe(ref.current), ro.disconnect), [])
return [{ ref }, bounds]
}
// defino las imágenes con dimensiones random
const images = [...Array(10)].map((el, i) => ({
key: i,
width: 200,
unknownHeight: get_random_number(30, 300),
currentHeight: 0,
background: get_random_gradient(),
}))
// el componente de la imagen
const Image = ({ image, reSetElements }) => {
// utilizo el hook
const [bind, { height }] = useMeasure()
// asigno el valor de height a la imagen que recibo para así poder acceder a ella
useEffect(() => {
const h = parseInt(height)
if (image.currentHeight !== h) {
reSetElements(image, h)
}
}, [height])
return (
<ImageDiv
{...bind}
style={{
width: image.width,
height: image.unknownHeight,
background: image.background,
}}
>
{image.key}: {image.currentHeight}
</ImageDiv>
)
}
const ImageDiv = styled.div`
margin: 10px;
border-radius: 5px;
box-shadow: 0px 0px 10px -4px #676767;
height: 100%;
font-size: 22px;
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
`
// el componente principal
const Main = () => {
// defino la animación
const config = { mass: 1, tension: 1000, friction: 50 }
// defino el estado donde guardaré la lista de imágenes
const [elements, setElements] = useState(images)
const reSetElements = (image, h) => {
elements.find(el => el.key === image.key).currentHeight = h
setElements([...elements])
}
// defino la altura de la lista que empieza con un valor de 0
let columnHeight = 0
// defino las transiciones, necesitaré una transición para cada elemento
const transitions = useTransition(
elements.map(el => ({
...el,
y: (columnHeight += el.currentHeight) - el.currentHeight,
})),
item => item.key,
{
config,
from: { height: 0, opacity: 0 },
leave: { height: 0, opacity: 0 },
enter: ({ y, currentHeight }) => ({ y, currentHeight, opacity: 1 }),
update: ({ y, currentHeight }) => ({ y, currentHeight }),
}
)
// construyo el componente a partir de las transiciones
return (
<Div style={{ columnHeight }}>
<button onClick={() => setElements([...elements].sort(() => 0.5 - Math.random()))}>Mezclar</button>
{transitions.map(({ item, key, props: { y, ...rest } }, i) => (
<animated.div
key={key}
style={{
transform: y.interpolate(y => `translate3d(0,${y}px,0)`),
...rest,
}}
>
<Image image={item} reSetElements={reSetElements} />
</animated.div>
))}
</Div>
)
}
export default Main
const Div = styled.div`
& button {
cursor: pointer;
}
`
En este caso el código se complica un poco
Primero tenemos el custom hook useMeasure
, que nos devuelve la referencia y el contentRect
, y a nivel interno inicializa un ResizeObserver
que lo guarda en un estado y que cuando el componente se desmonta lo desconecta
Segundo tenemos la formación de imágenes que simplemente genera una lista con un height
y un background
aleatorios
Después el componente Image
que utiliza el hook para volcar la referencia en la imagen y definirle las dimensiones (en realidad esas dimensiones vendrían dadas por la imagen, pero aquí se las damos porque las hemos creado artificialmente - y las he llamado unknownHeight
)
Este componente llama a la función reSetElements
que actualiza esas dimensiones (aquí sólo el height
) en el estado pertinente
Y finalmente el componente principal Main
donde genero la lista con un useState
y utilizo las imágenes que he generado antes de inicio
También fijo la altura de la columna en cero
Para generar el efecto de posicionar los elementos react-spring
necesita saber las coordenadas (lo hace posicionando los elementos con translate3d
), que aquí como es sólo una columna se reduce a saber la posición y
Eso lo sabremos porque iremos colocando cada elemento uno a uno
Y en cuanto lo pongamos, iremos incrementando el valor de columnHeight
, y así sabremos el siguiente elemento dónde posicionarlo (esto lo hacemos en el primer argumento de useTransition
)
Luego generamos las transiciones con useTransition
El primer argumento es la lista de elementos, que es donde definimos para cada elemento su posición y
*En realidad el columnHeight
nos sirve para contar las alturas, después no necesitamos ponerlo como estilo en <Div style={{ columnHeight }}>
, pero lo ponemos porque para el masonry luego sí que lo necesitaremos especificar
El segundo argumento lo utilizamos para volcar la key
El tercer argumento lo utilizamos para definir las transiciones en sí
Y luego ya volcamos el JSX
que utiliza el componente anterior <Image>
Hay una novedad y es el uso del estilo
<animated.div
style={{
transform: y.interpolate(y => `translate3d(0,${y}px,0)`),
}}
/>
Esta función interpolate
nos la da react-spring
e implementa la transición en sí
Técnica masonry
Y ahora ya sí, ya tenemos las bases para implementar una distribución masonry con react-spring
La diferencia con la anterior colocación de elementos en una columna es precisamente que aquí tendremos más columnas
Por lo tanto algo que haremos será calcular el número de columnas que podemos poner, y el ancho de las imágenes vendrá dado por este ancho de columna en lugar de fijarlo en 200px
Esto lo haremos responsive aprovechando el hook
que nos dan en la documentación de react-spring
en el mismo ejemplo masonry
A partir de aquí, utilizaremos la misma estrategia pero no sólo calcularemos la y
de cada elemento sino también la x
Y también hay una parte de código para emular las imágenes que no sería demasiado relevante, ya que al volcar una imagen en html
esta ocupa sus dimensiones intrínsecas
Cuando aquí lo hago con un <div>
tengo que darle dimensiones específicas, y después quitárselas ya que sino no se adaptan
Excepto estos detalles, los códigos son muy parecidos
import React, { useState, useRef, useEffect } from 'react'
import { useTransition, animated } from 'react-spring'
import styled from 'styled-components'
// funciones 'helpers' que me ayudan a generar una lista de imágenes
const get_random_gradient = () => {
const randomColour = () => '#000000'.replace(/0/g, () => (~~(Math.random() * 16)).toString(16))
const randomAngle = () => Math.round(Math.random() * 360)
return 'linear-gradient(' + randomAngle() + 'deg, ' + randomColour() + ', ' + randomColour() + ')'
}
const get_random_number = (min, max) => Math.random() * (max - min) + min
// hook para medir la width de nuestro contenedor y devolver las columnas
const useMedia = (queries, values, defaultValue) => {
const match = () => values[queries.findIndex(q => matchMedia(q).matches)] || defaultValue
const [value, set] = useState(match)
useEffect(() => {
const handler = () => set(match)
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
}, [])
return value
}
// hook para leer las dimensiones de la imagen
const useMeasure = () => {
const ref = useRef()
const [bounds, set] = useState({ left: 0, top: 0, width: 0, height: 0 })
const [ro] = useState(() => new ResizeObserver(([entry]) => set(entry.contentRect)))
useEffect(() => (ro.observe(ref.current), ro.disconnect), [])
return [{ ref }, bounds]
}
// defino las imágenes con dimensiones random
const images = [...Array(10)].map((el, i) => ({
key: i,
unknownWidth: get_random_number(200, 300),
unknownHeight: get_random_number(200, 300),
currentWidth: 0,
currentHeight: 0,
currentRatio: 1,
background: get_random_gradient(),
}))
// el componente de la imagen
const Image = ({ image, reSetElements }) => {
// utilizo el hook
const [bind, { width, height }] = useMeasure()
// asigno el valor de height a la imagen que recibo para así poder acceder a ella
useEffect(() => {
const h = parseInt(height)
const w = parseInt(width)
if (image.currentHeight === 0) reSetElements(image, h, w)
}, [height])
return (
<ImageDiv
{...bind}
style={{
width: image.currentWidth ? '100%' : image.unknownWidth,
height: image.currentHeight ? '100%' : image.unknownHeight,
}}
>
<div
style={{
background: image.background,
}}
>
{image.key}: {image.currentWidth} x {image.currentHeight}
</div>
</ImageDiv>
)
}
const ImageDiv = styled.div`
font-size: 22px;
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
& > div {
margin: 10px;
border-radius: 5px;
box-shadow: 0px 0px 10px -4px #676767;
width: 90%;
height: 90%;
}
`
// el componente principal
const Main = () => {
// defino la animación
const config = { mass: 1, tension: 1000, friction: 50 }
// defino las columnas
const columns = useMedia(['(min-width: 1500px)', '(min-width: 1000px)', '(min-width: 600px)'], [5, 4, 3], 2)
// mido la anchura del container
const [bind, { width }] = useMeasure()
// defino el estado donde guardaré la lista de imágenes
const [elements, setElements] = useState(images)
// función para actualizar el width y height de cada elemento una vez se rendericen
const reSetElements = (image, h, w) => {
const el = images.find(el => el.key === image.key)
el.currentHeight = h
el.currentWidth = w
el.currentRatio = w / h
if (images.filter(el => el.currentHeight === 0).length === 0) {
setElements([...elements])
}
}
// defino la altura de las columnas
const columnHeights = Array(columns).fill(0)
// defino las transiciones, necesitaré una transición para cada elemento
const transitions = useTransition(
elements.map(el => {
if (el.currentHeight === 0) return { ...el, xy: [0, 0], width: 0, height: 0 }
// La columna de menor altura
const column = columnHeights.indexOf(Math.min(...columnHeights))
// el ancho del elemento basado en la columna
const elWidth = (width / columns) * column
// la altura del elemento considerando su ratio de serie
const elHeight = width / columns / el.currentRatio
// La posición X y la posición Y
const xy = [elWidth, (columnHeights[column] += elHeight) - elHeight]
return { ...el, xy, width: width / columns, height: elHeight }
}),
item => item.key,
{
config,
from: ({ xy, width, height }) => ({ xy, width, height, opacity: 0 }),
leave: { height: 0, opacity: 0 },
enter: ({ xy, width, height }) => ({ xy, width, height, opacity: 1 }),
update: ({ xy, width, height }) => ({ xy, width, height }),
}
)
// construyo el componente a partir de las transiciones
return (
<Div {...bind} style={{ height: Math.max(...columnHeights) }}>
<button onClick={() => setElements([...elements].sort(() => 0.5 - Math.random()))}>Mezclar</button>
{transitions.map(({ item, key, props: { xy, ...rest } }, i) => (
<animated.div
key={key}
style={{
transform: xy.interpolate((x, y) => `translate3d(${x}px,${y}px,0)`),
...rest,
}}
>
<Image image={item} reSetElements={reSetElements} />
</animated.div>
))}
</Div>
)
}
export default Main
const Div = styled.div`
& button {
cursor: pointer;
}
& div {
position: absolute;
}
`
-
El
useMedia
nos devuelve el número de columnas dadas las dimensiones actuales de la ventana del navegador, y actualizándose a cada evento deresize
-
El
useMeasure
repite, lo utilizamos para todas las pseudo-imágenes y también para el contenedor donde va nuestra malla -
Repetimos estructura de generación de imágenes, pero añadimos el
currentRatio
Esto lo necesitamos porque escalaremos las imágenes
Si éstas están a 200x300
de original, pero la columna tiene un ancho de 150px
, entonces utilizaré el ratio para calcular la height
adaptada
-
El componente
<Image>
repite, aunque ahora monitoreo altura y anchura, y reseteo la estructura de los elementos sólo cuando hay una altura real -
Luego ya tenemos el componente principal
<Main>
donde utilizo los hooks de antes y el estado donde guardaré la estructura de mis datos -
La función para resetear esta estructura
reSetElements
fíjate que sólo actualiza el estado cuando no hay ninguna imagen con altura monitoreada de cero, es decir que se espera a que tengamos todas las alturas modificadas
Esto es un ejercicio de optimización, pero si tus imágenes son de servidores externos esto no lo querrás ya que si alguna imagen no se carga no te permitirá renderizar nada
- La generación de las transiciones con
useTransition
Parece complicada, pero al final se trata de
- Calcular qué columna tiene la altura más baja para añadir allí en nuevo elemento
- Calcular el ancho de esa columna, que será el ancho del elemento
- Calcular la altura del elemento basado en ese ancho
- Calcular las coordenadas X e Y donde irá ese elemento
- Devolver el mismo elemento pero extendido con las coordenadas, anchura y altura
Y por lo demás no hay demasiado misterio
Comentario
Al final implementar una técnica masonry sea cual sea la versión que escojas va a ser algo ofuscado
Pero no hay como practicar para ir interiorizando conceptos, y que lo que te parecía negro termine siendo un pálido agradable
🙋♂️
Lista de correo: escribo algo de tanto en cuanto