Dieser Artikel ist eine Schritt-für-Schritt-Anleitung, wie ich diese Demo einer einzigartigen Möglichkeit, durch Panels zu scrollen, erstellt habe.
Der Code in dieser Demo ist (hoffentlich) ziemlich geradlinig und leicht verständlich. Keine npm-Module oder ES2015-Sachen hier, ich habe mich für klassisches jQuery, SCSS und HTML (plus ein wenig Greensock) entschieden.
Ich habe die Idee von einem zufälligen Twitter-Link bekommen
Als ich neulich durch meinen Twitter-Feed scrollte, sah ich einen Link zu jetlag.photos. Ich war erstaunt über den Effekt und dachte, ich würde versuchen, ihn nachzubilden, ohne mir ihren Quellcode anzusehen.

Später, als ich doch nachsah, sah ich, dass ihre Implementierung auf <canvas> basierte, womit ich sowieso schlecht bin, also bin ich froh, dass ich bei meinem Können geblieben bin.
Beginnen wir mit dem HTML
Wir benötigen einen Container in voller Bildschirmgröße mit einigen Containern darin für jedes Panel. Wir versehen hier alles mit dem Präfix ws- für "wavescroll".
<div class="ws-pages">
<div class="ws-bgs">
<div class="ws-bg"></div>
<div class="ws-bg"></div>
<div class="ws-bg"></div>
<div class="ws-bg"></div>
<div class="ws-bg"></div>
</div>
</div>
Wir haben einen zusätzlichen Container, da wir später einen Platz für unsere Textüberschriften benötigen werden.
Und jetzt grundlegendes Styling mit Sass
Wir verwenden hier die SCSS-Syntax, und sie erleichtert den Teil mit dem Präfixing erheblich.
.ws {
&-pages {
overflow: hidden;
position: relative;
height: 100vh; // main container should be 100% height of the screen
}
&-bgs {
position: relative;
height: 100%;
}
&-bg {
display: flex;
height: 100%;
background-size: cover;
background-position: center center;
}
}
Nun müssen wir Hintergrund-Slices erstellen. Jeder Slice der "Welle" wird seine eigene <div> sein. Unser Ziel ist es, sie wie einen großen Hintergrund aussehen zu lassen, der zentriert und mit Hilfe von background-size: cover skaliert wird.
Jeder Slice wird 100% der Bildschirmbreite (nicht nur der sichtbaren Breite eines Slices) betragen und mit jedem Schritt nach links verschoben.
.ws-bg {
&__part {
overflow: hidden; // every part must show content only within it's own dimensions
position: relative;
height: 100%;
cursor: grab;
user-select: none; // significantly improves mouse-drag experience, by preventing background-image drag event
&-inner {
position: absolute;
top: 0;
// `left` property will be assigned through JavaScript
width: 100vw; // each block takes 100% of screen width
height: 100%;
background-size: cover;
background-position: center center;
}
}
}
Dann die Hintergrund-Slices mit JavaScript anhängen
Die Slices können wir dynamisch mit einer JavaScript for-Schleife erstellen. Ich habe ursprünglich versucht, all dies in SCSS mit Schleifen und sogar mit ::before-Elementen zu machen, um das HTML zu reduzieren, aber dieser Weg ist einfacher und besser, da man keine Variablen zwischen Sass und JavaScript synchronisieren muss.
var $wsPages = $(".ws-pages");
var bgParts = 24; // variable for amount of slices
var $parts;
function initBgs() {
var arr = [];
var partW = 100 / bgParts; // width of one slice in %
for (var i = 1; i <= bgParts; i++) {
var $part = $('<div class="ws-bg__part">'); // a slice
var $inner = $('<div class="ws-bg__part-inner">'); // inner slice
var innerLeft = 100 / bgParts * (1 - i); // calculating position of inner slice
$inner.css("left", innerLeft + "vw"); // assigning `left` property for each inner slice with viewport units
$part.append($inner);
$part.addClass("ws-bg__part-" + i).width(partW + "%"); // adding class with specific index for each slice and assigning width in %
arr.push($part);
}
$(".ws-bg").append(arr); // append array of slices
$wsPages.addClass("s--ready"); // we'll need this class later
$parts = $(".ws-bg__part"); // new reference to all slices
};
initBgs();
Gegen Ende dieser Funktion fügen wir dem Container eine Klasse s--ready hinzu (s steht für state, wie von JavaScript gesteuert). Wir benötigen sie, um das statische background-image von .ws-bg zu entfernen, das wir anfänglich anzeigen, damit der Benutzer sofort etwas sieht (wahrgenommene Leistung!).
Nachdem die Slices hinzugefügt wurden, wird der Hintergrund des ursprünglichen Containers nutzlos (und schädlich, da er sich nicht bewegt), also beheben wir das.
.ws-bg {
.ws-pages.s--ready & {
background: none;
}
}
// Sass loop to set backgrounds
.ws-bg {
@for $i from 1 through 5 {
$img: url(../images/onepgscr-#{$i + 3}.jpg);
&:nth-child(#{$i}) {
background-image: $img;
.ws-bg__part-inner {
background-image: $img;
}
}
}
}
Umgang mit Mausbewegungen
Wir binden Handler für Maus-Swipes an, die das Ändern der Panels übernehmen.
var numOfPages = $(".ws-bg").length;
// save the window dimensions
var winW = $(window).width();
var winH = $(window).height();
// Not debouncing since setting variables is low cost.
$(window).on("resize", function() {
winW = $(window).width();
winH = $(window).height();
});
var startY = 0;
var deltaY = 0;
// Delegated mouse handler, since all the parts are appended dynamically
$(document).on("mousedown", ".ws-bg__part", function(e) {
startY = e.pageY; // Y position of mouse at the beginning of the swipe
deltaY = 0; // reset variable on every swipe
$(document).on("mousemove", mousemoveHandler); // attaching mousemove swipe handler
$(document).on("mouseup", swipeEndHandler); // and one for swipe end
});
var mousemoveHandler = function(e) {
var y = e.pageY; // Y mouse position during the swipe
// with the help of the X mouse coordinate, we are getting current active slice index (the slice the mouse is currently over)
var x = e.pageX;
index = Math.ceil(x / winW * bgParts);
deltaY = y - startY; // calculating difference between current and starting positions
moveParts(deltaY, index); // moving parts in different functions, by passing variables
};
var swipeEndHandler = function() {
// removing swipeMove and swipeEnd handlers, which were attached on swipeStart
$(document).off("mousemove", mousemoveHandler);
$(document).off("mouseup", swipeEndHandler);
if (!deltaY) return; // if there was no movement on Y axis, then we don't need to do anything else
// if "swipe distance" is bigger than half of the screen height in specific direction, then we call the function to change panels
if (deltaY / winH >= 0.5) navigateUp();
if (deltaY / winH <= -0.5) navigateDown();
// even if the panel doesn't change, we still need to move all parts to their default position for the current panel
changePages();
};
// Update the current page
function navigateUp() {
if (curPage > 1) curPage--;
};
function navigateDown() {
if (curPage < numOfPages) curPage++;
};
Hinzufügen der Wellenbewegung
Zeit, die Welle hinzuzufügen!
Jeder Slice wird entsprechend dem "aktiven" Slice (dem, über dem sich der Cursor befindet) positioniert, basierend auf den Variablen "deltaY" und "index". Die Slices bewegen sich auf der Y-Achse mit einer gewissen Verzögerung, basierend darauf, wie weit sie vom aktiven Slice entfernt sind. Diese "Verzögerung" ist keine feste Zahl, sondern eine Zahl, die abnimmt, je weiter sie vom statischen Slice entfernt ist, bis hin zu Null (flach werdend).
var staggerStep = 4; // each slice away from the active slice moves slightly less
var changeAT = 0.5; // animation time in seconds
function moveParts(y, index) {
var leftMax = index - 1; // max index of slices left of active
var rightMin = index + 1; // min index of slices right of active
var stagLeft = 0;
var stagRight = 0;
var stagStepL = 0;
var stagStepR = 0;
var sign = (y > 0) ? -1 : 1; // direction of swipe
movePart(".ws-bg__part-" + index, y); // move active slice
for (var i = leftMax; i > 0; i--) { // starting loop from right to left with slices, which are on the left side from the active slice
var step = index - i;
var sVal = staggerVal - stagStepL;
// the first 15 steps we are using the default stagger, then reducing it to 1
stagStepL += (step <= 15) ? staggerStep : 1;
// no negative movement
if (sVal < 0) sVal = 0;
stagLeft += sVal;
var nextY = y + stagLeft * sign; // Y value for current step
// if the difference in distance of the current step is more than the deltaY of the active one, then we fix the current step on the default position
if (Math.abs(y) < Math.abs(stagLeft)) nextY = 0;
movePart(".ws-bg__part-" + i, nextY);
}
// same as above, for the right side
for (var j = rightMin; j <= bgParts; j++) {
var step = j - index;
var sVal = staggerVal - stagStepR;
stagStepR += (step <= 15) ? staggerStep : 1;
if (sVal < 0) sVal = 0;
stagRight += sVal;
var nextY = y + stagRight * sign;
if (Math.abs(y) < Math.abs(stagRight)) nextY = 0;
movePart(".ws-bg__part-" + j, nextY);
}
};
function movePart($part, y) {
var y = y - (curPage - 1) * winH;
// GSAP for animation
TweenLite.to($part, changeAT, {y: y, ease: Back.easeOut.config(4)});
};
Ich verwende GSAP (Greensock) für Animationen. Normalerweise verwende ich keine Animationsbibliotheken, aber in diesem Fall benötigen wir Echtzeit-Animationen (z.B. Pausieren und Neustarten bei jedem mousemove-Ereignis), ohne die Flüssigkeit zu verlieren, und GSAP leistet hier hervorragende Arbeit.
Hier ist die eigentliche Funktion zum Seitenwechsel.
var waveStagger = 0.013; // we don't want to move all slices at the same time, so we add a 13ms stagger
// we will remove the cumulative delay from animation time, because we don't want user to wait extra time just for this interaction
function changePages() {
var y = (curPage - 1) * winH * -1; // position, based on current page variable
var leftMax = index - 1;
var rightMin = index + 1;
TweenLite.to(".ws-bg__part-" + index, changeAT, {y: y});
for (var i = leftMax; i > 0; i--) {
var d = (index - i) * waveStagger;
TweenLite.to(".ws-bg__part-" + i, changeAT - d, {y: y, delay: d});
}
for (var j = rightMin; j <= bgParts; j++) {
var d = (j - index) * waveStagger;
TweenLite.to(".ws-bg__part-" + j, changeAT - d, {y: y, delay: d});
}
};
// call the function on resize to reset pixel values. you may want to debounce this now
$(window).on("resize", function() {
winW = $(window).width();
winH = $(window).height();
changePages();
});
Jetzt können wir wischen! Hier ist eine Demo, wo wir bisher stehen.
Paginierung mit dem Mausrad und den Pfeiltasten
@EliFitch hat mir geholfen, es "WaveScroll" zu nennen. Diese UX-Verbesserungen lassen es sich wellenartiger anfühlen.
// used to block scrolling so one wheel spin doesn't go through all the panels
var waveBlocked = false;
var waveStartDelay = 0.2;
// mousewheel handlers. DOMMouseScroll is required for Firefox
$(document).on("mousewheel DOMMouseScroll", function(e) {
if (waveBlocked) return;
if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) {
navigateWaveUp();
} else {
navigateWaveDown();
}
});
$(document).on("keydown", function(e) {
if (waveBlocked) return;
if (e.which === 38) { // key up
navigateWaveUp();
} else if (e.which === 40) { // key down
navigateWaveDown();
}
});
function navigateWaveUp() {
if (curPage === 1) return;
curPage--;
waveChange();
};
function navigateWaveDown() {
if (curPage === numOfPages) return;
curPage++;
waveChange();
};
function waveChange() {
waveBlocked = true; // blocking scroll waveScroll
var y = (curPage - 1) * winH * -1;
for (var i = 1; i <= bgParts; i++) {
// starting animation for each vertical group of slices with staggered delay
var d = (i - 1) * waveStagger + waveStartDelay;
TweenLite.to(".ws-bg__part-" + i, changeAT, {y: y, delay: d});
}
var delay = (changeAT + waveStagger * (bgParts - 1)) * 1000; // whole animation time in ms
setTimeout(function() {
waveBlocked = false; // remove scrollBlock when animation is finished
}, delay);
};
Nun sind alle Teile zusammengefügt und wir haben eine finale Demo.
Mobile Performance
Nachdem ich diese Demo auf meinem Handy (Nexus 5) getestet hatte, stellte ich ernsthafte Performance-Probleme während des drag-Events fest. Dann erinnerte ich mich, dass man normalerweise jegliche Bewegungs-Handler (mousemove/touchmove) optimieren muss, da diese zu oft in kurzer Zeit ausgelöst werden.
requestAnimationFrame war die Lösung. requestAnimationFrame ist eine spezielle Browser-API, die für performante Animationen erstellt wurde. Mehr dazu können Sie hier lesen.
// Polyfill for rAF
window.requestAnimFrame = (function() {
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
function(callback) {
window.setTimeout(callback, 1000 / 60);
};
})();
// Throttling function
function rafThrottle(fn) { // takes a function as parameter
var busy = false;
return function() { // returning function (a closure)
if (busy) return; // busy? go away!
busy = true; // hanging "busy" plate on the door
fn.apply(this, arguments); // calling function
// using rAF to remove the "busy" plate, when browser is ready
requestAnimFrame(function() {
busy = false;
});
};
};
var mousemoveHandler = rafThrottle(function(e) {
// same code as before
});
Wie könnten wir Touch-Events behandeln?
Diesen Code finden Sie in der Demo. Es ist nur ein zusätzlicher Handler für touchmove, der dasselbe tut wie mousemove. Ich habe beschlossen, dies nicht im Artikel zu behandeln, denn selbst nach der rAF-Performance-Optimierung ist die mobile Performance im Vergleich zur Originalwebsite (jetlag.photos), die über . läuft, ziemlich schlecht. Dennoch ist es nicht *zu* schlecht, wenn man bedenkt, dass es sich um Bilder in DOM-Elementen handelt.
Einige Browser haben zusätzliche Probleme mit schwarzen Linien an den Rändern der Slices. Dieses Problem tritt aufgrund der Kombination von Breite in % und 3D-Transformationen während der Bewegung auf, die Subpixel-Rendering-Probleme verursacht und den schwarzen Hintergrund durch die Lücken scheinen lässt.
Das ist alles!
Wenn Sie Vorschläge haben, wie ich etwas besser hätte machen können, höre ich sie gerne in den Kommentaren.
Ich bin überwältigt. Ich mache das erst seit etwa 3 Monaten. Das ist erstaunlich?
Smashing Code und so weiter... aber der Effekt ist nichts für mich, meinen Augen wurde ein wenig übel!