Lasst uns einen Multi-Thumb-Schieberegler erstellen, der die Breite zwischen den Daumen berechnet

Avatar of Simdi Jinkins
Simdi Jinkins am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

HTML hat ein <input type="range">, das man, so könnte man argumentieren, als einfachste Art von Proportionalschieberegler bezeichnen kann. Wo auch immer der Daumen dieses Schiebereglers landet, könnte er einen Anteil dessen darstellen, was davor und was danach kommt (unter Verwendung der Attribute value und max). Fortgeschrittener ist es möglich, einen Multi-Thumb-Schieberegler zu erstellen. Aber wir haben heute etwas anderes vor... einen Proportionalschieberegler mit mehreren Daumen und sich nicht überlappenden Abschnitten.

Hier ist, was wir heute bauen werden

Dies ist ein Schieberegler, aber nicht irgendeiner. Es ist ein *Proportionalitäts*-Schieberegler. Seine verschiedenen Abschnitte müssen sich zu 100 % addieren und der Benutzer kann die verschiedenen Abschnitte darin anpassen.

Warum sollte man so etwas brauchen?

Vielleicht möchten Sie eine Budget-App erstellen, bei der ein solcher Schieberegler Ihre verschiedenen geplanten Ausgaben darstellt.

Ich brauchte etwas Ähnliches, um eine Plattform für Anime-Filmvorschläge zu erstellen. Bei der Recherche von UX-Mustern fiel mir auf, dass andere Anime-Filmvorschlagsseiten so etwas haben, bei dem man Tags auswählt, um Filmempfehlungen zu erhalten. Das ist alles schön und gut, aber wie viel cooler wäre es, diesen Tags *Gewicht* zu geben, damit Empfehlungen für einen Tag mit höherem Gewicht Vorrang vor anderen Tags mit geringerem Gewicht haben? Ich habe mich umgesehen, aber nicht viel gefunden.

Also habe ich das hier gebaut! Und glauben Sie es oder nicht, das ist gar nicht so kompliziert. Lassen Sie mich Sie durch die Schritte führen.

Der statische Schieberegler

Wir werden dies in React und TypeScript bauen. Das ist aber nicht zwingend erforderlich. Die Konzepte sollten sich auf reines JavaScript oder jedes andere Framework übertragen lassen.

Erstellen wir diesen Schieberegler mit dynamischen Daten für den Anfang. Wir halten die verschiedenen Abschnitte in einem Variablen-Array mit dem Namen und der Farbe jedes Abschnitts. 

const _tags = [
  {
    name: "Action",
    color: "red"
  },
  {
    name: "Romance",
    color: "purple"
  },
  {
    name: "Comedy",
    color: "orange"
  },
  {
    name: "Horror",
    color: "black"
  }
];

Die Breite jedes Tag-Abschnitts wird durch ein Array von Prozentwerten gesteuert, die sich zu 100 % addieren. Dies geschieht unter Verwendung der Methode Array.Fill(), um den Zustand jeder Breite zu initialisieren.

const [widths, setWidths] = useState<number[]>(new Array(_tags.length).fill(100 / _tags.length))

Als Nächstes erstellen wir eine Komponente, um einen einzelnen Tag-Abschnitt zu rendern.

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 >
}

Dann rendern wir alle Abschnitte, indem wir das _tags-Array durchlaufen und die oben erstellte TagSection-Komponente zurückgeben.

const TagSlider = () => {
  const [widths, setWidths] = useState<number[]>((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>
}

Um abgerundete Ränder zu erzeugen und den letzten Schieberegler-Button auszublenden, verwenden wir die Pseudo-Selektoren :first-of-type und :last-of-type in CSS.

.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;
}

Hier sind wir bisher. Beachten Sie, dass die Schieberegler-Handles noch nichts bewirken! Dazu kommen wir als Nächstes.

Einstellbare Schieberegler-Abschnitte

Wir möchten, dass sich die Schieberegler-Abschnitte bewegen, wenn die Schieberegler-Buttons mit der Maus oder per Touch bedient werden. Das tun wir, indem wir sicherstellen, dass die Breitenänderungen der Abschnitte mit der Bewegung der Schieberegler-Buttons übereinstimmen. Dazu müssen wir einige Fragen beantworten:

  1. Wie ermitteln wir die Cursorposition, wenn der Schieberegler-Button angeklickt wird?
  2. Wie ermitteln wir die Cursorposition, während der Schieberegler-Button gezogen wird?
  3. Wie können wir die Breite des Tag-Abschnitts so gestalten, dass sie der Bewegung des Tag-Abschnitts-Buttons entspricht?

Eins nach dem anderen…

Wie ermitteln wir die Cursorposition, wenn der Schieberegler-Button angeklickt wird?

Fügen wir der TagSectionProps-Schnittstelle einen onSliderSelect-Ereignishandler hinzu.

interface TagSectionProps {
  name: string;
  color: string;
  width: number;
  onSliderSelect: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
}

Der onSliderSelect-Ereignishandler wird an das onPointerDown-Ereignis der TagSection-Komponente angehängt.

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>
  );
};

Wir verwenden onPointerDown anstelle von onMouseDown, um *sowohl* Maus- als auch Touchscreen-Ereignisse abzufangen. Hier finden Sie weitere Informationen dazu. 

Wir verwenden e.pageX im onSliderSelect-Prop-Funktion, um die Position des Cursors zu ermitteln, wenn der Button des Schiebereglers angeklickt wurde.

<TagSection
  width={widths[index]}
  key={index}
  name={tag.name}
  onSliderSelect={(e) => {
    const startDragX = e.pageX;
  }}
/>

Eins geschafft!

Wie ermitteln wir die Cursorposition, während der Schieberegler-Button gezogen wird?

Nun müssen wir einen Event-Listener hinzufügen, um die Drag-Ereignisse pointermove und touchmove zu überwachen. Wir verwenden diese Ereignisse, um Mauscursor- und Touch-Bewegungen abzudecken. Die Breiten der Abschnitte müssen die Aktualisierung stoppen, sobald der Benutzer seinen Finger vom Bildschirm nimmt (wodurch der Drag beendet wird): 

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);

Die Funktion resize gibt die X-Koordinate des Cursors zurück, während der Schieberegler-Button gezogen wird.

const resize = (e: MouseEvent & TouchEvent) => {
  e.preventDefault();
  const endDragX = e.touches ? e.touches[0].pageX : e.pageX
}

Wenn die Funktion resize durch ein Touch-Ereignis ausgelöst wird, ist e.touches ein Array-Wert – andernfalls ist es null. In diesem Fall nimmt endDragX den Wert von e.pageX an. 

Wie können wir die Breite des Tag-Abschnitts so gestalten, dass sie der Bewegung des Tag-Abschnitts-Buttons entspricht?

Um die Breitenprozente der verschiedenen Tag-Abschnitte zu ändern, ermitteln wir die Distanz, die der Cursor im Verhältnis zur Gesamtbreite des Schiebereglers als Prozentsatz zurücklegt. Von dort weisen wir diesen Wert dem Tag-Abschnitt zu.

Zuerst müssen wir mit dem useRef Hook von React die ref von TagSlider erhalten.

const TagSlider = () => {
const TagSliderRef = useRef<HTMLDivElement>(null);
  // TagSlider
  return (
    <div
      ref={TagSliderRef}
// ...

Nun ermitteln wir die Breite des Schiebereglers, indem wir seine Referenz verwenden, um die offsetWidth-Eigenschaft zu erhalten, die die Layout-Breite eines Elements als Integer zurückgibt.

onSliderSelect={(e) => {
  e.preventDefault();
  document.body.style.cursor = 'ew-resize';


  const startDragX = e.pageX;
  const sliderWidth = TagSliderRef.current.offsetWidth;
}};

Dann berechnen wir den prozentualen Abstand, den der Cursor relativ zum gesamten Schieberegler zurückgelegt hat.

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);
}

Schließlich können wir die neu berechnete Breiten des Abschnitts seinem Index in der _widths-Zustandsvariable zuweisen.

const percentageMoved = getPercentage(sliderWidth, distanceMoved);
const _widths = widths.slice();
const prevPercentage = _widths[index];
const newPercentage = prevPercentage + percentageMoved


_widths[index] = newPercentage;
setWidths(_widths);

Aber das ist noch nicht alles! Die anderen Abschnitte ändern ihre Breite nicht, und die Prozentsätze können negativ werden oder sich zu mehr als 100 % addieren. Ganz zu schweigen davon, dass die Summe aller Breiten der Abschnitte nicht immer gleich 100 % ist, da wir keine Einschränkung angewendet haben, die verhindert, dass sich der Gesamtprozentsatz ändert.

Die anderen Abschnitte korrigieren

Stellen wir sicher, dass sich die Breite eines Abschnitts ändert, wenn sich der Abschnitt daneben ändert.

const nextSectionNewPercentage = percentageMoved < 0 
  ? _widths[nextSectionIndex] + Math.abs(percentageMoved)
  : _widths[nextSectionIndex] - Math.abs(percentageMoved)

Dies hat den Effekt, dass die Breite des benachbarten Abschnitts reduziert wird, wenn der Abschnitt zunimmt und umgekehrt. Wir können ihn sogar verkürzen.

const nextSectionNewPercentage = _widths[nextSectionIndex] - percentageMoved

Das Anpassen eines Abschnitts-Prozentsatzes sollte nur seinen rechten Nachbarn beeinflussen. Das bedeutet, dass der Maximalwert des maximalen Prozentwerts eines gegebenen Abschnitts seine Breite zuzüglich der Breite seines Nachbarn ist, wenn dieser seine gesamte Breite einnehmen darf.

Das können wir erreichen, indem wir einen maximalen Prozentwert berechnen.

const maxPercent = widths[index] + widths[index+1]

Um negative Breitenwerte zu verhindern, beschränken wir die Breiten auf Werte größer als Null, aber kleiner als der maximale Prozentsatz: 

const limitNumberWithinRange = (value: number, min: number, max: number):number => {
  return Math.min(Math.max(value,min),max)
}

Die Funktion limitNumberWithinRange verhindert sowohl negative Werte als auch Fälle, in denen die Summe der Abschnitte zu einem Wert über dem maximalen Prozentsatz führt. (Ein Hut-Tipp an diesen StavkOverflow-Thread.)

Wir können diese Funktion für die Breite des aktuellen Abschnitts und seines Nachbarn verwenden.

const currentSectionWidth = limitNumberWithinRange(newPercentage, 0, maxPercent)
_widths[index] = currentSectionWidth


const nextSectionWidth = limitNumberWithinRange(nextSectionNewPercentage, 0, maxPercent);
_widths[nextSectionIndex] = nextSectionWidth;

Zusätzliche Berührungen

Derzeit berechnet der Schieberegler die Breite jedes Abschnitts als Prozentsatz des gesamten Containers mit verrückten Dezimalstellen. Das ist super präzise, aber für diese Art von Benutzeroberfläche nicht gerade nützlich. Wenn wir stattdessen mit ganzen Zahlen statt mit Dezimalstellen arbeiten wollen, können wir etwas wie das hier tun.

const nearestN = (N: number, number: number) => Math.ceil(number / N) * N;
const percentageMoved = nearestN(1, getPercentage(sliderWidth, distanceMoved))

Diese Funktion rundet den Wert des zweiten Parameters auf das nächste N (angegeben durch den ersten Parameter) auf. Wenn  N auf 1 gesetzt wird, wie in diesem Beispiel, wird die Prozentänderung in ganzen Zahlen statt in winzigen inkrementellen Dezimalstellen erfolgen.

Eine weitere nette Berührung ist die zusätzliche Behandlung von Abschnitten mit einem Prozentsatz von Null. Diese sollten wahrscheinlich ganz aus dem Schieberegler entfernt werden, da sie keinen Anteil mehr an der Gesamtbreite haben. Wir können aufhören, auf Ereignisse von diesen Abschnitten zu hören.

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à!

Hier ist der endgültige Schieberegler.