如何用JavaScript实现列表项的上下移动动画效果?
实现列表项上下移动的平滑动画
核心思路
实现平滑动画的关键是先让元素视觉上交换位置,再更新DOM结构——直接操作DOM会导致布局瞬间跳动,而通过CSS过渡+临时定位,可以让移动过程更自然。下面是完整的实现方案,适合新手理解和复用:
1. HTML结构
先搭建基础的列表结构,每个列表项包含内容和上下移动按钮:
<ul class="sortable-list"> <li class="list-item"> <span>列表项 1</span> <div class="controls"> <button class="move-btn move-up">↑</button> <button class="move-btn move-down">↓</button> </div> </li> <li class="list-item"> <span>列表项 2</span> <div class="controls"> <button class="move-btn move-up">↑</button> <button class="move-btn move-down">↓</button> </div> </li> <li class="list-item"> <span>列表项 3</span> <div class="controls"> <button class="move-btn move-up">↑</button> <button class="move-btn move-down">↓</button> </div> </li> </ul>
2. CSS样式
重点设置过渡属性和动画时的临时定位,避免布局跳动:
.sortable-list { list-style: none; padding: 0; width: 320px; margin: 20px auto; /* 给列表设置相对定位,让动画项的absolute基于列表定位 */ position: relative; } .list-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; margin: 8px 0; background: #f5f5f5; border-radius: 6px; /* 核心:设置过渡动画的属性和时长 */ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease; /* 默认层级,动画时提升层级避免遮挡 */ z-index: 1; } /* 动画过程中临时添加的类:固定元素位置,防止布局偏移 */ .list-item.animating { position: absolute; width: calc(100% - 32px); /* 对应左右padding的16px*2 */ opacity: 0.9; z-index: 10; } .move-btn { border: none; background: #e0e0e0; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; margin-left: 4px; } .move-btn:hover { background: #bdbdbd; }
3. JavaScript逻辑
实现按钮点击事件、动画触发和DOM交换的完整流程:
// 批量绑定按钮事件 document.querySelectorAll('.move-up').forEach(btn => { btn.addEventListener('click', () => handleMove(btn, 'up')); }); document.querySelectorAll('.move-down').forEach(btn => { btn.addEventListener('click', () => handleMove(btn, 'down')); }); /** * 处理移动逻辑 * @param {HTMLElement} btn 点击的按钮 * @param {string} direction 移动方向:up/down */ function handleMove(btn, direction) { const currentItem = btn.closest('.list-item'); let targetItem; // 判断边界情况,避免无效操作 if (direction === 'up') { targetItem = currentItem.previousElementSibling; if (!targetItem || !targetItem.classList.contains('list-item')) return; } else { targetItem = currentItem.nextElementSibling; if (!targetItem || !targetItem.classList.contains('list-item')) return; } // 启动交换动画 animateSwap(currentItem, targetItem); } /** * 执行元素交换动画 * @param {HTMLElement} itemA 当前元素 * @param {HTMLElement} itemB 目标元素 */ function animateSwap(itemA, itemB) { // 获取两个元素的位置信息(相对于视口) const rectA = itemA.getBoundingClientRect(); const rectB = itemB.getBoundingClientRect(); // 计算Y轴偏移量:A需要移动到B的位置,B需要移动到A的位置 const deltaY = rectB.top - rectA.top; // 添加动画类,固定位置 itemA.classList.add('animating'); itemB.classList.add('animating'); // 设置transform,触发过渡动画 itemA.style.transform = `translateY(${deltaY}px)`; itemB.style.transform = `translateY(${-deltaY}px)`; // 监听过渡结束,完成DOM交换 const onTransitionEnd = () => { // 交换DOM节点位置 itemA.parentNode.insertBefore(itemA, itemB); // 修正:如果是向下移动,需要插入到目标元素的下一个位置 if (itemA.nextElementSibling === itemB) { itemA.parentNode.insertBefore(itemB, itemA); } else { itemA.parentNode.insertBefore(itemB, itemA.nextElementSibling); } // 重置样式和类,恢复正常状态 itemA.classList.remove('animating'); itemB.classList.remove('animating'); itemA.style.transform = ''; itemB.style.transform = ''; // 移除事件监听,避免重复触发 itemA.removeEventListener('transitionend', onTransitionEnd); itemB.removeEventListener('transitionend', onTransitionEnd); }; // 绑定过渡结束事件 itemA.addEventListener('transitionend', onTransitionEnd); itemB.addEventListener('transitionend', onTransitionEnd); }
关键细节说明
- 临时定位(absolute):动画过程中给元素添加
animating类,固定其位置,避免DOM操作导致的布局跳动,动画结束后再实际交换节点。 - 过渡曲线:用
cubic-bezier(0.4, 0, 0.2, 1)替代默认的ease,让动画更符合自然运动规律。 - 边界判断:检查当前元素是否为第一个/最后一个,防止点击无效按钮时报错。
- 事件清理:动画结束后必须移除
transitionend监听,避免内存泄漏和后续操作异常。
内容的提问来源于stack exchange,提问作者Tobias von Falkenhayn
相关产品推荐
相关产品推荐

