本文转发并翻译至: Advanced animation patterns with Framer Motion
我从我的指南【中文】中得到 ✨ 了很多 ✨ 积极的反馈,这些动画用 Framer Motion 来激发人们的创造热情,不可否认的是,这个库激起了许多开发人员对基于 Web 的动画世界的兴趣。
虽然我在上一篇文章中介绍了构成动画的许多基础部分,以及如何使用 Framer Motion 轻松编排多个过渡,但我没有涉及该库提供的许多更高级的功能。
有没有想过如何在多个组件中传播动画或编排复杂的布局过渡? 好吧,本文将向您介绍这些高级模式的所有信息,并向您展示使用 Framer Motion 可以完成的一些伟大事情!
与之前博客文章一样,本文包含一系列交互式小部件和 Playground 以及预设示例,让您无需进行任何设置即可应用我们将要看到的 Framer Motion 概念!
让我知道你对这些例子的看法,以及它们是否有帮助。您的反馈非常重要,将帮助我在未来的博客文章中 😄 做得更好!
传播
当我尝试在我的项目中使用 Framer Motion 添加一些微交互时,第一个遇到的高级模式之一是传播。我很快学到,可以将父级动画组件的变化传播到任何子级动画组件中。然而,一开始这让我感到困惑,因为它打破了我对定义动画的原有心理模型。
还记得在我之前的博客文章中,我们了解到每个 Framer Motion Animation 都需要 3 个属性(props) initial
、 animate
、transition
来定义过渡/动画吗? 好吧,对于这种模式,这并不完全正确。
Framer Motion 允许变体“向动”通过每个运动子组件,只要这些运动组件没有定义 animate
props。在这种情况下,只有父运动组件定义 animate
props。子项本身只定义他们打算为这些变体提供的行为。
我在这个博客上使用传播的一个很好的例子是这个博客主页上的“精选”部分。当您将鼠标悬停在它时,每张卡片都会“发光”,这种效果可以通过这种模式实现。为了解释引擎盖下到底发生了什么,我在下面构建了这个小部件,在那里我再现了这种效果:
您可以看到,将鼠标悬停(如果您使用的是移动设备,则点击)卡片甚至其上方的标签都会触发发光效果。这是什么巫术?!通过单击“透视”按钮,您可以看到引擎盖下发生的事情:
- 有一个“看不见的”运动层覆盖在卡片和标签上。该层包含将变体设置为“悬停”的
whileHover
props。
- “发光”本身也是一个运动组件,但是,它唯一定义的是它自己的
variants
带有 hover
键的对象。
因此,当悬停这个不可见的层时,我们切换“悬停”变体,任何在其 variants
prop 中定义了此变体的子运动组件都将检测到此更改并切换相应的行为。
使用成帧器运动的传播模式示例
const CardWithGlow = () => {
const glowVariants = {
initial: {
opacity: 0
},
hover: {
opacity: 1
}
}
return (
<motion.div initial="initial" whileHover="hover">
{/* 子级动画组件设置与父级设置的键匹配的变量,以相应地进行动画。 */}
<motion.div variants={glowVariants} className="glow"/>
<Card>
<div>卡片上的一些文字</div>
</Card>
</motion.div>
)
}
现在让我们应用我们学到的关于 Framer Motion 的传播机制的知识!在下面的操场上,你会发现一个带有“悬停”动画的运动组件。将鼠标悬停在该组件的右端时,一个小图标将显示在该组件的右侧。您可以尝试:
- 修改用于包装按钮的运动组件中使用的变体键,并看到现在它偏离了父组件设置的内容,动画不会触发,并且按钮在悬停时不可见。
- 在包裹按钮的运动组件上设置一个
animate
prop ,并看到它现在会自行制作动画,并且不会使用父级在悬停时设置的变体。
import { styled } from '@stitches/react';
import { motion } from 'framer-motion';
const ListItem = styled(motion.li, {
width: '100%',
minWidth: '300px',
background: '#FCFCFC',
border: '1px solid #E2E2E2',
borderRadius: '8px',
padding: '8px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
marginBottom: '0px',
color: '#151715',
fontSize: 18,
});
const Button = styled('button', {
background: 'transparent',
cursor: 'pointer',
border: 'none',
shadow: 'none',
color: '#151715',
display: 'flex',
});
const InfoBox = styled('div', {
width: '50%',
});
const ARTICLES = [
{
category: 'swift',
title: 'Intro to SwiftUI',
description: 'An article with some SwitftUI basics',
id: 1,
},
];
const Item = (props) => {
const { article } = props;
const readButtonVariants = {
hover: {
opacity: 1,
},
initial: {
opacity: 0,
},
magic: {
rotate: 360,
opacity: 1,
},
};
return (
<ListItem layout initial="initial" whileHover="hover">
<InfoBox>{article.title}</InfoBox>
<motion.div
variants={readButtonVariants}
transition={{ duration: 0.25 }}
>
<Button
aria-label="read article"
title="Read article"
onClick={(e) => e.preventDefault()}
>
→
</Button>
</motion.div>
</ListItem>
);
};
const Example = () => <div className="p-3"><Item article={ARTICLES[0]} /></div>;
export default Example;
在卸载组件时对组件进行动画处理
到目前为止,我们只看到了在挂载时或在某些特定事件(如悬停或点击)之后触发动画的例子。但是,在组件卸载之前触发动画呢?某种“退出”过渡?
好吧,在第二部分中,我们将看看解决此用例的 Framer Motion 功能,也是给我留下最深刻印象的功能: AnimatePresence
!
在学习之前,我尝试实现某种退出动画 AnimatePresence
,但它很笨拙,并且总是需要额外的代码来设置适当的“过渡”状态(如 isClosing
, isOpening
)并切换该状态的相应动画。可以想象,它非常容易出错。
一种非常笨拙的方法来实现没有 AnimatePresence
的现有动画
const MagicComponent = () => {
const [hidden, setHidden] = React.useState(false);
const [hidding, setHidding] = React.useState(false);
const variants = {
animate: (hidding) => ({
opacity: hidding ? 0 : 1,
})
initial: {
opacity: 1
},
}
const hideButton = () => {
setHidding(true);
setTimeout(() => setHidden(true), 1500);
}
return (
<motion.button
initial="initial"
animate="animate"
variants={variants}
onClick={hideButton}
custom={hidding}
>
单击隐藏
</motion.button>
)
}
另一方面, AnimatePresence
它经过深思熟虑且易于使用。只需将任何运动组件包装在一个 AnimatePresence
组件中,您就可以设置 exit
prop!
AnimatePresence
用例示例
const MagicComponent = () => {
const [hidden, setHidden] = React.useState(false);
return (
<AnimatePresence>
{!hidden && (
<motion.button
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setHidden(true)}
>
单击隐藏
</motion.button>
)}
</AnimatePresence>
);
};
在下面的交互式小部件中,我展示了同一组件的 2 个版本:
- 左边的那个没有包裹
AnimatePresence
- 然而,第二个是包装的
这是代码方面的唯一区别。但正如你所看到的,差异是相当惊人的!
不使用 AnimatePresence
使用 AnimatePresence
👉 我在写这篇文章时发现了一些东西
为了根据用户按下的按钮设置正确的过渡方向(左或右),我在用户悬停在按钮上时设置了一个状态,该状态将相应地改变过渡的方向(虽然这不是最好的实现方式,但它有效 😄)。尽管右侧的示例(使用 AnimatePresence
)完美运行,但你可能已经注意到左侧的示例中,只需将鼠标悬停在其中一个按钮上,过渡就会开始。
似乎 AnimatePresence
可以跟踪在给定时间和整个状态更改中渲染的运动组件。
为此,我仍然需要研究 Framer Motion 的内部工作原理,尽管这让我感到惊讶,但考虑到用例,这种行为是有道理的。
我们现在有一个很棒的新工具,可以用来让我们的过渡变得更好!是时候在下面的 Playaround 上尝试一下了:
- 尝试删除
AnimatePresence
组件。请注意,这会使 Framer Motion 跳过 exit
prop 中指定的动画。
- 尝试修改
exit
prop 中定义的动画。例如,您可以使整个组件在退出时从 1 缩放到 0。(我已经在下面 😄 的代码中添加了被注释的正确动画对象)
import { styled } from '@stitches/react';
import { AnimatePresence, motion } from 'framer-motion';
import React from 'react';
import Pill from './Pill';
const List = styled(motion.ul, {
padding: '16px',
maxWidth: '340px',
background: ' hsl(223, 15%, 10%)',
borderRadius: '8px',
display: 'grid',
gap: '16px',
});
const ListItem = styled(motion.li, {
width: '100%',
background: 'hsla(222, 89%, 65%, 10%)',
boxShadow: '0 0px 10px -6px rgba(0, 24, 40, 0.3)',
borderRadius: '8px',
padding: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
marginBottom: '0px',
color: 'hsl(223, 15%, 65%)',
fontSize: 18,
});
const Button = styled('button', {
background: 'transparent',
cursor: 'pointer',
border: 'none',
shadow: 'none',
color: 'hsl(223, 15%, 65%)',
display: 'flex',
});
const InfoBox = styled('div', {
width: '50%',
});
const FilterWrapper = styled('div', {
marginBottom: '16px',
input: {
marginRight: '4px',
},
label: {
marginRight: '4px',
},
});
const ARTICLES = [
{
category: 'swift',
title: 'Intro to SwiftUI',
description: 'An article with some SwitftUI basics',
id: 1,
},
{
category: 'js',
title: 'Awesome React stuff',
description: 'My best React tips!',
id: 2,
},
{
category: 'js',
title: 'Styled components magic',
description: 'Get to know ways to use styled components',
id: 3,
},
{
category: 'ts',
title: 'A guide to Typescript',
description: 'Type your React components!',
id: 4,
},
];
const categoryToVariant = {
js: 'warning',
ts: 'info',
swift: 'danger',
};
const Item = (props) => {
const { article, showCategory } = props;
const readButtonVariants = {
hover: {
opacity: 1,
},
initial: {
opacity: 0,
},
};
return (
<ListItem initial="initial" whileHover="hover">
<InfoBox>{article.title}</InfoBox>
{}
<AnimatePresence>
{showCategory && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Pill variant={categoryToVariant[article.category]}>
{article.category}
</Pill>
</motion.div>
)}
</AnimatePresence>
<motion.div variants={readButtonVariants} transition={{ duration: 0.25 }}>
<Button
aria-label="read article"
title="Read article"
onClick={(e) => e.preventDefault()}
>
→
</Button>
</motion.div>
</ListItem>
);
};
const Component = () => {
const [showCategory, setShowCategory] = React.useState(false);
return (
<div className="p-3">
<FilterWrapper>
<div>
<input
type="checkbox"
id="showCategory"
checked={showCategory}
onChange={() => setShowCategory((prev) => !prev)}
/>
<label htmlFor="showCategory">显示类别</label>
</div>
</FilterWrapper>
<List>
{ARTICLES.map((article) => (
<Item
key={article.id}
article={article}
showCategory={showCategory}
/>
))}
</List>
</div>
);
};
export default Component;
布局动画
我们现在知道:
- 在一组运动组件中传播动画
- 向组件添加
exit
过渡,以便它可以正常卸载
这些高级模式应该使我们能够制作一些非常流畅的过渡,对吧? ok 等到您听到更多关于 Framer Motion 如何处理布局动画的信息!
什么是“布局动画”?
布局动画是任何涉及布局相关属性的动画,例如:
position
属性
flex
或 grid
属性
width
或 height
- 对元素进行排序
但为了让你对我在这里谈论的内容有更多的了解,让我们试着看一下下面的 playground,它展示了同一组件的 2 个版本:
- 第一个示例通过仅使用我们目前了解的模式,在动画属性中设置
justify-content
属性来在 flex-start
和 flex-end
之间实现动画效果。
- 第二个示例使用一个新的属性:
layout
。 将其设置为 true
告诉 Framer Motion,一个与布局相关的属性(以及组件的布局)将在重新渲染时发生变化。这些属性本身只是按照开发者通常在不使用 Framer Motion 时的方式,在 CSS 中定义。
import { styled } from '@stitches/react';
import { AnimatePresence, motion } from 'framer-motion';
import React from 'react';
const SwitchWrapper1 = styled(motion.div, {
width: '50px',
height: '30px',
borderRadius: '20px',
cursor: 'pointer',
display: 'flex',
padding: '2px',
alignItems: 'center',
});
const SwitchHandle1 = styled(motion.div, {
background: '#fff',
width: '26px',
height: '26px',
borderRadius: '50%',
});
const Switch1 = () => {
const [active, setActive] = React.useState(false);
const switchVariants = {
initial: {
backgroundColor: '#111',
},
animate: (active) => ({
backgroundColor: active ? '#f90566' : '#111',
justifyContent: active ? 'flex-end' : 'flex-start',
}),
};
return (
<SwitchWrapper1
initial="initial"
animate="animate"
onClick={() => setActive((prev) => !prev)}
variants={switchVariants}
custom={active}
>
<SwitchHandle1 />
</SwitchWrapper1>
);
};
const SwitchWrapper2 = styled('div', {
width: '50px',
height: '30px',
borderRadius: '20px',
cursor: 'pointer',
display: 'flex',
background: '#111',
justifyContent: 'flex-start',
padding: '2px',
alignItems: 'center',
'&[data-isactive="true"]': {
background: '#f90566',
justifyContent: 'flex-end',
},
});
const SwitchHandle2 = styled(motion.div, {
background: '#fff',
width: '26px',
height: '26px',
borderRadius: '50%',
});
const Switch2 = () => {
const [active, setActive] = React.useState(false);
return (
<SwitchWrapper2
data-isactive={active}
onClick={() => setActive((prev) => !prev)}
>
<SwitchHandle2 layout />
</SwitchWrapper2>
);
};
const Example = () => (
<div className="p-3">
<p className="mb-2">
Switch 1: 尝试在 Framer Motion 动画对象中对 justify-content 进行动画处理。
</p>
<Switch1 />
<br />
<p className="mb-2">
Switch 2: 使用布局动画和 layout 属性来对 justify-content 进行动画处理。
</p>
<Switch2 />
</div>
);
export default Example;
我们可以在这里观察到很多事情:
- 第一个示例不起作用,看起来 Framer Motion 无法优雅地将不透明度从 0 过渡到 1 那样,平滑地在
justify-content
属性之间进行过渡。
- 但第二个组件按预期在
flex-start
和 flex-end
属性之间转换。通过在运动组件中设置为 layout
true
,Framer Motion 可以平滑地转换组件 justify-content
的属性。
- 第二个组件的另一个优点是:它不像第一个组件那样对 Framer Motion 有那么多的“硬依赖性”。我们可以用一个简单的
div
替换 motion.div
,组件本身仍然可以工作。
我计划调整此博客上实现的一些动画,并可能将它们转换为适当的 layout
动画以简化代码。我很确定我的 Header
和 Search
组件可以从中受益,因为它们的动画以布局/高度变化为中心。
共享布局动画
我们现在知道什么是布局动画,以及如何在一些特定用例中利用它们。但是,如果我们开始拥有跨越多个组件的布局动画,会发生什么?
更新时间: 2021 年 11 月
在 Framer Motion v5.0
发布后,本节已完全重写,包括示例。
AnimatedSharedLayout
已弃用,取而代之的是一种新的、性能更高的方法来处理共享布局动画。
您可以在 v5.0
迁移文档中阅读有关此更新的更多详细信息。
在最新版本的 Framer Motion 中,构建共享布局动画得到了极大的改进:我们唯一需要做的就是为共享布局动画中的组件设置一个公共 layoutId
prop。
下面,您将找到一个小组件,其中展示了共享布局动画的示例。
单击上面示例中的一个表情符号时,您会注意到:
- 启用公共
layoutId
时,边框将正常移动到新选定的元素
- 当公共
layoutId
元素被禁用时,边框将突然出现在新选择的元素周围(即未定义或不同)
为了获得这个看似复杂的动画,我们需要做的就是添加一个 prop,就是这样!✨ 特别是在这个例子中,我添加的只是对蓝色圆圈组件的每个实例的共同 layoutId
调用 border
。
使用layoutId
prop 的共享动画布局示例
const MagicWidgetComponent = () => {
const [selectedID, setSelectedID] = React.useState('1');
return (
<ul>
{items.map((item) => (
<li
style={{
position: 'relative'
}}
key={item.id}
onClick={() => setSelectedID(item.id)}
>
<Circle>{item.photo}</Circle>
{selectedID === item.id && (
<motion.div
layoutId="border"
style={{
position: 'absolute',
borderRadius: '50%',
width: '48px',
height: '48px',
border: '4px solid blue';
}}
/>
)}
</li>
))}
</Grid>
);
};
在以前版本的 Framer Motion 中,以前 v5.0
,这种用例需要现在已弃用 AnimatedSharedLayout
的组件。
除了处理共享布局动画之外,此组件还有助于处理相互影响的布局动画。现在,如果您最终遇到涉及大量单个布局动画的情况,则需要将它们与 LayoutGroup
组件“分组”。
请参阅下面 playground 中的示例来尝试一下!
是时候尝试一下我们刚刚学到的东西了!最后一个示例将前面的所有 playground 编译在一起以创建此列表组件。此实现包括:
- 使用
ListItem
组件上的 layout
prop 对列表重新排序进行动画处理
- 使用列表本身上的
layout
prop,在单击时展开项目时优雅地调整大小
- 用于防止布局动画期间出现故障的
layout
prop 的其他实例(尤其是涉及更改列表项高度的实例)
您可以尝试:
- 注释掉或删除
ListItem
上的 layout
prop,然后看到现在,重新排序突然发生,👉 不再过渡!
- 注释掉或删除
LayoutGroup
并注意这如何影响所有布局动画
- 尝试在
<Title/>
组件上添加 layout
prop,并看到它在物品高度变化时优雅地调整
import { styled } from '@stitches/react';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import React from 'react';
import Pill from './Pill';
const List = styled(motion.ul, {
padding: '16px',
maxWidth: '340px',
background: ' hsl(223, 15%, 10%)',
borderRadius: '8px',
display: 'grid',
gap: '16px',
});
const ListItem = styled(motion.li, {
width: '100%',
background: 'hsla(222, 89%, 65%, 10%)',
boxShadow: '0 0px 10px -6px rgba(0, 24, 40, 0.3)',
borderRadius: '8px',
padding: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
marginBottom: '0px',
color: 'hsl(223, 15%, 65%)',
fontSize: 18,
});
const Button = styled('button', {
background: 'transparent',
cursor: 'pointer',
border: 'none',
shadow: 'none',
color: 'hsl(223, 15%, 65%)',
display: 'flex',
});
const InfoBox = styled('div', {
width: '50%',
});
const FilterWrapper = styled('div', {
marginBottom: '16px',
input: {
marginRight: '4px',
},
label: {
marginRight: '4px',
},
});
const Title = motion.div;
const ARTICLES = [
{
category: 'swift',
title: 'Intro to SwiftUI',
description: 'An article with some SwitftUI basics',
id: 1,
},
{
category: 'js',
title: 'Awesome React stuff',
description: 'My best React tips!',
id: 2,
},
{
category: 'js',
title: 'Styled components magic',
description: 'Get to know ways to use styled components',
id: 3,
},
{
category: 'ts',
title: 'A guide to Typescript',
description: 'Type your React components!',
id: 4,
},
];
const categoryToVariant = {
js: 'warning',
ts: 'info',
swift: 'danger',
};
const Item = (props) => {
const { article, showCategory, expanded, onClick } = props;
const readButtonVariants = {
hover: {
opacity: 1,
},
initial: {
opacity: 0,
},
};
return (
<ListItem layout initial="initial" whileHover="hover" onClick={onClick}>
<InfoBox>
{}
<Title
>
{article.title}
</Title>
<AnimatePresence>
{expanded && (
<motion.div
style={{ fontSize: '12px' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{article.description}
</motion.div>
)}
</AnimatePresence>
</InfoBox>
<AnimatePresence>
{showCategory && (
<motion.div
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Pill variant={categoryToVariant[article.category]}>
{article.category}
</Pill>
</motion.div>
)}
</AnimatePresence>
<motion.div
layout
variants={readButtonVariants}
transition={{ duration: 0.25 }}
>
<Button
aria-label="read article"
title="Read article"
onClick={(e) => e.preventDefault()}
>
→
</Button>
</motion.div>
</ListItem>
);
};
const Component = () => {
const [showCategory, setShowCategory] = React.useState(false);
const [sortBy, setSortBy] = React.useState('title');
const [expanded, setExpanded] = React.useState(null);
const onSortChange = (event) => setSortBy(event.target.value);
const articlesToRender = ARTICLES.sort((a, b) => {
const itemA = a[sortBy].toLowerCase();
const itemB = b[sortBy].toLowerCase();
if (itemA < itemB) {
return -1;
}
if (itemA > itemB) {
return 1;
}
return 0;
});
return (
<div className="p-3">
<FilterWrapper>
<div>
<input
type="checkbox"
id="showCategory2"
checked={showCategory}
onChange={() => setShowCategory((prev) => !prev)}
/>
<label htmlFor="showCategory2">显示类别</label>
</div>
<div>
排序方式:{' '}
<input
type="radio"
id="title"
name="sort"
value="title"
checked={sortBy === 'title'}
onChange={onSortChange}
/>
<label htmlFor="title">标题</label>
<input
type="radio"
id="category"
name="sort"
value="category"
checked={sortBy === 'category'}
onChange={onSortChange}
/>
<label htmlFor="category">类别</label>
</div>
</FilterWrapper>
{}
<LayoutGroup>
<List layout>
{articlesToRender.map((article) => (
<Item
key={article.id}
expanded={expanded === article.id}
onClick={() => setExpanded(article.id)}
article={article}
showCategory={showCategory}
/>
))}
</List>
</LayoutGroup>
</div>
);
};
export default Component;
结论
恭喜您,您现在是 Framer Motion 专家 🎉!从传播动画到编排复杂的布局动画,我们只是介绍了该库提供的一些最先进的模式。我们看到所提供的一些工具设计得多么好,以及由于这些工具实现复杂的转换是多么容易,这些转换通常需要更多的代码,或者最终会产生更多不良的副作用。
我真的希望这篇博文中提供的例子有助于说明那些原本很难用文字描述的概念,最重要的是,这些概念对你来说很有趣。像往常一样,不要犹豫,向我发送有关我的写作、代码或示例的反馈,我一直在努力改进这个博客!
在阅读本指南后,您是否想出了一些很酷的动画?
不要犹豫,给我发一条消息,展示你的作品!
想看更多例子吗?
Framer Motion 文档中有大量可以在 Codepen 上使用的文档。
如果您想更深入地了解,以下是查看本文中介绍的小部件实现的链接列表: