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

126 阅读18分钟

用CSS给元素做动画可以很容易,也可以很困难,这取决于你想做什么。当你悬停在一个按钮上时,改变它的背景颜色?很简单。以一种有效的方式对一个元素的位置和大小进行动画处理,同时影响其他元素的位置?困难!这正是我们要做的。这正是我们在这篇文章中要讨论的问题。

一个常见的例子是将一个项目从一叠项目中移除。叠在上面的物品需要往下掉,以顾及从堆栈底部移除的物品的空间。这就是事物在现实生活中的表现,用户可能期望在网站上有这种类似生活的运动。当它没有发生时,用户可能会感到困惑或瞬间迷失方向。你期望某样东西根据生活经验以一种方式表现,而得到的却是完全不同的东西,用户可能需要额外的时间来处理这种不现实的运动。

你可以通过添加一个 "淡出 "动画或其他东西来略微掩盖这个糟糕的用户界面,但结果不会那么好,因为列表会突然崩溃,并导致那些相同的认知问题。

将纯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) --它针对所有的容器*,除了*第一个容器(一个启用的选择器)。我们这样做是因为我们不希望第一个列表项被从列表的顶部边缘推下来。我们只想让此后的每一个项目都发生这种情况,因为它们被置于另一个列表项目的正下方,而第一个项目却不是。

现在,这不太可能是完全有意义的,因为我们现在没有将任何元素设置为零高度。但是我们以后会这样做,为了使列表元素之间的垂直间距正确,我们需要像我们一样设置边距。

关于定位的说明

position 还有一点值得指出的是,嵌套在父级.list-container 元素内部的.list-item 元素被设置为absolute ,这意味着它们被定位在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-item 容器有一些高度,因为与它们的子元素不同,它们会受到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](https://css-tricks.com/almanac/properties/t/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文件,并改变我们的唯一函数,以便.list-container 元素只有该元素上也有一个.show 类时才会被赋予一个height 属性,此外,我们将对我们的标准.list-container 元素应用一个transition 属性,并且我们将在一个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 ,给每个onclick 事件处理程序.list-container 。循环结束后,每一个在新的页面加载时实例化到DOM的.list-container ,每当它们被点击时都会调用这个相同的方法。

一旦这样做了,我们就为onclick 事件处理程序分配一个方法给addBtn ,这样我们就可以在点击它时激活代码。但显然,我们现在还不会创建这些代码。现在,我们只是把一些东西记录到控制台,用于测试。

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之前,因为它需要保持在列表的最底部。parentNode 所以,通过使用addBtn 的属性来定位它的父级,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秒。这个不易察觉的延迟足够长,以创建一个transition ,从proviso状态到通过添加.show 类创建的新状态。但这个延迟也足够短,我们永远不会知道一开始就有一个延迟。

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-loop来向上循环一个祖先,直到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 元素。所有嵌套在DOM中标有aria-live 的部分内的东西都会变成响应式的。换句话说,对带有aria-live 的元素所做的改变会被识别,允许他们发出更新的响应。在我们的案例中,我们希望事情具有高度的反应性,我们通过将aria live 属性设置为assertive 来做到这一点。这样,只要检测到变化,它就会这样做,中断它当时正在做的任何任务,立即对所做的变化进行评论。

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

崩溃动画

这是一个更微妙的动画,在这个动画中,列表项不是在改变不透明度时向上或向下浮动,而是元素在逐渐淡入或淡出时向外塌陷或扩展;同时,列表的其他部分会根据发生的过渡重新定位。

列表最酷的地方(也许是对我们创建的冗长DOM结构的一些缓解),是我们可以非常容易地改变动画而不干扰主要效果。

所以,为了达到这个效果,我们首先在我们的 overflow在我们的.list-container 。我们这样做是为了当.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-itemtransition 现在应该在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);
}

onclick 至于我们JavaScript中addBtn 的事件处理程序,我们使用一个嵌套的setTimeout 方法,在其container 元素已经开始过渡后,将listItem 动画的开始时间推迟350毫秒。

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

removeListItem 函数中,我们首先移除列表项的.show 类,这样它就可以立即开始过渡。然后,父container 元素失去了它的.show 类,但只是在最初的listItem 过渡已经开始后的 350 毫秒。然后,在container 元素开始过渡后600毫秒(或在listItem 过渡后950毫秒),我们从DOM中移除container 元素,因为此时,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结构沉淀到一个新的位置的情况下工作,以应对一个元素被添加或移除的情况。

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

无论如何,我祝愿你们在未来的项目中一切顺利。这就是我的全部。