使用原生 JavaScript 实现简单的滑动

Avatar of Ana Tudor
Ana Tudor 发表于

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

我曾经认为实现滑动手势非常困难,但最近我发现自己需要这样做,并发现现实并没有我想象的那么糟糕。

本文将逐步引导您完成实现过程,并使用我所能想到的最少代码量。所以,让我们直接开始吧!

HTML 结构

我们首先使用一个 .container,其中包含许多图像

<div class='container'>
  <img src='img1.jpg' alt='image description'/>
  ...
</div>

基本样式

我们使用 display: flex 确保图像彼此并排,且之间没有空格。align-items: center 将它们垂直居中对齐。我们使图像和容器都占用容器父元素(在本例中为 body)的 width

.container {
  display: flex;
  align-items: center;
  width: 100%;
  
  img {
    min-width: 100%; /* needed so Firefox doesn't make img shrink to fit */
    width: 100%; /* can't take this out either as it breaks Chrome */
  }
}

.container 及其子图像具有相同的 width,这使得这些图像溢出到右侧(如红色轮廓所示),从而创建了一个水平滚动条,但这正是我们想要的。

Screenshot showing this very basic layout with the container and the images having the same width as the body and the images spilling out of the container to the right, creating a horizontal scrollbar on the body.
初始布局(参见 在线演示)。

鉴于并非所有图像都具有相同的尺寸和纵横比,因此某些图像的上方和下方会有一些空白区域。因此,我们将通过为 .container 设置一个明确的 height 来修剪这些空白区域,该高度应该适用于这些图像的平均纵横比,并将 overflow-y 设置为 hidden

.container {
  /* same as before */
  overflow-y: hidden;
  height: 50vw;
  max-height: 100vh;
}

结果如下所示,所有图像都被修剪到相同的 height,并且不再有空隙。

Screenshot showing the result after limiting the container's height and trimming everything that doesn't fit vertically with overflow-y. This means we now have a horizontal scrollbar on the container itself.
通过 .container 上的 overflow-y 修剪图像后的结果(参见 在线演示)。

好的,但现在 .container 本身有一个水平滚动条。嗯,对于无 JavaScript 的情况来说,这实际上是一件好事。

否则,我们创建一个 CSS 变量 --n 来表示图像的数量,并使用它使 .container 足够宽,以容纳所有其图像子元素,这些子元素仍与父元素(在本例中为 body)具有相同的宽度。

.container {
  --n: 1;
  width: 100%;
  width: calc(var(--n)*100%);
  
  img {
    min-width: 100%;
    width: 100%;
    width: calc(100%/var(--n));
  }
}

请注意,我们将之前的 width 声明保留为回退。在从 JavaScript 获取 .container 及其包含的子图像数量后设置 --n 之前,calc() 值不会有任何变化。

const _C = document.querySelector('.container'), 
      N = _C.children.length;

_C.style.setProperty('--n', N)

现在,我们的 .container 已扩展以适应所有图像。

容器扩展后的布局(在线演示)。

切换图像

接下来,我们通过在容器的父元素(在本例中为 body)上设置 overflow-x: hidden 来去除水平滚动条,并创建另一个 CSS 变量来保存当前选定图像的索引(--i)。我们使用它通过平移来正确地定位 .container 相对于视口的位置(请记住,translate() 函数中的 % 值相对于我们设置此 transform 的元素的尺寸)。

body { overflow-x: hidden }

.container {
  /* same styles as before */
  transform: translate(calc(var(--i, 0)/var(--n)*-100%));
}

--i 更改为另一个大于或等于零但小于 --n 的整数值,会将另一张图像带入视野,如下面的交互式演示所示(其中 --i 的值由范围输入控制)。

查看 thebabydino 在 CodePen 上的 Pen@thebabydino)。

好的,但我们不想使用滑块来执行此操作。

基本思想是我们将在 "touchstart"(或 "mousedown")事件和 "touchend"(或 "mouseup")事件之间检测运动方向,然后相应地更新 --i 以移动容器,以便所需方向上的下一张图像(如果有)移动到视口。

function lock(e) {};

function move(e) {};

_C.addEventListener('mousedown', lock, false);
_C.addEventListener('touchstart', lock, false);

_C.addEventListener('mouseup', move, false);
_C.addEventListener('touchend', move, false);

请注意,如果我们在图像上设置 pointer-events: none,则此方法仅适用于鼠标。

.container {
  /* same styles as before */

  img {
    /* same styles as before */
    pointer-events: none;
  }
}

此外,Edge 需要从 about:flags 中启用触摸事件,因为此选项默认情况下是关闭的。

Screenshot showing the 'Enable touch events' option being set to 'Only when a touchscreen is detected' in about:flags in Edge.
在 Edge 中启用触摸事件。

在填充 lock()move() 函数之前,我们将触摸和点击情况统一。

function unify(e) { return e.changedTouches ? e.changedTouches[0] : e };

"touchstart"(或 "mousedown")上锁定意味着获取并将 x 坐标存储到初始坐标变量 x0 中。

let x0 = null;

function lock(e) { x0 = unify(e).clientX };

为了查看如何移动 .container(或者是否需要移动,因为当我们到达末尾时不想进一步移动),我们检查是否已执行 lock() 操作,如果已执行,则读取当前 x 坐标,计算它与 x0 之间的差值,并根据其符号和当前索引确定要执行的操作。

let i = 0;

function move(e) {
  if(x0 || x0 === 0) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx);
  
    if((i > 0 || s < 0) && (i < N - 1 || s > 0))
      _C.style.setProperty('--i', i -= s);
	
    x0 = null
  }
};

向左/向右拖动的结果如下所示。

Animated gif. Shows how we switch to the next image by dragging left/ right if there is a next image in the direction we want to go. Attempts to move to the right on the first image or left on the last one do nothing as we have no other image before or after, respectively.
滑动切换图像(在线演示)。尝试在第一张图像上向右移动或在最后一张图像上向左移动不会有任何反应,因为我们分别没有其他图像在前或在后。

以上是预期结果,也是我们在 Chrome 中进行少量拖动以及 Firefox 中获得的结果。但是,当我们向左或向右拖动时,Edge 会向前或向后导航,这与 Chrome 在拖动更多时也会发生。

Animated gif. Shows how Edge navigates the pageview backward and forward when we swipe left or right.
Edge 在向左或向右滑动时向前或向后导航页面。

为了覆盖这一点,我们需要添加一个 "touchmove" 事件监听器。

_C.addEventListener('touchmove', e => {e.preventDefault()}, false)

好的,我们现在有了 在所有浏览器中都能正常工作的内容,但它看起来还不是我们真正想要的…… 还没有!

流畅的运动

实现我们想要的效果最简单的方法是添加一个 transition

.container {
  /* same styles as before */
  transition: transform .5s ease-out;
}

就是这样,一个非常基本的滑动效果,大约 25 行 JavaScript 和 25 行 CSS 代码。

有效的滑动效果(在线演示)。

遗憾的是,Edge 中存在一个 bug,导致任何对依赖于 CSS 变量的 calc() 平移的 transition 都失败。唉,我想我们现在必须忘记 Edge 了。

完善整个过程

在所有炫酷的滑动效果中,我们目前所拥有的效果还不太够,所以让我们看看可以进行哪些改进。

拖动时的更好视觉提示

首先,在我们拖动期间没有任何事情发生,所有操作都遵循 "touchend"(或 "mouseup")事件。因此,在我们拖动时,我们无法了解接下来会发生什么。在所需方向上是否有下一张图像可以切换?或者我们是否已到达终点,并且不会发生任何事情?

为了解决这个问题,我们通过添加一个最初为 0px 的 CSS 变量 --tx 来稍微调整平移量。

transform: translate(calc(var(--i, 0)/var(--n)*-100% + var(--tx, 0px)))

我们使用另外两个事件监听器:一个用于 "touchmove",另一个用于 "mousemove"。请注意,我们已经使用 "touchmove" 监听器阻止了 Chrome 中的向前和向后导航。

function drag(e) { e.preventDefault() };

_C.addEventListener('mousemove', drag, false);
_C.addEventListener('touchmove', drag, false);

现在让我们填充 drag() 函数!如果我们已执行 lock() 操作,则读取当前 x 坐标,计算此坐标与初始坐标 x0 之间的差值 dx,并将 --tx 设置为此值(这是一个像素值)。

function drag(e) {
  e.preventDefault();

  if(x0 || x0 === 0)  
    _C.style.setProperty('--tx', `${Math.round(unify(e).clientX - x0)}px`)
};

我们还需要确保在最后将 --tx 重置为 0px,并在拖动期间删除 transition。为了使这更容易,我们将 transition 声明移动到一个 .smooth 类上。

.smooth { transition: transform .5s ease-out; }

lock() 函数中,我们从 .container 中删除此类(我们将在最后在 "touchend""mouseup" 上再次添加它),并设置一个 locked 布尔变量,以便我们不必继续执行 x0 || x0 === 0 检查。然后我们使用 locked 变量进行检查。

let locked = false;

function lock(e) {
  x0 = unify(e).clientX;
  _C.classList.toggle('smooth', !(locked = true))
};

function drag(e) {
  e.preventDefault();
  if(locked) { /* same as before */ }
};

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0))
    _C.style.setProperty('--i', i -= s);
    _C.style.setProperty('--tx', '0px');
    _C.classList.toggle('smooth', !(locked = false));
    x0 = null
  }
};

结果如下所示。在我们仍在拖动时,我们现在可以看到接下来会发生什么的视觉指示。

拖动时带有视觉提示的滑动(在线演示)。

修复transition-duration

此时,无论在拖动后图像的width还剩多少需要平移,我们始终使用相同的transition-duration。我们可以通过引入一个因子f以相当直接的方式解决这个问题,我们也将其设置为 CSS 变量,以帮助我们计算实际的动画持续时间。

.smooth { transition: transform calc(var(--f, 1)*.5s) ease-out; }

在 JavaScript 中,我们获取图像的width(在"resize"事件中更新),并计算我们水平拖动了该宽度的几分之几。

let w;

function size() { w = window.innerWidth };

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0)) {
      _C.style.setProperty('--i', i -= s);
      f = 1 - f
    }
		
    _C.style.setProperty('--tx', '0px');
    _C.style.setProperty('--f', f);
    _C.classList.toggle('smooth', !(locked = false));
    x0 = null
  }
};

size();

addEventListener('resize', size, false);

现在,这给了我们一个更好的结果

如果拖动不足则返回

假设我们不想在只稍微拖动了一点点(低于某个阈值)的情况下就切换到下一张图像。因为现在,拖动过程中的1px差异意味着我们将切换到下一张图像,这感觉有点不自然。

为了解决这个问题,我们将阈值设置为例如图像width20%

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0) && f > .2) {
      /* same as before */
    }
		
    /* same as before */
  }
};

结果可以在下面看到。

只有在我们拖动足够距离时(在线演示),才会切换到下一张图像。

也许添加弹跳?

我不确定这是否是一个好主意,但我还是忍不住想尝试一下:更改时间函数,以便引入弹跳。在 cubic-bezier.com 上稍微拖动了一下控制点后,我想出了一个看起来很有希望的结果

Animated gif. Shows the graphical representation of the cubic Bézier curve, with start point at (0, 0), end point at (1, 1) and control points at (1, 1.59) and (.61, .74), the progression on the [0, 1] interval being a function of time in the [0, 1] interval. Also illustrates how the transition function given by this cubic Bézier curve looks when applied on a translation compared to a plain ease-out.
我们选择的三次贝塞尔曲线时间函数与普通的ease-out相比。
transition: transform calc(var(--f)*.5s) cubic-bezier(1, 1.59, .61, .74);
使用自定义 CSS 时间函数引入弹跳(在线演示)。

那么 JavaScript 方法怎么样?

我们可以通过采用 JavaScript 方法进行转换来更好地控制更自然和更复杂的弹跳。这也能让我们支持 Edge 浏览器。

我们首先删除transition以及--tx--f CSS 变量。这将我们的transform还原到最初的样子。

transform: translate(calc(var(--i, 0)/var(--n)*-100%));

上面的代码也意味着--i不一定再是整数了。虽然在我们有一张图像完全进入视野时它仍然是整数,但在我们拖动或触发"touchend""mouseup"事件后的运动过程中则不再是整数了。

Annotated screenshots illustrating what images we see for --i: 0 (1st image), --i: 1 (2nd image), --i: .5 (half of 1st and half of 2nd) and --i: .75 (a quarter of 1st and three quarters of 2nd).
例如,当第一张图像完全进入视野时,--i0。当第二张图像完全进入视野时,--i1。当我们处于第一张和第二张图像之间时,--i.5。当我们看到第一张图像的四分之一和第二张图像的四分之三时,--i.75

然后我们更新 JavaScript 代码,以替换我们更新这些 CSS 变量的部分。首先,我们处理lock()函数,其中我们放弃了切换.smooth类,以及drag()函数,其中我们用更新--i替换了更新我们已删除的--tx变量,如前所述,--i不再需要是整数。

function lock(e) {
  x0 = unify(e).clientX;
  locked = true
};

function drag(e) {
  e.preventDefault();
	
  if(locked) {
    let dx = unify(e).clientX - x0, 
      f = +(dx/w).toFixed(2);
		
    _C.style.setProperty('--i', i - f)
  }
};

在我们更新move()函数之前,我们引入两个新变量inifin。它们分别表示我们在动画开始时设置的--i的初始值,以及我们在动画结束时设置的相同变量的最终值。我们还创建了一个动画函数ani()

let ini, fin;

function ani() {};

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
        s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);
		
    ini = i - s*f;

    if((i > 0 || s < 0) && (i < N - 1 || s > 0) && f > .2) {
      i -= s;
      f = 1 - f
    }

    fin = i;
    ani();
    x0 = null;
    locked = false;
  }
};

这与我们之前的代码没有什么不同。变化在于我们不再在这个函数中设置任何 CSS 变量,而是设置了inifin JavaScript 变量并调用动画ani()函数。

ini是在"touchend"/"mouseup"事件触发的动画开始时设置的--i的初始值。它由这两个事件之一触发时我们当前的位置给出。

fin是在相同动画结束时设置的--i的最终值。它始终是一个整数值,因为我们总是以一张图像完全进入视野结束,因此fin--i是该图像的索引。如果我们拖动了足够距离(f > .2),并且在所需方向上有下一张图像((i > 0 || s < 0) && (i < N - 1 || s > 0)),那么它就是所需方向的下一张图像。在这种情况下,我们还会更新存储当前图像索引(i)和到它的相对距离(f)的 JavaScript 变量。否则,它就是同一张图像,因此if不需要更新。

现在,让我们继续讨论ani()函数。我们从一个简化的线性版本开始,它省略了方向变化。

const NF = 30;

let rID = null;

function stopAni() {
  cancelAnimationFrame(rID);
  rID = null
};

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*cf/NF);
	
  if(cf === NF) {
    stopAni();
    return
  }
	
  rID = requestAnimationFrame(ani.bind(this, ++cf))
};

这里的主要思想是,初始值ini和最终值fin之间的转换发生在总帧数NF内。每次我们调用ani()函数时,我们都将进度计算为当前帧索引cf与总帧数NF的比率。它始终是一个介于01之间的数字(或者你可以将其视为百分比,从0%100%)。然后,我们使用此进度值来获取--i的当前值,并将其设置在我们的容器_C的样式属性中。如果我们到达了最终状态(当前帧索引cf等于总帧数NF),则退出动画循环。否则,我们只需递增当前帧索引cf并再次调用ani()

此时,我们有一个带有线性 JavaScript 转换的工作演示。

带有线性 JavaScript 转换的版本(在线演示)。

但是,它存在我们在 CSS 案例中最初遇到的问题:无论距离如何,我们都必须在释放("touchend"/"mouseup")时平滑地平移我们的元素,并且持续时间始终相同,因为我们始终在相同的帧数NF内进行动画。

让我们修复它!

为此,我们引入另一个变量anf,在其中存储我们使用的实际帧数,并在调用动画函数ani()之前在move()函数中计算其值。

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
      s = Math.sign(dx), 
      f = +(s*dx/w).toFixed(2);
		
    /* same as before */

    anf = Math.round(f*NF);
    ani();

    /* same as before */
  }
};

我们还需要在动画函数ani()中用anf替换NF

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*cf/anf);
	
  if(cf === anf) { /* same as before */ }
	
  /* same as before */
};

这样,我们就解决了时间问题!

以恒定速度进行linear JavaScript 转换的版本(在线演示)。

好吧,但是线性时间函数并不是那么令人兴奋。

我们可以尝试 CSS 时间函数(例如ease-inease-outease-in-out)的 JavaScript 等效项,并查看它们的比较结果。我已经在之前链接的文章中详细解释了如何获得这些函数,因此我不会再详细介绍,而只是将包含所有这些函数的对象放入代码中。

const TFN = {
  'linear': function(k) { return k }, 
  'ease-in': function(k, e = 1.675) {
    return Math.pow(k, e)
  }, 
  'ease-out': function(k, e = 1.675) {
    return 1 - Math.pow(1 - k, e)
  }, 
  'ease-in-out': function(k) {
    return .5*(Math.sin((k - .5)*Math.PI) + 1)
  }
};

k值是进度,它是当前帧索引cf与转换发生的实际帧数anf的比率。这意味着如果我们想例如使用ease-out选项,我们需要稍微修改ani()函数。

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*TFN['ease-out'](cf/anf));
	
  /* same as before */
};
带有ease-out JavaScript 转换的版本(在线演示)。

我们还可以通过使用 CSS 无法提供的弹跳时间函数类型来使事情变得更有趣。例如,类似于下面演示中说明的函数(点击触发转换)。

查看 thebabydino 在 CodePen 上创建的Pen@thebabydino)。

此函数的图形将与 easings.net 中的easeOutBounce时间函数的图形有些相似。

Animated gif. Shows the graph of the bouncing timing function. This function has a slow, then accelerated increase from the initial value to its final value. Once it reaches the final value, it quickly bounces back by about a quarter of the distance between the final and initial value, then going back to the final value, again bouncing back a bit. In total, it bounces three times. On the right side, we have an animation of how the function value (the ordinate on the graph) changes in time (as we progress along the abscissa).
时间函数的图形表示。

获得这种时间函数的过程类似于获得 CSS ease-in-out的 JavaScript 版本的过程(同样,在之前链接的文章中描述了如何使用 JavaScript 模拟 CSS 时间函数)。

我们从[0, 90°]区间(或弧度制的[0, π/2])上的余弦函数开始,没有弹跳,[0, 270°][0, 3·π/2])进行1次弹跳,[0, 450°][0, 5·π/2])进行2次弹跳,依此类推……一般来说,对于n次弹跳,区间为[0, (n + ½)·180°][0, (n + ½)·π])。

查看 thebabydino 在 CodePen 上创建的Pen@thebabydino)。

cos(k)函数的输入在[0, 450°]区间内,而其输出在[-1, 1]区间内。但我们想要的是一个定义域为[0, 1]区间且值域也为[0, 1]区间的函数。

我们可以通过只取绝对值|cos(k)|来将值域限制在[0, 1]区间内。

查看 thebabydino 在 CodePen 上创建的Pen@thebabydino)。

虽然我们得到了我们想要的余域区间,但我们希望该函数在0处的值为0,在区间另一端的值为1。目前,情况正好相反,但如果我们将函数改为1 - |cos(k)|,我们可以解决这个问题。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

现在我们可以继续将定义域从[0, (n + ½)·180°]区间限制到[0, 1]区间。为了做到这一点,我们将函数改为1 - |cos(k·(n + ½)·180°)|

查看thebabydino在CodePen上创建的Pen@thebabydino)。

这给了我们期望的定义域和余域,但我们仍然有一些问题。

首先,我们所有的反弹高度都相同,但我们希望随着k0增加到1,反弹高度逐渐降低。在这种情况下,我们的解决方案是用1 - k(或1 - k的幂,以实现非线性幅度衰减)乘以余弦。下面的交互式演示展示了对于不同的指数a,幅度是如何变化的,以及这对我们目前的函数有何影响。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

其次,所有反弹花费的时间都相同,即使它们的幅度不断减小。这里的第一个想法是在余弦函数内部使用k的幂而不是k本身。这导致了一些奇怪的结果,因为余弦不再以相等的时间间隔达到0,这意味着我们不再总是得到f(1) = 1,而这是我们实际使用的定时函数始终需要的。然而,对于像a = 2.75n = 3b = 1.5这样的值,我们得到了一个看起来令人满意的结果,所以我们将保留它,即使它可以进行微调以获得更好的控制。

Screenshot of the previously linked demo showing the graphical result of the a = 2.75, n = 3 and b = 1.5 setup: a slow, then fast increase from 0 (for f(0)) to 1, bouncing back down less than half the way after reaching 1, going back up and then having another even smaller bounce before finishing at 1, where we always want to finish for f(1).
我们想要尝试的定时函数。

如果我们想要一些反弹效果,这是我们在JavaScript中尝试的函数。

const TFN = {
  /* the other function we had before */
  'bounce-out': function(k, n = 3, a = 2.75, b = 1.5) {
    return 1 - Math.pow(1 - k, a)*Math.abs(Math.cos(Math.pow(k, b)*(n + .5)*Math.PI))
  }
};

嗯,在实践中似乎有点太极端了。

带有反弹JavaScript过渡的版本(现场演示)。

也许我们可以使n依赖于从释放那一刻起我们仍然需要执行的平移量。我们将其转换为一个变量,然后在调用动画函数ani()之前,在move()函数中设置它。

const TFN = {
  /* the other function we had before */
  'bounce-out': function(k, a = 2.75, b = 1.5) {
    return 1 - Math.pow(1 - k, a)*Math.abs(Math.cos(Math.pow(k, b)*(n + .5)*Math.PI))
  }
};

var n;

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
      s = Math.sign(dx), 
      f = +(s*dx/w).toFixed(2);
    
    /* same as before */
		
    n = 2 + Math.round(f)
    ani();
    /* same as before */
  }
};

这给了我们最终的结果。

带有最终反弹JavaScript过渡的版本(现场演示)。

肯定还有改进的空间,但我对什么构成良好的动画没有感觉,所以我就此结束。就这样,它现在可以在跨浏览器环境中正常工作(没有使用CSS过渡版本的任何Edge问题),并且非常灵活。