Rolagem suave sem jQuery

Acho que a maioria das pessoas se contenta em apenas usar jQuery para esses tipos de tarefas. Apesar disso, ainda acho que isso pode ser útil.

O que essa função faz é mudar ao element.scrollToplongo de um período de tempo, de modo que, no final da duração desejada, ela chegue a um valor alvo desejado.

A única vantagem que isso pode ter sobre a animação do jQuery é que ela retorna uma promessa que é cumprida no final da animação ou rejeitada se a animação for interrompida.

Explicarei o pensamento por trás disso primeiro e depois mostrarei a implementação.

Explicação

Em primeiro lugar, para fazer a animação funcionar, precisamos saber para cada ponto no tempo, como devemos definir o valor de scrollTop.

Suponha que a animação comece na hora 3000 e termine na hora 3500 e, para essa duração, o scrollTop precise mudar de 140 para 250.

Agora estamos no tempo 3120. Qual é o valor de scrollTop?

Se fizéssemos uma interpolação linear simples, a matemática seria mais ou menos assim:

interpolated_time = 120/500; // how much time since we started, over how much overall time
element
.scrollTop = 140 + (110 * interpolated_time); // 110 being the distance we want to cover

Dedique um minuto para ter certeza de que entendeu de onde vem a matemática.

Agora, na verdade, essa fórmula funcionaria principalmente, exceto que a animação não pareceria tão natural. Precisamos criar um efeito de atenuação, uma interpolação linear mais simples sozinha não vai funcionar.

Procurei um pouco e encontrei isto: http://en.wikipedia.org/wiki/Smoothstep

Esta é uma função de interpolação com suavização / atenuação:

var smooth_step = function(start, end, point) {
if(point <= start) { return 0; }
if(point >= end) { return 1; }
var x = (point - start) / (end - start); // interpolation
return x*x*(3 - 2*x);
}

Com isso, o cálculo do scrollTop em um determinado momento nowé feito assim:

var point = smooth_step(start_time, end_time, now);
var scrollTop = start_top + (distance * point);

Isso é normal, mas retornaria um número de ponto flutuante. No DOM, você não pode definir o valor scrollTop para um float; ele será arredondado para um número inteiro. Então, para evitar confusão, é melhor contornarmos também:

var scrollTop = Math.round(start_top + (distance * point));

Além disso, quero que a função retorne uma promessa. Esta promessa é cumprida quando terminamos a animação ou rejeitada se a animação for interrompida.

Como sabemos que fomos interrompidos?

Nós mantemos o controle de onde pensamos que deveríamos estar se nossa animação estiver rodando bem, e se não estivermos naquele lugar, então algo não está funcionando: provavelmente o uso rolou de forma diferente, ou outro pedaço de código está tentando nos anima de uma maneira diferente. Portanto, neste caso, apenas abortamos nossa animação e reconhecemos a interrupção.

Também é possível que a animação não esteja a correr da forma que queremos sem sermos interrompidos: se atingirmos o limite / limite e não conseguirmos avançar mais. Isso é fácil de detectar: ​​após alterarmos scrollTop, verificamos imediatamente seu valor (durante o mesmo tick): se não mudou para o valor desejado, provavelmente significa que não pode mais se mover! Na verdade, não tenho certeza se isso funciona em todos os navegadores, mas funciona no Chrome e no Firefox mais recentes, e isso é bom o suficiente para mim.

A implementação

/**
Smoothly scroll element to the given target (element.scrollTop)

for the given duration


Returns a promise that's fulfilled when done, or rejected if

interrupted

*/

var smooth_scroll_to = function(element, target, duration) {
target
= Math.round(target);
duration
= Math.round(duration);
if (duration < 0) {
return Promise.reject("bad duration");
}
if (duration === 0) {
element
.scrollTop = target;
return Promise.resolve();
}

var start_time = Date.now();
var end_time = start_time + duration;

var start_top = element.scrollTop;
var distance = target - start_top;

// based on http://en.wikipedia.org/wiki/Smoothstep
var smooth_step = function(start, end, point) {
if(point <= start) { return 0; }
if(point >= end) { return 1; }
var x = (point - start) / (end - start); // interpolation
return x*x*(3 - 2*x);
}

return new Promise(function(resolve, reject) {
// This is to keep track of where the element's scrollTop is
// supposed to be, based on what we're doing
var previous_top = element.scrollTop;

// This is like a think function from a game loop
var scroll_frame = function() {
if(element.scrollTop != previous_top) {
reject
("interrupted");
return;
}

// set the scrollTop for this frame
var now = Date.now();
var point = smooth_step(start_time, end_time, now);
var frameTop = Math.round(start_top + (distance * point));
element
.scrollTop = frameTop;

// check if we're done!
if(now >= end_time) {
resolve
();
return;
}

// If we were supposed to scroll but didn't, then we
// probably hit the limit, so consider it done; not
// interrupted.
if(element.scrollTop === previous_top
&& element.scrollTop !== frameTop) {
resolve
();
return;
}
previous_top
= element.scrollTop;

// schedule next frame for execution
setTimeout
(scroll_frame, 0);
}

// boostrap the animation process
setTimeout
(scroll_frame, 0);
});
}

Exemplos de uso

Encontre alguma página da web com um corpo longo, abra o console dev e cole a definição da função nele.

Em seguida, tente o seguinte:

smooth_scroll_to(document.body, 600, 2000);

No Chrome, isso deve rolar suavemente o corpo em 600 pixels por 2 segundos.

Observação: para Firefox, você deve usar .document.documentElement

Agora vamos tentar algo mais divertido: encadear várias animações de rolagem!

smooth_scroll_to(document.body, 1600, 2000).then(function() {
return smooth_scroll_to(document.body, 800, 2000);
}).then(function() {
return smooth_scroll_to(document.body, 3100, 2000);
}).catch(function(error) {
console
.log("Sequence cancelled:", error)
})

Isso criaria uma sequência de animações para cima e para baixo e, quando qualquer uma delas for interrompida, a sequência inteira também será interrompida!

Assim é o poder das promessas.

Experimente: execute a sequência e role manualmente no meio dela para interrompê-la. A sequência será interrompida e você verá a seguinte mensagem no console:

Sequence cancelled: interrupted 

Então aí está. Espero que isso seja útil para você, ou pelo menos, espero que você tenha aprendido algo novo hoje!

demonstração jsFiddle