三月过去了好几周,这天终于丢下了寒冬腊月的影子,慢慢开始热了起来,晴天真好,可以看到故事里的小黄花,又可以抖擞抖擞精神开始折腾起来了.
每天上班的第一件事情就是打开网易云音乐(WIN PC版本),找个歌单开始听歌,再打开VSCODE,不停地码码码...某天码累了小憩了一会,点了点云音乐,不经意发现它简直是一个神奇的存在,到处都是各式各样的组件,有菜单,跑马灯,卡片,pageHeader,滑动条...,业务代码写累了就容易就这样,会琢磨着做点有趣的事情,好吧,那我就开始做一个自己的网易云音乐吧!
万事开头难,难上加难就是上来就啃一块硬骨头.
技术栈说明
前端: Typescript / React / umijs / sass / ant-design/icons
后端: Java / SpringBoot / Mybatis / MySQL
走马灯Carousel
之所以选它做第一个组件除了它有点难度之外,还因为这家伙天天在我眼前滚滚滚,我没法不注意到它. - -!
因为是C端的产品,所以相比于antd的走马灯,云音乐的走马灯更加花哨,卡片之间存在一定的重叠,而且过度比较舒服平滑.
元素1,卡片
1.带圆角的卡片(width:542px;height:196px).
2.右下角有一个 独家/首发 之类的红色角落标(高28,宽不定,颜色:#EC4141,注意:网易产品的主色调都是这个红色,后续涉及到主红色我们直接调用这个颜色值)
3.点击卡片触发某个事件
元素2,横排点阵
元素3, 按钮
接下来我们逐个实现各个小组件,最后再配上动画效果
1.卡片
其实组件参数是比较简单的,看下我们的组件类型
1.1 组件类型
interface CardProps {
onClick?: () => void; //回调点击事件
text?: string;//卡片右下角的文字
className?: string;
}
1.2 组件代码
const Card: React.FC<CardProps> = ({ children, onClick, text, className }) => {
const classes = classnames('carousel-card-container', className);
return (
<div className={classes} onClick={onClick}>
{React.cloneElement(children as React.ReactElement, {
style: { width: '100%', height: '100%' },
})}
<div className="carousel-card-text">{text}</div>
</div>
);
};
export default Card;
1.3 样式
.carousel-card-container {
position: relative;
border-radius: 8px;
overflow: hidden;
}
.carousel-card-text {
position: absolute;
bottom: 0;
right: 0;
height: 22px;
padding: 4px 8px;
background: $primary-color; //$primary-color是一个全局的sass变量,代表主色 网易红:#EC4141
color: #fff;
font-size: 12px;
line-height: 14px;
border-top-left-radius: 8px;
border-bottom-right-radius: 8px;
}
1.4 注意点
1.利用cloneElement对children附加的属性width和height是为了让后续的子元素宽高适应父元素. 比如:
<Card><img src="http://example.com/xxx.png"/></Card>
这时img无论多少像素都会被压缩至适应父元素的值
1.5 效果图
<Card text="独家">
<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8835bba0ccdc4fd58a92c29f6af17580~tplv-k3u1fbpfcp-zoom-1.image"/>
</Card>
好像还可以
元素2 点阵
2.1 一个点
通过仔细观察归纳如下:
1.状态有2种,被选中时,红色, 非选中时,灰色
2.支持鼠标hover时回调事件
归纳完毕,我们代码部分依旧是配置先行
2.2 组件类型
interface DotProps {
isSelected?: boolean;
isHover?: (hover: boolean) => void;
style?: React.CSSProperties;
}
2.3 组件代码
const Dot: React.FC<DotProps> = ({ isSelected, isHover, style }) => {
const classes = classnames('carousel-dot-container', {
'carousel-dot-selected': isSelected,
});
return (
<div
className={classes}
style={style}
onMouseEnter={() => isHover?.(true)}
onMouseLeave={() => isHover?.(false)}
/>
);
};
export default Dot;
2.4 样式代码
.carousel-dot-container{
width: 6px;
height: 6px;
border-radius: 50%; //把正方形加上大圆角,变成原型
margin: 0 4px 0 4px; //用以之后点阵的间隙
background-color: #E6E6E6;
}
.carousel-dot-selected {
background-color:$primary-color //依旧是网易红
}
2.5 点阵
先归纳如下: 1.输入点阵数量 2.当前被选中的点 3.hover某个点后回调修改被选中的点
2.6 组件类型
interface DotsProps {
count: number;
current: number;
onChange?: (index: number) => void;
style?: React.CSSProperties;
}
2.7 组件代码
const Dots: React.FC<DotsProps> = ({ count, current, onChange }) => {
return (
<div className="carousel-dots-container">
{new Array(count).fill(0).map((curr, index) => {
return (
<Dot
key={index}
isSelected={current === index}
isHover={() => onChange?.(index)}
/>
);
})}
</div>
);
};
export default Dots;
2.8 样式代码
// 点阵样式
.carousel-dots-container {
display: flex;
justify-content:center;
}
2.9 效果图
import React, { useState } from 'react'
import Dots from '@/components/carousel/dots/Dots'
const Test = ()=>{
const [current,setCurrent] = useState(0)
return (
<div>
<Dots count={9} current={current} onChange={i=>setCurrent(i)}/>
</div>
)
}
export default Test
3 按钮
按钮组件是最简单的部分了,在这里我们把向左和向右按钮以一个基础组件的两个状态来表示
3.1 组件代码
import React from 'react';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import classnames from 'classnames';
interface ButtonProps {
onClick?: () => void;
placement: 'left' | 'right';
className?: string;
}
const Button: React.FC<ButtonProps> = ({ onClick, placement, className }) => {
const classes = classnames(className, 'carousel-button-container');
return (
<div className={classes} onClick={() => onClick?.()}>
{placement === 'left' ? (
<LeftOutlined className="carousel-button-icon" />
) : (
<RightOutlined className="carousel-button-icon" />
)}
</div>
);
};
export default Button;
3.2 样式代码
.carousel-button-container{
height: 30px;
width: 30px;
border-radius: 50%;
background: $grey-7; //$grey-X是一个灰阶系列色,代表不同深浅的灰色
opacity: 0.5;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.carousel-button-icon {
font-size: 12px;
color: $grey-5;
font-weight: 600;
&:hover,&.hover {
color: $grey-1;
}
}
3. 走马灯赋能
到这里我们已经把走马灯的三个基础组件卡片/点阵/按钮实现了,接下来到了重头戏,即如何布局,如何实现这个切换动画?
3.1 动画分解
整个动画切换的过程很快,大概0.5s完成一帧,经过录屏后0.25倍速+逐帧分析,我们归纳该动画的细节,具体的分解过程我就不多说了,直接看重点:
1.整个跑马灯的前台图片共三张,左|中|右,其中左和右分别有一定程度的缩放(css3中可以用scale去实现x,y的缩放,不需要手动去修改元素的大小),中间图z-index优先级最高,始终在最前方,覆盖左右两图.
2.除此之外的图片全部堆叠在中间图的后方被隐藏,堆叠时需要缩放,缩放值和左右图一样.
3.整个正向的切换逻辑如下:
当前帧
左 - 中 - 右
下一帧
左进卡堆最下方,中切换到左, 右切换到中,卡堆最上方的卡片切换到右
4.切换间隔4s左右
5.鼠标hover到跑马灯上时切换暂停,鼠标hover点阵时,直接切换hover点阵对应的卡片
3.2 组件代码
组件代码本身没有太难的点,只需要注意到只是利用了getClasses动态生成了各张卡片的className
import React from 'react';
import Card from './card/Card';
import Dots from './dots/Dots';
import Button from './button/Button';
import classnames from 'classnames';
interface CarouselProps {
cards: Array<{ text: string; img: string }>;
className?: string
}
const Carousel: React.FC<CarouselProps> = ({ cards,className }) => {
const [current, setCurrent] = React.useState(0); //当前卡片索引值
const countRef = React.useRef(0);
countRef.current = current;
const [isHover, setHover] = React.useState(false); //鼠标是否hover到跑马灯上
const useHoverRef = React.useRef(false);
useHoverRef.current = isHover;
const getClasses = (index: number) => {
const isLeft =
index === current - 1 || (current === 0 && index === cards.length - 1);
const isRight =
index === current + 1 || (current === cards.length - 1 && index === 0);
const isCurrent = current === index;
return classnames('carousel-card-size',className, {
'carousel-card-dock': !(isLeft || isRight || isCurrent),
'carousel-card-middle': isCurrent,
'carousel-card-left': isLeft,
'carousel-card-right': isRight,
});
};
if (cards.length < 3) {
throw new Error('走马灯元素不得少于3个');
}
const step = (directon: 1 | -1, source: 'manual' | 'auto') => {
if (useHoverRef.current && source === 'auto') {
return;
}
if (directon === 1) {
if (countRef.current === cards.length - 1) {
setCurrent(0);
} else {
setCurrent((current) => current + 1);
}
}
if (directon === -1) {
if (countRef.current === 0) {
setCurrent(cards.length - 1);
} else {
setCurrent((current) => current - 1);
}
}
};
//设置定时滚动,滚动间隔4s
React.useEffect(() => {
const timer = setInterval(() => {
step(1, 'auto');
}, 4000);
return () => clearInterval(timer);
}, []);
return (
<div
className="carousel-container"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<div className="carousel-cards">
{cards.map((card, index) => (
<Card key={index} text={card.text} className={getClasses(index)}>
<img src={card.img} />
</Card>
))}
{isHover ? (
<Button
placement="left"
className="carousel-button-left"
onClick={() => step(-1, 'manual')}
/>
) : null}
{isHover ? (
<Button
placement="right"
className="carousel-button-right"
onClick={() => step(1, 'manual')}
/>
) : null}
</div>
<div className="carousel-bottom-dots">
<Dots
count={cards.length}
current={current}
onChange={(i) => setCurrent(i)}
/>
</div>
</div>
);
};
export default Carousel;
3.3 样式代码
从上面的动画分解看,主要存在四种类型的卡片 左|中|右|卡堆,分别以
carousel-card-left,carousel-card-middle,carousel-card-right,carousel-card-dock
重点关注几个属性:
z-index实现堆叠顺序
transform实现缩放,分别缩放0.82
transform-origin转移元素中心,否则左右两边的卡片向默认中心缩放时会产生一个空隙
calc动态计算left位置
transition实现动画
在看完下方的css代码后你可能会注意到为什么右边的卡片(carousel-card-right)定位用了计算left的方式而不是简单的right:0,这是因为transition只能对相同的可度量的css属性产生过渡动画,left和right同时出现时transition无效,在这里我们只能让所有卡片都用left或者都用right进行定位.
$carousel-card-width: 542px !default;
@import './button/style'; //引入子组件样式代码
@import './card/style';
@import './dots/style';
.carousel-container{
width: 100%;
position: relative;
height: 250px;
}
.carousel-cards {
height: 196px;
.carousel-card-size {
width: $carousel-card-width;
height: 196px;
}
.carousel-card-middle {
z-index: 99;
position: absolute;
left: calc(50% - #{$carousel-card-width/2});
bottom: 0;
top: 0;
margin: auto;
transition: all 0.5s;
}
.carousel-card-left {
position: absolute;
z-index: 98;
top: 0;
bottom: 0;
left: 0;
margin-top: auto;
margin-bottom: auto;
transform: scale(0.82,0.82);//卡片缩放
transform-origin: 0% 50%; //左边卡片的元素中心迁移到最左方
transition: all 0.5s;
}
.carousel-card-right {
position: absolute;
z-index: 98;
top: 0;
bottom: 0;
left: calc(100% - #{$carousel-card-width});
margin-top: auto;
margin-bottom: auto;
transform: scale(0.82,0.82);
transform-origin: 100% 50%;//右边卡片的元素中心迁移到最右方
transition: all 0.5s;
}
.carousel-card-dock {
position: absolute;
left: calc(50% - #{$carousel-card-width/2});
bottom: 0;
top: 0;
margin: auto;
transition: all 0.5s;
transform: scale(0.82,0.82);
}
.carousel-button-left {
position: absolute;
left: 15px;
bottom: 0;
top: 0;
margin: auto;
z-index: 100;
}
.carousel-button-right {
position: absolute;
right: 15px;
bottom: 0;
top: 0;
margin: auto;
height: 30px;
z-index: 100;
}
}
.carousel-bottom-dots {
position: absolute;
width: 100%;
bottom: 0;
text-align: center;
}
3.4 效果图
import React, { useState } from 'react'
import Carousel from '@/components/carousel/Carousel'
const Test = ()=>{
const carouselCards = [
{text:'独家',img:'/resourcebed/picture?md5=9062dc80bb47556c3565efa18f1dcc32'},
{text:'独家',img:'/resourcebed/picture?md5=5624a16a955d3f52088afe8b5eb5bb12'},
{text:'新碟首发',img:'/resourcebed/picture?md5=98cfacc930a291d70d72eb1b447fe2b0'},
{text:'MV首发',img:'/resourcebed/picture?md5=d6d853c35dd5d0fffec8a7509a13c070'},
{text:'独家',img:'/resourcebed/picture?md5=2b97b3ec46feb33141d313e5379d8fd8'},
{text:'新歌首发',img:'/resourcebed/picture?md5=ae05e981386806b0dde1f848f7333937'},
{text:'独家',img:'/resourcebed/picture?md5=cfc889c49b92312bcb26fe8fc7e1e896'},
{text:'新歌首发',img:'/resourcebed/picture?md5=6e9bdc761b49f6dc95bfda35b1981636'},
{text:'独家',img:'/resourcebed/picture?md5=54298ef6444d6fa77584922e883ac5ca'}
]
return (
<div>
<Carousel cards={carouselCards}/>
</div>
)
}
export default Test
结语
感谢看完文章.
其实在写这篇文章的时候,整个拆解工作已经做了很多了,此时几乎已经把整个云音乐所有可用的组件都抽象出来,正在写后台的CRUD,后面陆续要把一些接口对上.等整个工作完成后,我将考虑如何把这个组件库给独立出来,当然这又是另外一个不小的挑战~~
最后,第一次在掘金写文章,如有不当,烦请指正,多谢!