从堆栈中添加和删除项目的动画技巧

Avatar of Luke Courtney
Luke Courtney on

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 $200 免费积分!

使用 CSS 动画元素可能非常容易或非常困难,具体取决于您要做什么。 当您将鼠标悬停在按钮上时更改按钮的背景颜色? 很容易。 以一种高效的方式动画化元素的位置和大小,同时还会影响其他元素的位置? 棘手! 这正是我们在本文中要讨论的内容。

一个常见的例子是 **从项目堆栈中删除一个项目**。 位于顶部的项目需要向下移动以弥补从堆栈底部删除的项目的空间。 这也是现实生活中事物的运作方式,用户可能希望在网站上看到这种逼真的运动。 当这种情况没有发生时,用户可能会感到困惑或短暂的迷失方向。 您希望某样东西根据生活经验以一种方式表现,却得到了完全不同的结果,用户可能需要额外的时间来处理这种不切实际的运动。

下面演示了一个添加项目(点击按钮)或删除项目(点击项目)的 UI。

您可以通过添加“淡出”动画或其他动画来稍微掩盖糟糕的 UI,但结果不会很好,因为列表会突然折叠并导致相同的认知问题。

将仅 CSS 动画应用于动态 DOM 事件(添加全新的元素和完全删除元素)是一项 **极其困难的工作**。 我们将正面解决这个问题,并介绍三种截然不同的动画类型来处理这个问题,它们都实现了帮助用户理解项目列表变化的目标。 到我们完成时,您将有能力使用这些动画,或根据这些概念构建您自己的动画。

我们还将简要介绍无障碍功能,以及如何借助 ARIA 属性,即使是精心设计的 HTML 布局也能保留与无障碍设备的一些兼容性。

向下滑动透明度动画

一种非常现代的方法(也是我个人最喜欢的方法)是,新添加的元素根据它们最终将要放置的位置垂直淡入淡出并浮动到位置。 这也意味着列表需要“打开”一个位置(也是动画化的)为其腾出空间。 如果一个元素要离开列表,它所占用的位置需要收缩。

由于我们同时进行了很多事情,我们需要更改 DOM 结构,将每个 .list-item 放在一个容器类中,该容器类恰当地命名为 .list-container。 这是为了使我们的动画正常工作绝对必要的。

<ul class="list">
  <li class="list-container">
    <div class="list-item">List Item</div>
  </li>
  <li class="list-container">
    <div class="list-item">List Item</div>
  </li>
  <li class="list-container">
    <div class="list-item">List Item</div>
  </li>
  <li class="list-container">
    <div class="list-item">List Item</div>
  </li>
</ul>

<button class="add-btn">Add New Item</button>

现在,这种样式非常规,因为为了使我们的动画效果在以后正常工作,我们需要以一种非常特殊的方式对列表进行样式设置,这种方式可以完成任务,但代价是牺牲了一些惯用的 CSS 实践。

.list {
  list-style: none;
}
.list-container {
  cursor: pointer;
  font-size: 3.5rem;
  height: 0;
  list-style: none;
  position: relative;
  text-align: center;
  width: 300px;
}
.list-container:not(:first-child) {
  margin-top: 10px;
}
.list-container .list-item {
  background-color: #D3D3D3;
  left: 0;
  padding: 2rem 0;
  position: absolute;
  top: 0;
  transition: all 0.6s ease-out;
  width: 100%;
}
.add-btn {
  background-color: transparent;
  border: 1px solid black;
  cursor: pointer;
  font-size: 2.5rem;
  margin-top: 10px;
  padding: 2rem 0;
  text-align: center;
  width: 300px;
}

如何处理间距

首先,我们使用 margin-top 在堆栈中的元素之间创建垂直间距。 底部没有边距,这样其他列表项就可以填充删除的列表项留下的空间。 这样一来,即使我们将容器高度设置为零,它仍然在底部有边距。 这种额外的间距是在曾经位于删除的列表项正下方的列表项之间产生的。 并且同一个列表项应该向上移动以响应删除的列表项的容器具有零高度。 并且由于这种额外的空间扩展了列表项之间的垂直间距,超出了我们的预期。 这就是我们使用 margin-top 的原因——为了防止这种情况发生。

但是,我们只在所讨论的项目容器不是列表中的第一个容器时才这样做。 这就是我们使用 :not(:first-child) 的原因——它会定位所有除了第一个容器之外的容器(一个启用选择器)。 我们这样做是因为我们不希望第一个列表项从列表顶部边缘向下推。 我们只希望此操作发生在之后的每个后续项目上,因为它们位于另一个列表项的正下方,而第一个列表项则没有。

现在,这可能不太容易理解,因为我们目前还没有将任何元素的高度设置为零。 但我们将在后面这样做,为了使列表元素之间的垂直间距正确,我们需要像我们现在这样设置边距。

关于定位的说明

另一个值得指出的事是,嵌套在父 .list-container 元素中的 .list-item 元素被设置为 positionabsolute,这意味着它们在 DOM 外部定位,并且相对于它们相对定位的 .list-container 元素进行定位。 我们这样做是为了让 .list-item 元素在移除时向上浮动,同时让其他 .list-item 元素移动并填补移除此 .list-item 元素留下的空间。 发生这种情况时,.list-container 元素(没有定位为 absolute,因此受 DOM 的影响)会折叠其高度,允许其他 .list-container 元素填充其位置,而 .list-item 元素(使用 absolute 定位)向上浮动,但不影响列表的结构,因为它不受 DOM 的影响。

处理高度

不幸的是,我们还没有做足够的努力来获得一个合适的列表,在该列表中,各个列表项一个接一个地堆叠在一起。 相反,我们现在只能看到一个 .list-item,它代表了所有堆叠在一起的列表项,并且在完全相同的位置。 这是因为,虽然 .list-item 元素可能通过其 padding 属性具有一定的高度,但它们的父元素没有,而是高度为零。 这意味着我们 DOM 中没有任何东西实际上将这些元素彼此分开,因为要做到这一点,我们需要我们的 .list-container 元素具有一定的高度,因为与它们的孩子元素不同,它们会受到 DOM 的影响。

为了使列表容器的高度完全匹配其子元素的高度,我们需要使用 JavaScript。 因此,我们将所有列表项存储在一个变量中。 然后,我们创建一个函数,该函数在脚本加载后立即被调用。

这将成为处理列表容器元素高度的函数。

const listItems = document.querySelectorAll('.list-item');

function calculateHeightOfListContainer(){
};

calculateHeightOfListContainer();

我们首先从列表中提取第一个 .list-item 元素。 我们可以这样做,因为它们的大小都相同,所以我们使用哪个都无所谓。 获得对它的访问权限后,我们将通过元素的 clientHeight 属性存储其以像素为单位的高度。 之后,我们创建一个新的 <style> 元素,该元素在文档的 body 中的开头立即被追加,这样我们就可以直接创建一个包含我们刚刚提取的高度值的 CSS 类。 随着此 <style> 元素安全地存在于 DOM 中,我们编写了一个新的 .list-container 类,其样式会自动优先于外部样式表中声明的样式,因为这些样式来自一个实际的 <style> 标签。 这使 .list-container 类具有与其 .list-item 子元素相同的高度。

const listItems = document.querySelectorAll('.list-item');

function calculateHeightOfListContainer() {
  const firstListItem = listItems[0];
  let heightOfListItem = firstListItem.clientHeight;
  const styleTag = document.createElement('style');
  document.body.prepend(styleTag);
  styleTag.innerHTML = `.list-container{
    height: ${heightOfListItem}px;
  }`;
};

calculateHeightOfListContainer();

显示和隐藏

现在,我们的列表看起来有点单调——与我们在第一个示例中看到的相同,只是没有添加或删除逻辑,并且样式与在第一个示例中使用的 <ul><li> 标签列表完全不同。

Four light gray rectangular boxes with the words list item. The boxes are stacked vertically, one on top of the other. Below the bottom box is another box with a white background and thin black border that is a button with a label that says add new item.

我们现在将做一些可能在当前时刻看似莫名其妙的事情,并修改我们的 .list-container.list-item 类。 我们还将为这两个类创建额外的样式,这些样式只会添加到它们中,前提是将新类 .show, 与这两个类分别结合使用。

我们这样做是为了为.list-container.list-item 元素创建两种状态。一种状态是在这两个元素上都没有 .show 类,这种状态表示元素从列表中动画退出时的状态。另一种状态包含添加到这两个元素上的 .show 类。它表示指定的 .list-item 已在列表中牢固地实例化并可见。

过了一会儿,我们将通过在特定 .list-item 的父元素和容器中添加/删除 .show 类来在这两种状态之间切换。我们将把它与 CSS transition 结合起来,在这两种状态之间进行过渡。

请注意,将 .list-item 类与 .show 类结合会为某些元素引入一些额外的样式。具体来说,我们正在引入我们正在创建的动画,在该动画中,列表项在添加到列表时向下淡入并变得可见,而移除列表项时则相反。由于使用 transform 属性是动画元素位置最有效的方式,因此我们将在这里使用它,并在此过程中应用 opacity 来处理可见性部分。因为我们已经在 .list-item.list-container 元素上应用了 transition 属性,所以每当我们在这两个元素中添加或删除 .show 类时,由于 .show 类带来的额外属性,就会自动发生过渡,从而在添加或删除这些新属性时都会导致过渡。

.list-container {
  cursor: pointer;
  font-size: 3.5rem;
  height: 0;
  list-style: none;
  position: relative;
  text-align: center;
  width: 300px;
}
.list-container.show:not(:first-child) {
  margin-top: 10px;
}
.list-container .list-item {
  background-color: #D3D3D3;
  left: 0;
  opacity: 0;
  padding: 2rem 0;
  position: absolute;
  top: 0;
  transform: translateY(-300px);
  transition: all 0.6s ease-out;
  width: 100%;
}
.list-container .list-item.show {
  opacity: 1;
  transform: translateY(0);
}

为了响应 .show 类,我们回到我们的 JavaScript 文件,并更改我们的唯一函数,以便只有当元素也具有 .show 类时,.list-container 元素才会获得 height 属性。此外,我们还将 transition 属性应用于我们的标准 .list-container 元素,并且将在 setTimeout 函数中执行此操作。如果我们没有这样做,那么我们的容器会在脚本加载时在初始页面加载时进行动画处理,并且高度会在第一次应用时被应用,这不是我们想要发生的事情。

const listItems = document.querySelectorAll('.list-item');
function calculateHeightOfListContainer(){
  const firstListItem = listItems[0];
  let heightOfListItem = firstListItem.clientHeight;
  const styleTag = document.createElement('style');
  document.body.prepend(styleTag);
  styleTag.innerHTML = `.list-container.show {
    height: ${heightOfListItem}px;
  }`;
  setTimeout(function() {
    styleTag.innerHTML += `.list-container {
      transition: all 0.6s ease-out;
    }`;
  }, 0);
};
calculateHeightOfListContainer();

现在,如果我们返回并在 DevTools 中查看标记,那么我们应该能够看到列表已消失,只剩下按钮。列表并没有消失,因为这些元素已从 DOM 中删除;它消失了,因为 .show 类现在是必须添加到 .list-item.list-container 元素中的必需类,以便我们能够查看它们。

让列表恢复的办法很简单。我们将 .show 类添加到我们所有的 .list-container 元素以及它们内部包含的 .list-item 元素中。完成此操作后,我们应该能够看到我们预先创建的列表项恢复到它们通常的位置。

<ul class="list">
  <li class="list-container show">
    <div class="list-item show">List Item</div>
  </li>
  <li class="list-container show">
    <div class="list-item show">List Item</div>
  </li>
  <li class="list-container show">
    <div class="list-item show">List Item</div>
  </li>
  <li class="list-container show">
    <div class="list-item show">List Item</div>
  </li>
</ul>

<button class="add-btn">Add New Item</button>

但是我们还无法与任何东西交互,因为要做到这一点,我们需要在我们的 JavaScript 文件中添加更多内容。

在我们初始函数之后,我们要做的第一件事是声明对我们单击以添加新列表项的按钮以及 .list 元素本身的引用,该元素是围绕每个 .list-item 及其容器的元素。然后我们选择嵌套在父 .list 元素中的每个 .list-container 元素,并使用 forEach 方法遍历它们。我们在该回调函数中分配了一个方法 removeListItem,该方法分配给每个 .list-containeronclick 事件处理程序。在循环结束时,在新的页面加载时实例化到 DOM 的每个 .list-container 都在被单击时调用此相同方法。

完成此操作后,我们将方法分配给 addBtnonclick 事件处理程序,以便我们可以在单击它时激活代码。但显然,我们现在还不会创建该代码。现在,我们只是将一些内容记录到控制台以进行测试。

const addBtn = document.querySelector('.add-btn');
const list = document.querySelector('.list');
function removeListItem(e){
  console.log('Deleted!');
}
// DOCUMENT LOAD
document.querySelectorAll('.list .list-container').forEach(function(container) {
  container.onclick = removeListItem;
});

addBtn.onclick = function(e){
  console.log('Add Btn');
}

开始处理 addBtnonclick 事件处理程序,我们要做的第一件事是创建两个新元素:containerlistItem。这两个元素都代表 .list-item 元素及其各自的 .list-container 元素,这就是为什么我们在创建它们后立即为它们分配这些确切的类。

准备这两个元素后,我们使用 container 上的 append 方法将 listItem 作为子元素插入其中,与列表中已存在的元素的格式相同。成功将 listItem 附加为 container 的子元素后,我们可以使用 insertBefore 方法将 container 元素及其子元素 listItem 元素移动到 DOM。我们这样做是因为我们希望新项目出现在列表的底部,但在 addBtn 之前,它需要一直停留在列表的最底部。因此,通过使用 addBtnparentNode 属性来定位其父元素 list,我们说我们想将元素插入为 list 的子元素,并且我们要插入的子元素 (container) 将插入到已经存在于 DOM 中的子元素之前,并且我们已经使用 insertBefore 方法的第二个参数 addBtn 定位了该子元素。

最后,在成功将 .list-item 及其容器添加到 DOM 后,我们可以将容器的 onclick 事件处理程序设置为与默认情况下已存在于 DOM 中的每个其他 .list-item 相同的方法。

addBtn.onclick = function(e){
  const container = document.createElement('li'); 
  container.classList.add('list-container');
  const listItem = document.createElement('div'); 
  listItem.classList.add('list-item'); 
  listItem.innerHTML = 'List Item';
  container.append(listItem);
  addBtn.parentNode.insertBefore(container, addBtn);
  container.onclick = removeListItem;
}

如果我们尝试一下,那么无论我们单击 addBtn 多少次,我们都无法看到列表的任何变化。这不是 click 事件处理程序中的错误。事情按预期进行。.list-item 元素(及其容器)已添加到列表中的正确位置,只是它们添加时没有 .show 类。因此,它们没有任何高度,这就是为什么我们看不到它们,以及为什么看起来列表没有任何变化的原因。

为了让每个新添加的 .list-item 在我们单击 addBtn 时动画进入列表,我们需要将 .show 类应用于 .list-item 及其容器,就像我们必须为查看已硬编码到 DOM 中的列表项所做的那样。

问题是我们不能立即将 .show 类添加到这些元素。如果我们这样做,新的 .list-item 将静态地弹出并出现在列表的底部,没有任何动画。我们需要在动画之前注册一些样式,这些样式会覆盖那些初始样式,以便元素知道要执行什么 transition。这意味着,如果我们只是将 .show 类应用于已经存在的元素,那么就不会发生过渡。

解决方法是在 setTimeout 回调中应用 .show 类,将回调的激活延迟 15 毫秒,或 1.5/100 秒。这种难以察觉的延迟足以从 provisio 状态创建到添加 .show 类后创建的新状态的 transition。但这种延迟也足够短,以至于我们永远不会知道实际上存在延迟。

addBtn.onclick = function(e){
  const container = document.createElement('li'); 
  container.classList.add('list-container');
  const listItem = document.createElement('div'); 
  listItem.classList.add('list-item'); 
  listItem.innerHTML = 'List Item';
  container.append(listItem);
  addBtn.parentNode.insertBefore(container, addBtn);
  container.onclick = removeListItem;
  setTimeout(function(){
    container.classList.add('show'); 
    listItem.classList.add('show');
  }, 15);
}

成功!现在是处理单击时如何移除列表项的时候了。

现在移除列表项应该不会太难,因为我们已经完成了添加列表项的困难任务。首先,我们需要确保我们正在处理的元素是 .list-container 元素,而不是 .list-item 元素。由于事件传播,触发此单击事件的目标很可能是 .list-item 元素。

由于我们想处理关联的 .list-container 元素而不是实际触发事件的 .list-item 元素,因此我们使用 while 循环向上循环一个祖先,直到 container 中保存的元素是 .list-container 元素。当 container 获得 .list-container 类时,我们知道它有效,这是我们可以通过在 container 元素的 classList 属性上使用 contains 方法来发现的。

一旦我们访问了 container,我们就会立即从 container 及其 .list-item 中移除 .show 类,一旦我们也访问了它。

function removeListItem(e) {
  let container = e.target;
  while (!container.classList.contains('list-container')) {
    container = container.parentElement;
  }
  container.classList.remove('show');
  const listItem = container.querySelector('.list-item');
  listItem.classList.remove('show');
}

这是最终结果

可访问性和性能

现在你可能会想就这样结束这个项目,因为列表添加和移除现在都应该能正常工作了。但重要的是要记住,此功能只是表面的,为了使其成为一个完整的包,肯定需要进行一些修补。

首先,尽管已移除的元素已向上淡出并消失,并且列表已收缩以填补它留下的空缺,但这并不意味着已移除的元素已从 DOM 中移除。实际上,它没有。这是一个性能责任,因为它意味着我们在 DOM 中有一些元素,它们除了在后台积累并减慢我们的应用程序速度之外,没有任何用途。

为了解决这个问题,我们在容器元素上使用 ontransitionend 方法将其从 DOM 中移除,但只有在我们移除 .show 类导致的过渡完成后才执行此操作,这样它的移除就不可能中断我们的过渡。

function removeListItem(e) {
  let container = e.target;
  while (!container.classList.contains('list-container')) {
    container = container.parentElement;
  }
  container.classList.remove('show');
  const listItem = container.querySelector('.list-item');
  listItem.classList.remove('show');
  container.ontransitionend = function(){
    container.remove();
  }
}

此时我们应该看不到任何区别,因为我们所做的只是提高性能——没有样式更新。

另一个区别也是不可察觉的,但非常重要:兼容性。因为我们使用了正确的 <ul><li> 标签,所以设备应该能够毫无问题地将我们创建的内容正确地解释为无序列表。

此技术的其他注意事项

然而,我们确实存在一个问题,即设备可能无法处理我们列表的动态特性,例如列表如何改变其大小以及它所持有的项目数量。新的列表项将被完全忽略,已移除的列表项将被读取为仍然存在。

因此,为了让设备在列表大小发生变化时重新解释我们的列表,我们需要使用 ARIA 属性。它们可以帮助非标准 HTML 列表被兼容设备识别为列表。也就是说,它们不是这里保证的解决方案,因为它们在兼容性方面永远不如原生标签好。以 `<ul>` 标签为例——我们不需要担心这个问题,因为我们能够使用原生无序列表元素。

我们可以将 `aria-live` 属性应用于 `.list` 元素。被 `aria-live` 标记的 DOM 部分内部的所有内容都会变得响应式。换句话说,对具有 `aria-live` 属性的元素进行的更改会被识别,从而允许它们发出更新的响应。在我们的例子中,我们希望它们高度响应,因此将 `aria live` 属性设置为 `assertive`。这样,每当检测到更改时,它都会立即做出反应,打断它当时正在执行的任务,立即对所做的更改进行评论。

<ul class="list" role="list" aria-live="assertive">

折叠动画

这是一个更微妙的动画,与列表项在更改不透明度时上下浮动不同,元素只是在逐渐淡入或淡出时向外收缩或扩展;同时,列表的其余部分重新定位以适应正在发生的过渡。

列表的妙处(也许是为我们创建的冗长 DOM 结构提供了一些弥补)在于,我们可以非常轻松地更改动画,而不会影响主要效果。

因此,要实现这种效果,我们首先隐藏 `.list-container` 上的 overflow。我们这样做是为了在 `.list-container` 向内折叠时,它会这样做,而不会让子 `.list-item` 在收缩时超出列表容器的边界。除此之外,我们唯一需要做的就是从具有 `.show` 类别的 `.list-item` 中删除 `transform` 属性,因为我们不再希望 `.list-item` 向上浮动。

.list-container {
  cursor: pointer;
  font-size: 3.5rem;
  height: 0;
  overflow: hidden;
  list-style: none;
  position: relative;
  text-align: center;
  width: 300px;
}
.list-container.show:not(:first-child) {
  margin-top: 10px;
}
.list-container .list-item {
  background-color: #D3D3D3;
  left: 0;
  opacity: 0;
  padding: 2rem 0;
  position: absolute;
  top: 0;
  transition: all 0.6s ease-out;
  width: 100%;
}
.list-container .list-item.show {
  opacity: 1;
}

侧滑动画

最后一种动画技术与其他动画技术截然不同,`container` 动画和 `.list-item` 动画实际上是不同步的。当 `.list-item` 从列表中移除时,它会向右滑动,当它添加到列表中时,它会从右侧滑动。列表中需要有足够的垂直空间来为新的 `.list-item` 腾出空间,然后它才能开始动画进入列表,反之亦然,删除时也是如此。

至于样式,它非常类似于向下滑动不透明度动画,唯一的区别是 `.list-item` 的 `transition` 现在应该在 x 轴上,而不是 y 轴上。

.list-container {
  cursor: pointer;
  font-size: 3.5rem;
  height: 0;
  list-style: none;
  position: relative;
  text-align: center;
  width: 300px;
}
.list-container.show:not(:first-child) {
  margin-top: 10px;
}
.list-container .list-item {
  background-color: #D3D3D3;
  left: 0;
  opacity: 0;
  padding: 2rem 0;
  position: absolute;
  top: 0;
  transform: translateX(300px);
  transition: all 0.6s ease-out;
  width: 100%;
}
.list-container .list-item.show {
  opacity: 1;
  transform: translateX(0);
}

至于我们 JavaScript 中 `addBtn` 的 `onclick` 事件处理程序,我们正在使用嵌套的 `setTimeout` 方法,在 `listItem` 的 `container` 元素开始过渡后 350 毫秒延迟 `listItem` 动画的开始。

setTimeout(function(){
  container.classList.add('show'); 
  setTimeout(function(){
    listItem.classList.add('show');
  }, 350);
}, 10);

在 `removeListItem` 函数中,我们首先删除列表项的 `.show` 类,以便它可以立即开始过渡。然后,父 `container` 元素会失去其 `.show` 类,但这仅在初始 `listItem` 过渡开始后的 350 毫秒后。然后,在 `container` 元素开始过渡后的 600 毫秒(或 `listItem` 过渡后的 950 毫秒),我们将 `container` 元素从 DOM 中移除,因为此时 `listItem` 和容器的过渡都应该已经结束。

function removeListItem(e){
  let container = e.target;
  while(!container.classList.contains('list-container')){
    container = container.parentElement;
  }
  const listItem = container.querySelector('.list-item');
  listItem.classList.remove('show');
  setTimeout(function(){
    container.classList.remove('show');
    container.ontransitionend = function(){
      container.remove();
    }
  }, 350);
}

以下是最终结果

总结

好了,以上是三种为添加到堆栈和从堆栈中移除的项进行动画的方法。我希望通过这些示例,你现在有信心在 DOM 结构根据添加到或从 DOM 中移除的元素而重新定位的情况下工作。

如你所见,有很多移动的部件和需要考虑的事情。我们从对这种现实世界中运动的期望开始,并考虑了当其中一个元素被更新时,元素组会发生什么。在显示和隐藏状态之间进行转换以及哪个元素在特定时间获得这些状态之间需要一些平衡,但我们做到了。我们甚至还确保我们的列表既高效又易访问,这些都是我们在实际项目中需要处理的事情。

无论如何,我祝愿你未来的项目一切顺利。这就是我的全部内容。完毕。