Hagamos un control deslizante de varios pulgares que calcula el ancho entre los pulgares.
HTML tiene un control input type="range"
deslizante de proporción, que se podría argumentar que es el tipo más simple de control deslizante de proporción. Dondequiera que termine el pulgar de ese control deslizante podría representar una proporción de lo que está antes y lo que está después (usando los atributos value
y max
). Cada vez más cómodo, es posible crear un control deslizante con varios pulgares. Pero hoy tenemos otra cosa en mente… un control deslizante de proporciones con múltiples pulgares y secciones que no pueden superponerse.
Esto es lo que construiremos hoy:
Este es un control deslizante, pero no cualquier control deslizante. Es un control deslizante de proporciones. Todas sus distintas secciones deben sumar el 100% y el usuario puede ajustar las diferentes secciones que contiene.
¿Por qué necesitarías algo así?
Tal vez quieras crear una aplicación de presupuesto donde un control deslizante como este conste de tus diversos gastos planificados:
Necesitaba algo como esto para crear una plataforma de sugerencias de películas de anime. Mientras investigaba patrones de UX, note que otros sitios web de sugerencias de películas de anime tienen este tipo de cosas en las que seleccionas etiquetas para obtener recomendaciones de películas. Eso está bien y todo, pero sería mucho mejor agregar peso a esas etiquetas para que las recomendaciones para una etiqueta con un peso mayor tengan prioridad sobre otras etiquetas con pesos más livianos. Busqué otras ideas, pero no encontré muchas.
¡Así que construí esto! Y lo creas o no, hacerlo no es tan complicado. Déjame guiarte por los pasos.
El control deslizante estático
Construiremos esto en React y TypeScript. Aunque eso no es necesario. Los conceptos deben trasladarse a JavaScript básico o cualquier otro marco.
Hagamos este control deslizante usando datos dinámicos para empezar. Mantendremos las diferentes secciones en una matriz variable con el nombre y color de cada sección.
const _tags = [ { name: "Action", color: "red" }, { name: "Romance", color: "purple" }, { name: "Comedy", color: "orange" }, { name: "Horror", color: "black" }];
El ancho de cada sección de etiqueta está controlado por una serie de porcentajes que suman 100%. Esto se hace utilizando el Array.Fill()
método para inicializar el estado de cada ancho:
const [widths, setWidths] = useStatenumber[](new Array(_tags.length).fill(100 / _tags.length))
A continuación, crearemos un componente para representar una sección de etiqueta única:
interface TagSectionProps { name: string color: string width: number}
const TagSection = ({ name, color, width }: TagSectionProps) = { return div className='tag' style={{ ...styles.tag, background: color, width: width + '%' }} span style={styles.tagText}{name}/span div style={styles.sliderButton} className='slider-button' img src={"https://assets.codepen.io/576444/slider-arrows.svg"} height={'30%'} / /div /div }
Luego representaremos todas las secciones mapeando la _tags
matriz y devolveremos el TagSection
componente que creamos anteriormente:
const TagSlider = () = { const [widths, setWidths] = useStatenumber[]((new Array(_tags.length).fill(100 / _tags.length))) return div style={{ width: '100%', display: 'flex' }} { _tags.map((tag, index) = TagSection width={widths[index]} key={index} name={tag.name} color={tag.color} /) } /div}
Para hacer bordes redondeados y ocultar el último botón deslizante, usemos los pseudoselectores :first-of-type
y en CSS::last-of-type
.tag:first-of-type { border-radius: 50px 0px 0px 50px;}.tag:last-of-type { border-radius: 0px 50px 50px 0px;}.tag:last-of-type.slider-button { display:none !important;}
Aquí es donde estamos hasta ahora. ¡Tenga en cuenta que los controles deslizantes no hacen nada todavía! Llegaremos a eso a continuación.
Secciones deslizantes ajustables
Queremos que las secciones del control deslizante se ajusten al movimiento cuando los botones del control deslizante se arrastran con el cursor del mouse o con el toque. Lo haremos asegurándonos de que los cambios en el ancho de la sección corresponden a cuánto se han arrastrado los botones del control deslizante. Esto requiere que respondamos algunas preguntas:
- ¿Cómo obtenemos la posición del cursor cuando se hace clic en el botón deslizante?
- ¿Cómo obtenemos la posición del cursor mientras se arrastra el botón deslizante?
- ¿Cómo podemos hacer que el ancho de la sección de etiquetas corresponde con cuánto se ha arrastrado el botón de la sección de etiquetas?
Uno a uno…
¿Cómo obtenemos la posición del cursor cuando se hace clic en el botón deslizante?
Agregamos un onSliderSelect
controlador de eventos a la TagSectionProps
interfaz:
interface TagSectionProps { name: string; color: string; width: number; onSliderSelect: (e: React.MouseEventHTMLDivElement, MouseEvent) = void;}
El onSliderSelect
controlador de eventos se adjunta al onPointerDown
evento del TagSection
componente:
const TagSection = ({ name, color, width, onSliderSelect // Highlight}: TagSectionProps) = { return ( div className="tag" style={{ ...styles.tag, background: color, width: width + "%" }} span style={styles.tagText}{name}/span span style={{ ...styles.tagText, fontSize: 12 }}{width + "%"}/span div style={styles.sliderButton} onPointerDown={onSliderSelect} className="slider-button" img src={"https://animesonar.com/slider-arrows.svg"} height={"30%"} / /div /div );};
Usamos onPointerDown
en lugar de onMouseDown
para detectar eventos del mouse y de la pantalla táctil. Aquí hay más información al respecto.
Estamos usando la función prop para obtener la posición del cursor cuando se hace clic en el botón del control deslizante:e.pageX
onSliderSelect
TagSection width={widths[index]} key={index} name={tag.name} onSliderSelect={(e) = { const startDragX = e.pageX; }}/
¡Uno abajo!
¿Cómo obtenemos la posición del cursor mientras se arrastra el botón deslizante?
Ahora necesitamos agregar un detector de eventos para escuchar los eventos de arrastre pointermove
y touchmove
. Usaremos estos eventos para cubrir el cursor del mouse y los movimientos táctiles. Los anchos de las secciones deben dejar de actualizarse una vez que el dedo del usuario se levanta de la pantalla (finalizando así el arrastre):
window.addEventListener("pointermove", resize);window.addEventListener("touchmove", resize);
const removeEventListener = () = { window.removeEventListener("pointermove", resize); window.removeEventListener("touchmove", resize);}
const handleEventUp = (e: Event) = { e.preventDefault(); document.body.style.cursor = "initial"; removeEventListener();}
window.addEventListener("touchend", handleEventUp);window.addEventListener("pointerup", handleEventUp);
La resize
función proporciona la coordenada X del cursor mientras se arrastra el botón deslizante:
const resize = (e: MouseEvent TouchEvent) = { e.preventDefault(); const endDragX = e.touches ? e.touches[0].pageX : e.pageX}
Cuando la resize
función se activa mediante un evento táctil, e.touches
es un valor de matriz; de lo contrario, es nulo, en cuyo caso endDragX
toma el valor de e.pageX
.
¿Cómo podemos hacer que el ancho de la sección de etiquetas corresponde con cuánto se ha arrastrado el botón de la sección de etiquetas?
Para cambiar los porcentajes de ancho de las distintas secciones de etiquetas, obtenemos la distancia que se mueve el cursor en relación con todo el ancho del control deslizante como porcentaje. A partir de ahí, asignaremos ese valor a la sección de etiquetas.
Primero, necesitamos aprender ref
a TagSlider
usar el gancho de React useRef
:
const TagSlider = () = {const TagSliderRef = useRefHTMLDivElement(null); // TagSlider return ( div ref={TagSliderRef}// ...
Ahora determinamos el ancho del control deslizante usando su referencia para obtener la offsetWidth
propiedad, que devuelve el ancho del diseño de un elemento como un número entero:
onSliderSelect={(e) = { e.preventDefault(); document.body.style.cursor = 'ew-resize';
const startDragX = e.pageX; const sliderWidth = TagSliderRef.current.offsetWidth;}};
Luego calculamos el porcentaje de distancia que se movió el cursor con respecto a todo el control deslizante:
const getPercentage = (containerWidth: number, distanceMoved: number) = { return (distanceMoved / containerWidth) * 100;};
const resize = (e: MouseEvent TouchEvent) = { e.preventDefault(); const endDragX = e.touches ? e.touches[0].pageX : e.pageX; const distanceMoved = endDragX - startDragX; const percentageMoved = getPercentage(sliderWidth, distanceMoved);}
Finalmente, podemos asignar el ancho de sección recién calculado a su índice en la _widths
variable de estado:
const percentageMoved = getPercentage(sliderWidth, distanceMoved);const _widths = widths.slice();const prevPercentage = _widths[index];const newPercentage = prevPercentage + percentageMoved
_widths[index] = newPercentage;setWidths(_widths);
¡Pero este no es el final! Las otras secciones no cambian de ancho y los porcentajes pueden terminar siendo negativos o sumar más del 100%. Sin mencionar que la suma de todos los anchos de las secciones no siempre es igual al 100% porque no hemos aplicado una restricción que impida que cambie el porcentaje general.
Arreglando las otras secciones
Asegurémonos de que el ancho de una sección cambie cuando cambie la sección contigua.
const nextSectionNewPercentage = percentageMoved 0 ? _widths[nextSectionIndex] + Math.abs(percentageMoved) : _widths[nextSectionIndex] - Math.abs(percentageMoved)
Esto tiene el efecto de reducir el ancho de la sección vecina si la sección aumenta y viceversa. Incluso podemos acortarlo:
const nextSectionNewPercentage = _widths[nextSectionIndex] - percentageMoved
Ajustar el porcentaje de una sección solo debería afectar a su vecina a la derecha. Esto significa que el valor máximo de un porcentaje máximo de sección determinado debe ser su ancho más el ancho de su vecino cuando se le permite ocupar todo el espacio del vecino.
Podemos hacer que eso suceda calculando un valor porcentual máximo:
const maxPercent = widths[index] + widths[index+1]
Para evitar valores de ancho negativos, restrinjamos los anchos a valores mayores que cero pero menores que el porcentaje máximo:
const limitNumberWithinRange = (value: number, min: number, max: number):number = { return Math.min(Math.max(value,min),max)}
La limitNumberWithinRange
función evita valores negativos y casos en los que la suma de secciones da como resultado un valor superior al porcentaje máximo. (Un consejo para este hilo de StavkOverflow).
Podemos usar esta función para el ancho de la sección actual y su vecina:
const currentSectionWidth = limitNumberWithinRange(newPercentage, 0, maxPercent)_widths[index] = currentSectionWidth
const nextSectionWidth = limitNumberWithinRange(nextSectionNewPercentage, 0, maxPercent);_widths[nextSectionIndex] = nextSectionWidth;
Toques extra
En este momento, el control deslizante calcula el ancho de cada sección como un porcentaje del contenedor completo con un decimal loco. Eso es súper preciso, pero no exactamente útil para este tipo de interfaz de usuario. Si queremos trabajar con números enteros en lugar de decimales, podemos hacer algo como esto:
const nearestN = (N: number, number: number) = Math.ceil(number / N) * N;const percentageMoved = nearestN(1, getPercentage(sliderWidth, distanceMoved))
Esta función aproxima el valor del segundo parámetro al más cercano N
(especificado por el primer parámetro). Configurar N
como 1
este ejemplo tiene el efecto de hacer que el cambio porcentual sea en números enteros en lugar de pequeños decimales incrementales.
Otro punto interesante es considerar un manejo adicional para las secciones con un valor porcentual cero. Probablemente deberían eliminarse del control deslizante por completo, ya que ya no ocupan ninguna proporción del ancho total. Podemos dejar de escuchar eventos en esas secciones:
if (tags.length 2) { if (_widths[index] === 0) { _widths[nextSectionIndex] = maxPercent; _widths.splice(index, 1); setTags(tags.filter((t, i) = i !== index)); removeEventListener(); } if (_widths[nextSectionIndex] === 0) { _widths[index] = maxPercent; _widths.splice(nextSectionIndex, 1); setTags(tags.filter((t, i) = i !== nextSectionIndex)); removeEventListener(); }}
¡Voilá!
Aquí está el control deslizante final:
Deja un comentario