You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用Framer Motion实现书籍排序/洗牌时的X轴滑动过渡动画

书籍排序/洗牌时X轴平滑滑动过渡异常修复方案

需求背景

实现书籍排序或洗牌操作时,每本书沿X轴平滑滑动的过渡效果,当前使用Framer Motion后出现动画异常。

技术栈

  • Next.js
  • Tailwind CSS
  • Framer Motion

核心问题排查

  1. Motion模块未导入:Book组件中使用motion.li但未引入framer-motion,导致Motion标签无效。
  2. 索引作为key:排序/洗牌时,React因key复用错误识别元素,Motion无法跟踪位置变化,引发异常动画。
  3. 未启用布局动画:未添加layout属性,Motion无法自动处理元素位置变更的过渡效果。
  4. 代码功能缺陷:存在selectedBook状态未定义、shuffleArray函数缺失、书籍数据未设置到状态、Image加载事件未绑定等问题,间接影响动画表现。

修复后的完整代码

Page.js(父组件)

"use client";

import Book from "./Book";
import { useEffect, useState } from "react";

export default function Home() {
  const [books, setBooks] = useState([]);
  const [sortOrder, setSortOrder] = useState({ title: "asc", dateFinished: "asc", year: "asc" });
  const [selectedBook, setSelectedBook] = useState(null);

  useEffect(() => {
    async function fetchBooks() {
      try {
        const response = await fetch("/books.json");
        if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
        const data = await response.json();
        setBooks(data);
      } catch (error) {
        console.error("Error fetching books:", error);
      }
    }
    fetchBooks();
  }, []);

  const shuffleArray = (array) => {
    const shuffled = [...array];
    for (let i = shuffled.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }
    return shuffled;
  };

  const sortBooks = (criteria) => {
    const order = sortOrder[criteria] === "asc" ? "desc" : "asc";
    const sortedBooks = [...books].sort((a, b) => {
      switch (criteria) {
        case "title":
          return order === "asc" ? a.title.localeCompare(b.title) : b.title.localeCompare(a.title);
        case "year":
          return order === "asc" ? a.year - b.year : b.year - a.year;
        default:
          return 0;
      }
    });
    setBooks(sortedBooks);
    setSortOrder({ ...sortOrder, [criteria]: order });
  };

  const handleSelectBook = (book) => {
    setSelectedBook(selectedBook === book ? null : book);
  };

  const handleShuffleBooks = () => {
    setBooks(shuffleArray([...books]));
  };

  return (
    <div className="overflow-y-hidden max-h-[100vh] max-w-[100vw] overflow-hidden">
      <h1 className="text-center text-2xl font-bold my-4">My Bookshelf</h1>
      <div className="flex gap-2 px-8 mb-4">
        <button
          onClick={() => sortBooks("title")}
          className="px-3 py-1 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
        >
          Sort Alphabetically ({sortOrder.title === "asc" ? "A-Z" : "Z-A"})
        </button>
        <button
          onClick={() => sortBooks("year")}
          className="px-3 py-1 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
        >
          Sort by Year ({sortOrder.year === "asc" ? "Oldest First" : "Newest First"})
        </button>
        <button
          onClick={handleShuffleBooks}
          className="px-3 py-1 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
        >
          🔀
        </button>
      </div>

      <ul className="max-h-[calc(100vh-120px)] flex border-b-[40px] border-orange-900 mt-4 items-baseline mx-8 px-8 overflow-x-scroll w-full">
        {books.map((book) => (
          <Book
            key={book.id}
            data={book}
          />
        ))}
      </ul>
    </div>
  );
}

Book.js(子组件)

"use client";

import { useState } from "react";
import Image from "next/image";
import { motion } from "framer-motion";

export default function Book({ data }) {
  const [imageSize, setImageSize] = useState({ width: 0, height: 0 });

  const handleImageLoad = ({ target }) => {
    setImageSize({ width: target.naturalWidth / 4, height: target.naturalHeight / 4 });
  };

  return (
    <motion.li 
      className="relative flex gap-2 items-end mr-2"
      layout
      transition={{ type: "spring", stiffness: 250, damping: 30 }}
    >
      <Image
        src={`/images/${data.route}`}
        width={imageSize.width}
        height={imageSize.height}
        unoptimized={true}
        onLoad={handleImageLoad}
        alt={`Cover of ${data.title}`}
        className="shadow-md hover:shadow-lg transition-shadow"
      />
    </motion.li>
  );
}

关键修复说明

  1. 替换元素key:用书籍的唯一ID替代索引,确保React能准确识别每个书籍元素,让Motion可以跟踪元素的位置变化。
  2. 启用Motion布局动画:给motion.li添加layout属性,Motion会自动计算元素位置变更的过渡动画,实现平滑滑动效果。
  3. 配置过渡参数:通过transition属性设置弹簧动画的刚度和阻尼值,调整动画的快慢和回弹效果,让滑动更自然。
  4. 修复功能缺陷:补充缺失的状态、函数和事件绑定,确保组件能正常渲染和交互,为动画提供稳定的基础。

内容的提问来源于stack exchange,提问作者Tyler Morales

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 09:09:52