在本指南中,我们将 Framer Motion 动画应用到一个基本示例项目中,以丰富用户对动画的体验。

该项目是使用 Next 和 TypeScript 制作的,但您可以将所有概念应用到使用 JavaScript 的普通 React 项目中。

样品项目

您可以在这个 repo](https://github.com/dastasoft/memory-game)示例项目中找到[,这是一个基本的记忆游戏,具有用于介绍、选择难度、选择卡组(要玩不同的动漫)和游戏本身的不同屏幕。与其他记忆游戏一样,您必须在限定时间内发现所有配对。

[板](https://res.cloudinary.com/practicaldev/image/fetch/s--hM5c4VBS--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://blog.dastasoft.com /_next/image%3Furl%3D%252Fassets%252Fposts%252Fcontent%252Fframer-motion%252Fboard.png%26w%3D3840%26q%3D75)

遵循本指南的最佳方法是使用初始版本功能齐全,没有动画,测试文章中的不同代码部分,如果在此过程中遇到任何问题,请查看最终版本。

您可以查看示例项目的现场演示:

  • 没有成帧器运动*

  • 最终版本

*在这个版本中,添加了 CSS 动画,至少使游戏具有可玩性。

什么是 Framer Motion?

这是 Framer 为 React 制作的动画库,旨在让我们以声明方式轻松编写动画,并与我们的 React 生态系统无缝集成。

您可以使用纯 CSS 获得相同的结果,但 Framer Motion 将允许您快速引入漂亮流畅的动画,同时保持代码更简单,像在 React 中一样使用道具,并让您能够对状态变化和其他方面做出反应反应行为。

此外,如果您不太习惯 CSS 动画,由于我们将使用直观的语法,这可能是对它们的一个很好的介绍,它具有对开发人员更友好的语法。

您将能够使用当前工作组件中的几个道具来运行简单和复杂的动画、过渡甚至顺序动画。

安装

只需在项目中安装framer-motion包:

yarn add framer-motion
npm install framer-motion

进入全屏模式 退出全屏模式

安装后,只需导入motion组件并在任何 HTML 标记中使用它:

import { motion } from "framer-motion"

<motion.div animate={{ scale: 0.5 }} />

进入全屏模式 退出全屏模式

Motion 将包装所有 HTML 元素并添加我们将在本指南中看到的动画属性。

基本动画

正如我们之前看到的,添加动画就像在一个用motion包裹的组件上使用animate属性一样简单。

因此,作为第一个测试,让我们为位于Intro页面上的Play按钮设置动画。

// components/Intro

import { motion } from 'framer-motion'

const Intro = ({ next }: { next: () => void }) => {
  return (
    <div className="flex-vertical">
      <h1>Memory Game</h1>
      <motion.button
        onClick={next}
        animate={{ scale: 1.5 }}
        transition={{ delay: 1 }}
      >
        Play
      </motion.button>
    </div>
  )
}

export default Intro

进入全屏模式 退出全屏模式

  • 我们用motion组件包装了button标签,这允许我们使用额外的属性,例如animate

  • 提供的动画用于放大 1.5

  • 为了能够看到大小差异,我们添加了一个附加属性transition,稍后我们将详细介绍,以将动画延迟 1 秒。

有了这几行,我们就准备好了动画。现在我们使用我们习惯的 JS 对象语法,但稍后我们将在animate属性中看到更多用于传递动画的选项。

在上面的示例中,成帧器运动默认我们使用所有默认值的initial属性,但我们可以定义它并覆盖我们想要的动画不同状态的任何内容。

// components/Intro

import { motion } from 'framer-motion'

const Intro = ({ next }: { next: () => void }) => {
  return (
    <div className="flex-vertical">
      <h1>Memory Game</h1>
      <motion.button
        onClick={next}
        initial={{ rotate: -360, scale: 3 }}
        animate={{ rotate: 0, scale: 1 }}
        transition={{ duration: 1 }}
      >
        Play
      </motion.button>
    </div>
  )
}

export default Intro

进入全屏模式 退出全屏模式

有了这个,我们在旋转时从一个大的播放按钮切换到一个正常大小的按钮。

过渡

我们将使用过渡来控制状态之间的动画,例如在上一个示例中,我们将起点延迟了 1 秒,但我们可以做更多的事情。

我们将稍微更改最后一个播放按钮以测试过渡提供的一些可能性,例如,我们希望动画在无限循环中缩放,而不是仅仅触发一次。

// components/Intro

import { motion } from 'framer-motion'

const Intro = ({ next }: { next: () => void }) => {
  return (
    <div className="flex-vertical">
      <h1>Memory Game</h1>
      <motion.button
        onClick={next}
        animate={{ scale: 1.5 }}
        transition={{
          duration: 0.4,
          yoyo: Infinity,
        }}
      >
        Play
      </motion.button>
    </div>
  )
}

export default Intro

进入全屏模式 退出全屏模式

  • 我们删除了延迟道具,但它也可以使用它。

  • 现在0.4秒的持续时间是动画的总持续时间。

  • 最后,yoyo是一个特殊属性,可以在初始状态和动画之间来回切换,在这种情况下,是无限次。使用此属性,您可以控制要触发动画的次数。

过渡允许我们定义我们想要使用的动画类型,我们可以使用:

  • Tween→ 基于持续时间的动画,当您定义没有任何类型的duration时,这是使用的默认类型。
// components/Intro

<motion.button
        onClick={next}
        animate={{ rotate: 360 }}
        transition={{
          type: 'tween',
          duration: 0.4,
        }}
      >
        Play
</motion.button>

进入全屏模式 退出全屏模式

  • Spring→ 将自然物理模拟为动画,如果您尝试过react-spring这遵循相同的原理。
// components/Intro

<motion.button
        onClick={next}
        initial={{ x: '100vw' }}
        animate={{ x: 0 }}
        transition={{
          type: 'spring',
          stiffness: 300,
        }}
      >
        Play
</motion.button>

进入全屏模式 退出全屏模式

  • Inertia→ 此类动画将从初始速度开始减速。
// components/Intro

<motion.button
        onClick={next}
        animate={{ rotate: 360 }}
        transition={{ type: 'inertia', velocity: 450 }}
      >
        Play
</motion.button>

进入全屏模式 退出全屏模式

在示例项目中尝试这些不同的选项并检查生成的动画。

提示:以上一些设置与某些属性不兼容,如果你使用TypeScript,任何组合没有意义都会出现错误。

转换的另一个有用的用途是编排,我们稍后会解释,但首先要了解一些事情。

变体

正如你所看到的,代码越来越大,很快,这些新的 props 将比那些与 React 逻辑相关的更相关。我们可以使用variants来隔离与动画等相关的代码。

对于变体,我们需要指定不同的标签,我们将分配给动画的不同阶段。

让我们用变体重构其中一个播放按钮示例:

// components/Intro

import { motion } from 'framer-motion'

const buttonVariants = {
  hidden: {
    x: '100vw',
  },
  visible: {
    x: 0,
    transition: {
      type: 'spring',
      stiffness: 300,
    },
  },
}

const Intro = ({ next }: { next: () => void }) => {
  return (
    <div className="flex-vertical">
      <h1>Memory Game</h1>
      <motion.button
        onClick={next}
        initial="hidden"
        animate="visible"
        variants={buttonVariants}
      >
        Play
      </motion.button>
    </div>
  )
}

export default Intro

进入全屏模式 退出全屏模式

现在我们将组件内的所有代码替换为:

  • initial状态相关的标签,在本例中为hidden(您可以随意命名)。

  • animate状态相关的标签(还包含转换详细信息)。

  • 此组件使用的variants对象。

提示:您可以将所有变体移动到单独的文件中,就像使用普通 CSS 或任何其他 CSS-in-JS 库一样,以简化您的组件。

提示:如果父组件和子组件共享相同的标签,则只需在父组件中编写一次,子组件默认具有相同的标签。

编排

在某些情况下,我们希望一个接一个地触发动画,在这种情况下,编排 + 变体将派上用场。

例如,我们将为甲板选择的标题设置动画,一旦动画完成,我们将为每个孩子制作动画。

// components/SelectDeck

import { motion } from 'framer-motion'

import { DECKS } from '@/utils/Decks'

import Button from '../ListedButton'
import { childVariants, containerVariants } from './SelectDeck.variants'

type Props = {
  next: () => void
  setDeck: (deckName: string) => void
}

const SelectDeck: React.FC<Props> = ({ next, setDeck }) => {
  const handleSelect = (
    event: React.MouseEvent<HTMLButtonElement, MouseEvent>
  ) => {
    setDeck(event.currentTarget.value)
    next()
  }

  return (
    <motion.div
      variants={containerVariants}
      initial="hidden"
      animate="visible"
    >
      <h2>Select Deck</h2>
      <div className="flex-vertical stack">
        {Object.keys(DECKS).map((theme: string) => (
          <motion.div key={theme} variants={childVariants}>
            <Button onClick={handleSelect} value={theme}>
              {theme}
            </Button>
          </motion.div>
        ))}
      </div>
    </motion.div>
  )
}

export default SelectDeck

进入全屏模式 退出全屏模式

在查看变体代码之前,请注意在此组件中,运动组件container定义了initialanimated道具,但运动children没有。如上所述,孩子们默认从父母那里获取动画道具,所以如果我们设置相同的标签,则无需指定其他标签。

// components/SelectDeck//SelectDeck.variants.ts

const containerVariants = {
  hidden: {
    opacity: 0,
    x: '100vw',
  },
  visible: {
    opacity: 1,
    x: 0,
    transition: {
      type: 'spring',
      mass: 0.4,
      damping: 8,
      when: 'beforeChildren',
      staggerChildren: 0.4,
    },
  },
}

const childVariants = {
  hidden: {
    opacity: 0,
  },
  visible: {
    opacity: 1,
  },
}

export { containerVariants, childVariants }

进入全屏模式 退出全屏模式

  • transition中,我们定义了两个属性来定义编排whenstaggerChildren

  • 在这种情况下,我们指定beforeChildren以便父动画在子动画之前运行并完成。

*staggerChildren参数将一个一个地应用每个子动画,它们之间有 0.4 秒的延迟。

其他编排方式有:

  • 使用我们在第一个示例中所做的delay

  • delayChildren延迟子动画,而不是让它依赖于父动画。

  • repeat重复动画。

通过编排,您可以进行强大的组合。

手势

除了 React 的内置侦听器之外,成帧器运动还包括手势,允许我们在其他情况下执行动画,例如hovertappanviewportdrag

例如,让我们回到介绍屏幕中的播放按钮,并在鼠标悬停并点击按钮时执行其他动画:

// components/Intro

import { motion } from 'framer-motion'

const buttonVariants = {
  hidden: {
    x: '100vw',
  },
  visible: {
    x: 0,
    transition: {
      type: 'spring',
      stiffness: 300,
    },
  },
  hover: {
    scale: 1.5,
  },
  tap: {
    scale: 0.5,
  },
}

const Intro = ({ next }: { next: () => void }) => {
  return (
    <div className="flex-vertical">
      <h1>Memory Game</h1>
      <motion.button
        onClick={next}
        initial="hidden"
        animate="visible"
        whileHover="hover"
        whileTap="tap"
        variants={buttonVariants}
      >
        Play
      </motion.button>
    </div>
  )
}

export default Intro

进入全屏模式 退出全屏模式

  • 我们将whileHoverwhileTap监听器添加到新的hovertap变体中,一如既往,您可以随意命名。通过这些更改,现在当我们将鼠标悬停在按钮上时,它会放大,当我们单击它时,它会缩小。

您不需要使用变体来使用手势,如在前面的示例中,您可以将对象直接放置在侦听器上而不是标记上。

在这个例子中,我们只是修改了比例,但是你可以制作复杂的动画,甚至像你目前看到的那样的过渡,把手势想象成动画链中的另一个状态。

另一个非常有用的手势是whileInView,使用它你可以轻松控制当元素出现在视口中时触发动画,在我上一篇关于如何使用 Redux Toolkit的文章中我做了一个示例项目使用此功能:

// components/Card/Card.tsx

<motion.div
      initial="hidden"
      variants={cardVariants}
      animate={controls}
      whileInView="show"
      viewport={{ once: true }}
    >
...
</motion.div>

进入全屏模式 退出全屏模式

*我为本文简化了此组件,但您可以在上面的链接中看到实际代码。

使用whileInView并传入我们想要运行的变体是我们在那个精确时刻触发动画所需的全部内容。我们还使用viewport``once仅触发一次动画,而不是每次该元素返回视图时。

关键帧

对动画行为进行更多控制的另一种方法是使用关键帧来制作它,当您想要组合不同的属性并及时精确控制值时,这是一种方法。

例如,让我们为卡片放置在棋盘上时添加动画:

// components/Card/

import { motion } from 'framer-motion'

import { Card as TCard } from '@/types'

import styles from './Card.module.css'

const cardVariants = {
  hidden: { scale: 0, rotate: 0 },
  flip: {
    scale: [1, 0.5, 0.5, 1],
    rotate: [0, 180, 360, 0],
    transition: {
      duration: 0.8,
    },
  },
}

type Props = {
  card: TCard
  handleSelection: (card: TCard) => void
  flipped: boolean
  disabled: boolean
}

export default function Card({
  card,
  handleSelection,
  flipped,
  disabled,
}: Props) {
  const handleClick = () => {
    if (!disabled) handleSelection(card)
  }

  return (
    <motion.div
      className={styles.card}
      variants={cardVariants}
      initial="hidden"
      animate="flip"
    >
      <div className={`${styles.inner} ${flipped ? styles.flipped : ''}`}>
        <img className={styles.front} src={card.imageURL} alt="card front" />
        <img
          src={`${card.imageURL.split('/').slice(0, -1).join('/')}/cover.jpg`}
          alt="card back"
          className={styles.back}
          onClick={handleClick}
        />
      </div>
    </motion.div>
  )
}

进入全屏模式 退出全屏模式

所做的更改:

  • 将容器转换为motiondiv 并添加了cardVariantshiddenflip状态。

  • cardVariants中,不是使用scalerotation中的值,而是使用数组来指定每个关键帧中的确切值。

如果未指定持续时间,则该帧将平均间隔放置在关键帧上的更改。

控制动画

我们已经看到了很多关于如何在动画之间过渡的选项,但在某些情况下,您需要直接控制何时开始和/或结束动画。在这些情况下,我们可以调用一个名为useAnimation的即用型挂钩。

作为一个简单的例子,假设我们想要在播放按钮介绍屏幕上做两个动画,除了从隐藏到可见的过渡:

// components/Intro

import { useEffect } from 'react'

import { motion, useAnimation } from 'framer-motion'

const buttonVariants = {
  hidden: {
    x: '500vw',
  },
  visible: {
    x: 0,
    transition: { type: 'spring', delay: 0.3, duration: 1 },
  },
  loop: {
    scale: 1.5,
    transition: {
      duration: 0.4,
      yoyo: Infinity,
    },
  },
}

const Intro = ({ next }: { next: () => void }) => {
  const controls = useAnimation()

  useEffect(() => {
    const sequence = async () => {
      await controls.start('visible')
      return controls.start('loop')
    }

    sequence()
  }, [controls])

  return (
    <div className="flex-vertical">
      <h1>Memory Game</h1>
      <motion.button
        onClick={next}
        variants={buttonVariants}
        initial="hidden"
        animate={controls}
      >
        Play
      </motion.button>
    </div>
  )
}

export default Intro

进入全屏模式 退出全屏模式

  • 如您所见,在从hidden过渡到visible之后,我们想做另一个动画,在这种情况下是 Infinity 悠悠球动画,其中一种解决方案是使用useEffect获取组件的挂载点的时刻并执行必要的行动。

  • 按钮现在具有controls作为animate值,该值是从useAnimation挂钩中提取的。

  • 当组件被挂载时,我们可以使用controls触发任何动画,它返回一个在动画结束时解析的 promise。

Controls 支持我们在文章开头看到的变体和 JS 对象。

退出动画

除了initialanimate之外,还有第三个状态exit,当组件从 DOM 中移除时,我们可以使用它来制作动画。

在这种情况下,我们希望每个游戏屏幕都以相反的方向退出屏幕,以提供滑动屏幕的感觉。

// components/Intro/

import { useEffect } from 'react'

import { motion, useAnimation } from 'framer-motion'

const containerVariants = {
  exit: {
    x: '-100vh',
    transition: { ease: 'easeInOut' },
  },
}

const Intro = ({ next }: { next: () => void }) => {
  const controls = useAnimation()

  useEffect(() => {
    const sequence = async () => {
      await controls.start('visible')
      return controls.start('loop')
    }

    sequence()
  }, [controls])

  return (
    <motion.div
      className="flex-vertical"
      variants={containerVariants}
      exit="exit"
    >
      <h1>Memory Game</h1>
      <button onClick={next}>Play</button>
    </motion.div>
  )
}

export default Intro

进入全屏模式 退出全屏模式

  • 在这种情况下,我们添加一个exit变体,将内容移到左侧,远离视口。

如果你尝试这段代码,它不会起作用,你必须用AnimatePresence指定需要知道组件存在的父元素。在这种情况下,父组件是包含整个游戏的单个页面:

// pages/index.tsx

import { useState } from 'react'

import { AnimatePresence } from 'framer-motion'
import type { NextPage } from 'next'

import Game from '@/components/Game'
import Intro from '@/components/Intro'
import SelectDeck from '@/components/SelectDeck'
import SelectDifficulty, { Difficulties } from '@/components/SelectDifficulty'
import { Deck } from '@/types'
import { DECKS } from '@/utils/Decks'

const UIStates = {
  IntroScreen: 0,
  DifficultyScreen: 1,
  DeckScreen: 2,
  GameScreen: 3,
} as const

const Home: NextPage = () => {
  const [UIState, setUIState] = useState<number>(UIStates.IntroScreen)
  const [deck, setDeck] = useState<Deck>(DECKS['Dragon Ball'])
  const [difficulty, setDifficulty] = useState(Difficulties.Normal)

  return (
    <div>
      <AnimatePresence>
        {UIState === UIStates.IntroScreen && (
          <Intro next={() => setUIState(UIStates.DifficultyScreen)} />
        )}
        {UIState === UIStates.DifficultyScreen && (
          <SelectDifficulty
            next={() => setUIState(UIStates.DeckScreen)}
            setDifficulty={setDifficulty}
          />
        )}
        {UIState === UIStates.DeckScreen && (
          <SelectDeck
            next={() => setUIState(UIStates.GameScreen)}
            setDeck={(deckName: string) => setDeck(DECKS[deckName])}
          />
        )}
        {UIState === UIStates.GameScreen && (
          <Game
            selectedDeck={deck.slice(0, difficulty)}
            backToDifficulty={() => setUIState(UIStates.DifficultyScreen)}
            backToDeck={() => setUIState(UIStates.DeckScreen)}
          />
        )}
      </AnimatePresence>
    </div>
  )
}

export default Home

进入全屏模式 退出全屏模式

而且很遗憾的说,虽然加了AnimatePresence,还是不行!那是因为 framer 在切换屏幕时不区分我们试图动画哪个组件,所以你需要为每个屏幕指定一个唯一的键。

{UIState === UIStates.IntroScreen && (
    <Intro
        next={() => setUIState(UIStates.DifficultyScreen)}
        key={UIStates.IntroScreen}
     />
 )}

进入全屏模式 退出全屏模式

现在它可以工作了,但是您会看到一些奇怪的动画,其中第一个屏幕和第二个屏幕同时存在。所以,要解决这个问题,让这个动画工作的最后一步,就是告诉 framer 我们想要延迟下面的动画,直到退出动画完全完成。

<AnimatePresence exitBefoeEnter>

进入全屏模式 退出全屏模式

SVG 动画

一个很酷的实用程序是动画 SVG 的能力,它就像使用pathLength动画 SVG 路径绘制过程一样简单。

首先,让我们将这个 SVG 添加到介绍页面:

// components/Intro/index.tsx

<svg
        className={styles.Container}
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 512 512"
      >
        <motion.path
          fill="none"
          stroke="var(--primary)"
          strokeWidth={6}
          strokeLinecap="round"
          variants={pathVariants}
          d="M256 224C238.4 224 223.1 238.4 223.1 256S238.4 288 256 288c17.63 0 32-14.38 32-32S273.6 224 256 224zM470.2 128c-10.88-19.5-40.51-50.75-116.3-41.88C332.4 34.88 299.6 0 256 0S179.6 34.88 158.1 86.12C82.34 77.38 52.71 108.5 41.83 128c-16.38 29.38-14.91 73.12 25.23 128c-40.13 54.88-41.61 98.63-25.23 128c29.13 52.38 101.6 43.63 116.3 41.88C179.6 477.1 212.4 512 256 512s76.39-34.88 97.9-86.13C368.5 427.6 441 436.4 470.2 384c16.38-29.38 14.91-73.13-25.23-128C485.1 201.1 486.5 157.4 470.2 128zM95.34 352c-4.001-7.25-.1251-24.75 15-48.25c6.876 6.5 14.13 12.87 21.88 19.12c1.625 13.75 4.001 27.13 6.751 40.13C114.3 363.9 99.09 358.6 95.34 352zM132.2 189.1C124.5 195.4 117.2 201.8 110.3 208.2C95.22 184.8 91.34 167.2 95.34 160c3.376-6.125 16.38-11.5 37.88-11.5c1.75 0 3.876 .375 5.751 .375C136.1 162.2 133.8 175.6 132.2 189.1zM256 64c9.502 0 22.25 13.5 33.88 37.25C278.6 105 267.4 109.3 256 114.1C244.6 109.3 233.4 105 222.1 101.2C233.7 77.5 246.5 64 256 64zM256 448c-9.502 0-22.25-13.5-33.88-37.25C233.4 407 244.6 402.7 256 397.9c11.38 4.875 22.63 9.135 33.88 12.89C278.3 434.5 265.5 448 256 448zM256 336c-44.13 0-80.02-35.88-80.02-80S211.9 176 256 176s80.02 35.88 80.02 80S300.1 336 256 336zM416.7 352c-3.626 6.625-19 11.88-43.63 11c2.751-12.1 5.126-26.38 6.751-40.13c7.752-6.25 15-12.63 21.88-19.12C416.8 327.2 420.7 344.8 416.7 352zM401.7 208.2c-6.876-6.5-14.13-12.87-21.88-19.12c-1.625-13.5-3.876-26.88-6.751-40.25c1.875 0 4.001-.375 5.751-.375c21.5 0 34.51 5.375 37.88 11.5C420.7 167.2 416.8 184.8 401.7 208.2z"
        />
</svg>

进入全屏模式 退出全屏模式

以及它背后真正的魔力,pathVariants

// components/Intro/Intro.variants.ts

const pathVariants = {
  hidden: {
    pathLength: 0,
  },
  visible: {
    pathLength: 1,
    transition: {
      duration: 4,
      yoyo: Infinity,
      ease: 'easeInOut',
    },
  },
}

进入全屏模式 退出全屏模式

我已经用一堆我们现在已经知道的附加属性使这个变得过于复杂了,但关键是从 0pathLenght到 1,成帧器运动将遵循我们 SVG 的路径描述并使用我们的动画值绘制该路径指定。

结论

通过这个简单的项目,我们已经看到在我们的项目中包含简单和复杂的动画是多么容易、可靠和符合我们当前的技能。

这只是 framer-motion 的介绍性指南,库中还有更多内容,尤其是许多实用程序钩子,通过将此库与[react-three/fiber]结合起来,可以毫不费力地制作更疯狂的动画和高级主题,如 3D 动画例如(https://docs.pmnd.rs/react-three-fiber/getting-started/introduction)。

请务必查看官方文档并尝试不同的动画以将您的项目提升到一个新的水平。

Logo

React社区为您提供最前沿的新闻资讯和知识内容

更多推荐