尽管它的名字很有趣,但如果您曾经使用过 iOS 设备,您就会认出 UISegmentedControl:
在这篇文章中,我们将展示如何通过 React 和 Framer Motion 在网页上把它创建出来。
技术栈
我们可以通过使用 Next.js/React/Typescript/SASS/CSS 模块来实现,但是任何其他的React项目也可以实现,只需要保证都是通过使用 Framer Motion 来处理动画效果即可。
最终代码如下
import { useState } from "react";
import { motion, AnimateSharedLayout } from "framer-motion";
import styles from "./segmentedcontrol.module.scss";
type SegmentedControlProps = {
items: Array<string>;
};
const SegmentedControl = ({ items }: SegmentedControlProps): JSX.Element => {
const [activeItem, setActiveitem] = useState(0);
return (
<AnimateSharedLayout>
<ol className={styles.list}>
{items.map((item, i) => {
const isActive = i === activeItem;
return (
<motion.li
className={
isActive || i === activeItem - 1
? styles.itemNoDivider
: styles.item
}
whileTap={isActive ? { scale: 0.95 } : { opacity: 0.6 }}
key={item}
>
<button
onClick={() => setActiveitem(i)}
type="button"
className={styles.button}
>
{isActive && (
<motion.div
layoutId="SegmentedControlActive"
className={styles.active}
/>
)}
<span className={styles.label}>{item}</span>
</button>
</motion.li>
);
})}
</ol>
</AnimateSharedLayout>
);
};
export default SegmentedControl;
/* CSS variables from my global config */
:root {
--boxBg: #f3f3f3;
--activeBg: #292929;
--text: #000;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--boxBg: #1f1e1d;
--activeBg: #292929;
--text: #fff;
}
.list {
display: inline-flex;
margin: 0;
padding: 3px;
list-style: none;
background-color: var(--boxBg);
border-radius: 10px;
}
.item {
position: relative;
margin-bottom: 0;
line-height: 1;
&:after {
position: absolute;
top: 15%;
right: -0.5px;
display: block;
width: 1px;
height: 70%;
background-color: var(--border);
transition: opacity 200ms ease-out;
content: "";
}
&:last-of-type {
&:after {
display: none;
}
}
}
.itemNoDivider {
composes: item;
&:after {
opacity: 0;
}
}
.button {
position: relative;
margin: 0;
padding: 7px 30px;
color: var(--text);
line-height: 1;
background: transparent;
border: none;
outline: none;
&:hover,
&:focus {
cursor: pointer;
}
}
.label {
position: relative;
z-index: 2;
}
.active {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
background-color: var(--inputBg);
border-radius: 7px;
box-shadow: var(--shadow);
content: "";
}
这里发生了一些事情,我们来逐一了解一下。
首先,我们确保组件接受一个项目数组作为道具,然后我们初始化我们的状态以跟踪使用 react useState 选中了哪个项目。
import { useState } from 'react'
type SegmentedControlProps = {
items: Array<string>
}
const SegmentedControl = ({ items }: SegmentedControlProps): JSX.Element => {
const [activeItem, setActiveitem] = useState(0)
return ();
}
在实际使用案例中,你可能会选择通过页面路径名来跟踪活动项目,以便你可以链接到特定页面,在这种情况下,你可以使用各自的路由解决方案获取当前路径名,然后将其与你的项目进行比较。对于 Next.js,类似于:
/* example.com/two */
import { useRouter } from "next/router";
const { pathname } = useRouter();
const items = ["one", "two", "three"];
{
items.map((item) => {
const activeItem = pathname.contains(item);
});
}
继续,我们使用 Framer Motion 中的 AnimateSharedLayout
包装我们的组件,这是一种在不同元素或组件之间进行动画的非常简单的方法。如果你使用过 Keynote,你可以把它想象成“神奇移动”过渡。在这个示例中,我们使用它在切换项目时为我们的活动背景状态设置动画。
我们将映射我们的项目,通过将项目索引与我们的状态进行比较来获取当前活动选项卡,并为每个项目返回一个 motion.li
。运动组件 用于通过传递诸如 animate
(动画将如何完成)、initial
(动画的初始状态)、exit
(组件卸载时的动画外观)等道具来指定我们希望动画的外观。还有一些用于诸如悬停和点击之类的操作的帮助器道具,例如 whileHover
和 whileTap
。在此示例中,我们根据项目是否处于活动状态对 whileTap
道具使用两种不同的方式。如果处于活动状态,则将活动状态稍微缩小,以便用户看到 UI 做出响应,否则稍微更改不透明度。
如果显示的项目超过 2 个,我们希望在项目之间显示分隔符。我们可以通过使用 :after 伪元素在 CSS 中做到这一点。这样,我们无需向原始 DOM 添加任何额外元素。让我们根据分隔符是否应该可见来更改 motion.li
的类名。有两种情况下它不应该可见:1:当前项目被选中时,2:它是当前被选中项目之前的一个项目。这是因为我们将分隔符定位在每个项目的右侧(right: -0.5px
)。我们的最后一个项目不需要任何分隔符,所以我们可以使用 :last-of-type
选择器将其移除。我们还添加一个不透明度过渡,以便在切换项目时分隔符平滑淡出。
return (
<AnimateSharedLayout>
<ol className={styles.list}>
{items.map((item, i) => {
const isActive = i === activeItem;
return (
<motion.li
className={
isActive || i === activeItem - 1
? styles.itemNoDivider
: styles.item
}
whileTap={isActive ? { scale: 0.95 } : { opacity: 0.6 }}
key={item}
>
…
</motion.li>
);
})}
</ol>
</AnimateSharedLayout>
);
.item {
position: relative;
margin-bottom: 0;
line-height: 1;
&:after {
position: absolute;
top: 15%;
right: -0.5px;
display: block;
width: 1px;
height: 70%;
background-color: var(--border);
transition: opacity 200ms ease-out;
content: "";
}
&:last-of-type {
&:after {
display: none;
}
}
}
.itemNoDivider {
composes: item; /* css modules syntax, compiles to something like "class="item itemNoDivider" */
&:after {
opacity: 0;
}
}
我们将在每个项目中添加一个按钮,以便单击时更改项目。如果当前项目是活动项目,我们将添加一个 motion.div
来渲染活动背景样式。这是我们通过传递 layoutId
告诉 AnimatePresence 要为哪个元素设置动画的地方。它可以是任何字符串,但需要与所有应共享相同过渡的元素匹配。让我们为活动 div 添加一些样式,并将我们的项目文本放入一个 span 中,以便使用 CSS 将其定位在背景 div 之上。
<button
onClick={() => setActiveitem(i)}
type="button"
className={styles.button}
>
{isActive && (
<motion.div layoutId="SegmentedControlActive" className={styles.active} />
)}
<span className={styles.label}>{item}</span>
</button>
.button {
position: relative;
margin: 0;
padding: 7px 30px;
color: var(--text);
line-height: 1;
background: transparent;
border: none;
outline: none;
&:hover,
&:focus {
cursor: pointer;
}
}
.label {
position: relative;
z-index: 2;
}
.active {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
background-color: var(--activeBg);
border-radius: 7px;
box-shadow: var(--shadow);
content: "";
}
当点击更改项目时,我们的背景现在会神奇地进行动画。如果你尝试点击已经处于活动状态的同一项目,它会略微缩小。如果项目超过 2 个,我们将在项目之间添加分隔符,这些分隔符不是活动项目的兄弟元素。切换项目时,分隔符会平滑淡出。