React 輪播動畫探索
1. 前言
1.1. 氛圍氣泡需求
最近投入了一個需求,遇到一個需要用動畫去實現的場景。
我們的產品大大管它叫氛圍氣泡,在很多應用(淘寶、抖音、bilibili)的直播間場景都會有類似這樣營造氛圍感的組件,能夠讓你感知到其他用戶在當前直播間的行爲。
這個東西看起來轉瞬即逝的,但背後其實是基於一套和 push 通道相關的設計:
前人栽樹後人乘涼,所幸大佬們把 push 消息中心
和 後臺服務
都建設得很完善,所以這次開發我只需要做這麼一件事情:
設置監聽 push 的回調,拿到數據渲染對應組件。
1.2. 開發評估
看上去好簡單!讓我來簡單的評估一下它的開發成本:
1.2.1. 首先看看它像啥(是否有現有組件可以複用)
這東西一進一出的,還撲棱撲棱的閃,好似一個輪播圖。在公共組件庫蒐羅一下,找到了一個 Marquee(跑馬燈)
組件,它是基於 swiper/react
去實現的。
swiper
大家都熟,一個功能非常強大且開箱即用的組件,目前已經迭代到了 v7 版本。它也支持在現代前端框架下的使用,例如說支持 React。
在 React 中,我們可以給它初始化一堆幻燈片,讓它可以滑動:
1.2.2. swiper 實踐
基礎示例
import SwiperCore, { Autoplay } from"swiper";
import { Swiper, SwiperSlide } from"swiper/react";
// Import Swiper styles
import"swiper/css";
import"swiper/css/navigation";
import"swiper/css/pagination";
import"swiper/css/scrollbar";
import"./styles.css";
// install Swiper modules
SwiperCore.use([Autoplay]);
const renderBubble = (bubble) => {
const { name, wording, btnText } = bubble;
return (
<SwiperSlide>
<p class>
<span class>{name}</span>
<span class>{wording}</span>
<span class>{btnText}</span>
</p>
</SwiperSlide>
);
};
export default () => {
const dataList = [
{ name: "李*", wording: "諮詢了課程", btnText: "去諮詢" },
{ name: "黃*", wording: "領取了優惠券", btnText: "去領取" },
{ name: "高*", wording: "分享了直播間給好友", btnText: "去分享" },
{ name: "劉*", wording: "領取了直播間資料", btnText: "去領取" },
{ name: "朱*", wording: "購買了直播間課程《xxx》", btnText: "去領取" }
];
return (
<div style={{ background: "#000" }}>
<Swiper
slidesPerView={1}
direction="horizontal"
onSlideChange={() => console.log("slide change")}
autoplay
>
{dataList.map((item) => renderBubble(item))}
</Swiper>
</div>
);
};
設置了 autoplay,可以自動播放,效果如下:
細節改造
你可能發現了,上面的示例跟想要實現的效果還差很遠,我們需要的效果是:
-
輪播方向:從左至右
-
進入效果:從左到右一邊移入,一邊漸現
-
退出效果:原地不動,漸隱
接下來讓我們逐個擊破,改造一下 swiper。
輪播方向修改
autoplay 除了支持自動播放,還可以設置自動播放的方向。比如說,當 direction
爲 horizontal
的時候,每個滑塊默認是向左退出和進入的,即從右至左輪播。如果想要變成相反方向,可以這樣設置:
autoplay={{
delay: 3000,
reverseDirection: true
}}
進入效果和退出效果
關於 swiper 切換效果的定製,官方也提供了一些切換效果相關的 api:effect。
其中比較符合我們要求的應該是 creativeEffect
,創造性的切換效果。我們目前想要定製一套漸隱退出和滑動漸現進入的效果。
我們再設置一下 swiper 的參數:
<Swiper
slidesPerView={1}
direction="horizontal"
onSlideChange={() =>console.log("slide change")}
autoplay={{
delay: 3000,
reverseDirection: true
}}
loop
speed={3000}
effect={"creative"}
creativeEffect={{
prev: {
opacity: 0,
translate: ['-300%', 0, 0]
},
next: {
opacity: 0
}
}}
>
prev 和 next 的具體參數類型可以參考 swiper creativeEffect,比如說上面示例中 creativeEffect
的意思是:
-
進入動畫的起始狀態(prev):
-
translate:[’-300%‘, 0, 0],表示一開始在 x 軸的 -300% swiper 寬度的位置上
-
opacity:0,表示一開始透明不可見
-
退出動畫的結束狀態(next):
-
opacity:0,表示結束時透明不可見
經過我們的改造,最終效果如下:
侷限性
上面的效果其實並沒有完全滿足我們的要求,我們可以觀察到 swiper 的幻燈片進入和退出有這樣的特點:會有某一段時間,上一張幻燈片和下一張幻燈片會同時存在於可視區域。
這個會導致我們的滑動漸現進入效果不能完美實現,只能通過調整起始位置到儘可能遠,來擬合我們想要的效果。像上面其實就設置了 -300%,才達到了比較理想的效果。與之相對的,也帶來了另一個問題:透明度變化太快了,進入可視區域時幻燈片的 opacity 已經接近 1 了。
這下可把我整不會了,沒想到 swiper 還有這樣的侷限性。但幻燈片切換效果不佳並不是最主要的,更重要的還是氛圍氣泡業務邏輯的實現,我們看看結合 push 命令,動態更新幻燈片數量的情況下,swiper 在 react 中的狀態管理會變得多不堪。
另一個問題 —— 基於 swiper 動態更新氛圍氣泡
在實際業務使用中,其實還遇到了優先級展示的問題,氛圍氣泡的位置一共有三個組件在輪流展示:
-
打開直播間,先展示一段 5s 的課程公告
-
公告消失後,如果有氛圍氣泡數據,就展示氛圍氣泡
-
如果沒有氛圍氣泡,就展示兜底的引導進羣組件
如果我們需要動態插入氛圍氣泡的話,就會有兩種情況:
-
氛圍氣泡組件未初始化時:通過組件 state 去緩存氛圍氣泡數組
-
氛圍氣泡組件初始化後:通過 swiper 實例,調用 swiper.appendSlide/prependSlide 方法去插入氛圍氣泡幻燈片
比如說以下的業務代碼:
// 氛圍氣泡推送監聽
onAtmosphereBubble((data) => {
// 未展示前,緩存氣泡到狀態中
if (this.state.chatBoxTopIndex === 0) {
this.setState((prev) => ({
bubbleList: [
data,
...prev.bubbleList,
],
}));
} elseif (this.state.chatBoxTopIndex === 1) {
// 展示中,通過 swiper 實例插入幻燈片
this.addBubble(data);
} else {
// 即將展示,向狀態中插入幻燈片
this.setState((prev) => ({
bubbleList: [
data,
...prev.bubbleList,
],
}));
this.nextChatBoxTop(1);
}
});
const addBubble = (data) => {
this.swiper?.prependSlide('<div class="swiper-slide">new Slide</div>');
};
從這裏就能看出來,在 react 中,如果需要動態更新幻燈片的場景,使用 swiper 相當不合適。原因是 swiper 是通過示例方法去更新 UI,而 react 是通過 數據(狀態)去更新 UI 的,兩者不太兼容。
除此之外,實踐中也發現了很多其他的問題,比如說:
-
通過 swiper.appendSlide/prependSlide 方法去插入新的幻燈片,只能傳入 HTML 字符串,不能傳入 React 組件。也就是說,新的幻燈片需要手動綁定事件,且不具備 React 的生命週期 hook。
-
swiper 的幻燈片數據由組件 state 和 swiper 實例共同控制,會出現兩者數據不同步的情況,不易理解和管理。
1.3. 別的方案?
總的來說,swiper 在這個需求裏表現得不是很好,使用它反而會讓代碼變得複雜。既然沒有現有的組件可以複用,我們可以怎麼另闢蹊徑呢?接下來就來到本文的正題了,我們來通過一個神奇的 React 動畫庫來實現我們的需求。
2. react-transition-group
react-transition-group
是 React 官方實現的,用於操作過渡效果的組件庫。它可以在組件安裝和卸載時,增加過渡效果。一共提供了 4 個 api,上手成本極低。
我們首先從單個組件切換的場景入手,比如說現在希望爲一個組件增加進入和退出的動畫,就可以使用 Transition 或者 CSSTransition。
2.1. Transition 組件
Transition 組件是一個自由度比較高的組件,CSSTransition 也是基於它擴展的。它通過 in 參數跟蹤組件的進入和退出(或者說顯隱),並由開發者自定義動畫樣式。
話不多說,我們直接上代碼。下面設計了一個按鈕點擊來控制組件進入退出的示例:
- index.js
import React, { useState } from"react";
import { Transition } from"react-transition-group";
import"./styles.css";
const duration = 500;
const defaultStyle = {
transition: `opacity ${duration}ms ease-in-out`,
opacity: 0
};
const transitionStyles = {
entering: { opacity: 1 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
exited: { opacity: 0 }
};
exportdefaultfunction App() {
const [inProp, setInProp] = useState(false);
return (
<div>
<Transition in={inProp} timeout={duration}>
{(state) => (
<div
style={{
...defaultStyle,
...transitionStyles[state]
}}
>
I'm a fade Transition!
</div>
)}
</Transition>
<button onClick={() => setInProp((prev) => !prev)}>Click to Enter</button>
</div>
);
}
效果如下:
Transition 包含了以下參數:
-
in:控制組件顯示的布爾值,觸發進入或退出狀態
-
timeout:動畫的持續時間,單位爲毫秒,可以一次設置所有狀態的動畫時間,也可以單獨設置每個狀態的動畫時間。這個時間主要是結合 SwitchTransition api 使用的,需要和 css 中的動畫時間保持一致。
timeout={500}
// or
timeout={{
appear: 500,
enter: 300,
exit: 500,
}}
-
children 函數:提供了一個 state 參數的 children 函數,用於渲染我們的組件。其中 state 包括了以下狀態:
-
'entering'
-
'entered'
-
'exiting'
-
'exited'
-
開始動畫的三個鉤子,均接收一個回調函數
Function(node: HtmlElement, isAppearing: bool) -> void
,回調函數接收 2 個參數,第一個參數爲當前元素的 dom 節點,第二個參數表示當前動畫是否爲元素初次掛載時發生 -
onEnter:在動畫狀態變爲 entering 之前調用
-
onEntering:在動畫狀態變爲 entering 之後調用
-
onEntered:在動畫狀態變爲 entered 之後調用
-
離開動畫的三個鉤子,均接收一個回調函數
Function(node: HtmlElement) -> void
,回調函數僅接收當前元素的 dom 節點 -
onExit:在動畫狀態變爲 exiting 之前調用
-
onExiting:在動畫狀態變爲 exiting 之後調用
-
onExited:在動畫狀態變爲 exited 之後調用
-
......
2.2. CSSTransition 組件
通過上面 Transition 的例子,我們也看到了,組件當前的 class 是由 children 函數中的 state 參數來決定的,寫法上不夠 auto。CSSTransition 組件解決了這一問題,它繼承了 Transition 組件,並簡化了 className 的聲明。
同理,我們上一段代碼看看區別,依然是剛纔的例子:
- index.js
import React, { useState } from"react";
import { CSSTransition } from"react-transition-group";
import"./styles.scss";
const duration = 300;
exportdefaultfunction App() {
const [inProp, setInProp] = useState(false);
return (
<div>
<CSSTransition in={inProp} timeout={duration} classNames="my-fade">
<p class>I'm a fade Transition!</p>
</CSSTransition>
<button onClick={() => setInProp((prev) => !prev)}>Click to Enter</button>
</div>
);
}
- style.scss
.my-fade {
opacity: 0;
&-enter { opacity: 0 }
&-enter-active {
opacity: 1;
transition: opacity 300ms;
}
&-enter-done {
opacity: 1;
}
&-exit { opacity: 1 }
&-exit-active {
opacity: 0;
transition: opacity 300ms;
}
&-exit-done {
opacity: 0;
}
};
效果依然是:
CSSTransition 會根據 in 參數的變化,爲組件添加不同的 class。例如,當 in 變爲 true,會先後爲組件添加 {classNames}-enter、{classNames}-enter-active、{classNames}-enter-done 的 class,形成入場的動畫效果;當 in 變爲 false,則會爲組件添加 {classNames}-exit、{classNames}-exit-active、{classNames}-exit-done 的 class,形成退場的動畫效果。
CSSTransition 默認有三個階段 —— 消失(appear)、進入(enter)、離開(exit)。並且每個階段都先後添加三個類名,以 enter 爲例,分別是:
-
enter 表示開始動畫的初始階段
-
enter-active 表示開始動畫的激活階段
-
enter-done 表示開始動畫的結束階段,也是樣式的持久化展示階段
CSSTransition 的參數跟 Transition 差別不大,需要注意的是 classNames
這個參數。爲了與 React 中的 className 進行區別,classNames
這個參數在 className
的基礎上在末尾加了個 s
。
一般來說,這個參數將作爲動畫過渡的一系列類名(-enter、-enter-active、-enter-done、......)的前綴。
classNames="my-fade"
// my-fade-enter
// my-fade-enter-active
// my-fade-enter-done
// ......
但也支持對每個類名單獨定義:
classNames={{
appear: 'my-appear',
appearActive: 'my-active-appear',
appearDone: 'my-done-appear',
enter: 'my-enter',
enterActive: 'my-active-enter',
enterDone: 'my-done-enter',
exit: 'my-exit',
exitActive: 'my-active-exit',
exitDone: 'my-done-exit',
}}
2.3. SwitchTransition
SwitchTransition 用於包裹 Transition 或 CSSTransition 組件,並修改它們內部組件的過渡模式。它擁有一個 mode
參數,可以實現兩種效果:
-
out-in
:當前元素先轉出,然後當完成時,新元素轉入。 -
in-out
:新元素首先轉入,然後當完成時,當前元素轉出。
默認: 'out-in'
同樣上代碼來看看效果:
- index.js
import React, { useState } from"react";
import { CSSTransition, SwitchTransition } from"react-transition-group";
import { Button, Form } from"react-bootstrap";
import"./styles.scss";
const modes = ["out-in", "in-out"];
exportdefaultfunction App() {
const [mode, setMode] = useState("out-in");
const [state, setState] = useState(true);
return (
<div>
<div class>Mode:</div>
<div class>
{modes.map((m) => (
<Form.Check
key={m}
custom
inline
label={m}
id={`mode=msContentScript${m}`}
type="radio"
checked={mode === m}
value={m}
onChange={(event) => {
setMode(event.target.value);
}}
/>
))}
</div>
<div class>
<SwitchTransition mode={mode}>
<CSSTransition
key={state}
addEndListener={(node, done) => {
node.addEventListener("transitionend", done, false);
}}
classNames="fade"
>
<div class>
<Button onClick={() => setState((state) => !state)}>
{state ? "Hello, world!" : "Goodbye, world!"}
</Button>
</div>
</CSSTransition>
</SwitchTransition>
</div>
</div>
);
}
- style.scss
body {
padding: 2rem;
font-family: sans-serif;
text-align: center;
}
.label {
margin-bottom: 0.5rem;
}
.modes {
margin-bottom: 1rem;
}
.button-container {
margin-bottom: 0.5rem;
}
.fade {
&-enter .btn {
opacity: 0;
transform: translateX(-100%);
}
&-enter-active .btn {
opacity: 1;
transform: translateX(0%);
}
&-exit .btn {
opacity: 1;
transform: translateX(0%);
}
&-exit-active .btn {
opacity: 0;
transform: translateX(100%);
}
&-enter-active .btn,
&-exit-active .btn {
transition: opacity 500ms, transform 500ms;
}
}
效果如下:
從這裏可以看出和 swiper 的區別,swiper 類似於 in-out
的效果,而我們希望實現的氛圍氣泡是 out-in
的效果。
2.4. TransitionGroup
顧名思義,TransitionGroup
是用來管理多個 Transition/CSSTransition 的。當需要管理多個 Transition,即需要實現不同的動畫效果時,適合使用它。
<TransitionGroup>
{list.map(({ id }) => (
<CSSTransition
key={id}
timeout={300}
classNames="my"
>
<MyComponent key={id}/>
</CSSTransition>
))}
</TransitionGroup>
3. 用 react-transition-group 實現氛圍氣泡
瞭解了 react-transition-group
之後,我們很容易聯想到,可以用 CSSTransition + SwitchTransition(out-in mode) 實現我們的需求。
但在實現之前,還需要定義一下我們的數據結構。
3.1. 數據結構
氣泡是隨着用戶的交互,從客戶端觸發上報給到後臺,再由後臺反饋到其他客戶端的。由於上文提到氛圍氣泡不是常駐的,會去展示其他的組件,所以當後臺反饋了新的氣泡數據,會存在三種情況:
-
正在展示氛圍氣泡:將氣泡數據插入到展示順序的尾部。
-
正在展示不可中斷的組件(課程公告):將氣泡數據緩存起來。
-
正在展示可中斷的組件(兜底組件):將氣泡數據緩存起來,並立即展示氛圍氣泡。
顯然是一個 隊列
,我們可以維護一個氣泡的 JSX 元素數組,用一個 index 來決定當前展示的氣泡來實現切換渲染,並將不斷到來的氣泡數據插入到數組的尾部。
這樣的好處在於,相比 swiper/react 通過狀態和實例來維護氣泡的方式,我們統一使用狀態來維護氣泡數據更加符合數據驅動視圖的思想。
3.2. 隊列實現
我們將氣泡列表的展示順序(index)放到 props 中維護,使之變成受控的。並在隊列中維護一個定時器,定時觸發 props 中的 nextBubble 方法去更新 index。如下:
import React, { useEffect, useCallback } from"react";
import { CSSTransition, SwitchTransition } from"react-transition-group";
import"./index.css";
const AtmosphereBubbleSequence = ({
bubbleList,
index,
setCurIndex,
nextBubble,
next,
resetList
}) => {
const bubbleListLength = bubbleList.length;
let curIndex = 0;
if (index > -1 && index < bubbleListLength) {
curIndex = index;
} else {
curIndex = bubbleListLength - 1;
setCurIndex(curIndex);
}
useEffect(() => {
// 定時觸發
const interval = setInterval(() => {
nextBubble();
}, 3000);
return() => {
clearInterval(interval);
};
}, []);
const onExited = useCallback(
(node) => {
if (+node.dataset.bubbleKey === bubbleListLength - 1) {
// 切換到兜底組件
next();
// 清空氣泡列表
resetList();
}
},
[bubbleListLength, next, resetList]
);
return (
<SwitchTransition>
<CSSTransition
key={bubbleList[index]?.type?.name + index || Math.random()}
timeout={{
enter: 300,
exit: 300
}}
classNames="atmosphere-bubble-cnt"
unmountOnExit
onExited={onExited}
>
<div data-bubble-key={index} class>
{bubbleList[index]}
</div>
</CSSTransition>
</SwitchTransition>
);
};
export default AtmosphereBubbleSequence;
值得注意的是,需要在 onExited 回調中去判斷氣泡列表是否已經展示完畢,調用銷燬氣泡序列組件的方法,並清空氣泡數據列表,去展示其他的組件。
同時,我們設置氣泡的樣式如下:
.atmosphere-bubble-cnt {
padding: 016px;
}
.atmosphere-bubble-cnt-enter {
opacity: 0;
transform: translate3d(-60px, 0, 0);
}
.atmosphere-bubble-cnt-enter-active {
opacity: 1;
transform: translate3d(0, 0, 0);
transition: opacity 300ms, transform 300ms;
}
.atmosphere-bubble-cnt-exit {
opacity: 1;
}
.atmosphere-bubble-cnt-exit-active {
opacity: 0;
transition: opacity 300ms;
}
3.3. 效果模擬
爲了試驗效果,我們在外部設置一段 mock 邏輯,來模擬不斷塞入氣泡數據的情況:
import React, { useEffect, useState } from"react";
import Bubble from"./Bubble";
import AtmosphereBubbleSequence from"./Sequence";
import"./styles.css";
exportdefaultfunction App() {
const [index, setIndex] = useState(0);
const [bubbleDataList, setBubbleDataList] = useState([
{ nick: "李*", content: "諮詢了課程", eventMsg: "去諮詢" },
{ nick: "黃*", content: "領取了優惠券", eventMsg: "去領取" },
{ nick: "高*", content: "分享了直播間給好友", eventMsg: "去分享" },
{ nick: "劉*", content: "領取了直播間資料", eventMsg: "去領取" },
{ nick: "朱*", content: "購買了直播間課程《xxx》", eventMsg: "去領取" }
]);
useEffect(() => {
const interval = setInterval(() => {
setBubbleDataList((prev) => [
...prev,
{ nick: "朱*", content: "購買了直播間課程《xxx》", eventMsg: "去領取" }
]);
}, 3000);
return() => {
clearInterval(interval);
};
}, []);
if (!bubbleDataList.length) {
returnnull;
}
const nextBubble = (newIndex) => {
setIndex((prevIndex) => {
returntypeof newIndex === "undefined" ? prevIndex + 1 : newIndex;
});
};
return (
<div
style={{
background: "#000",
height: "56px",
display: "flex",
alignItems: "center"
}}
>
<AtmosphereBubbleSequence
bubbleList={bubbleDataList.map((data) => (
<Bubble data={data} />
))}
index={index}
setCurIndex={setIndex}
nextBubble={nextBubble}
/>
</div>
);
}
接着來看看效果:
很好,已經實現我們想要的效果了!
4. 總結
在本文我們接觸到了 swiper 和 react-transition-group 的使用,並分別用它們實現了氛圍氣泡需求。
4.1. 動畫效果層面的對比
-
react-transition-group 更加靈活,針對組件過渡的動畫效果有更廣泛的應用場景。
-
swiper 作爲輪播效果組件,它受限於前後幻燈片同時存在的這一問題,在氛圍氣泡需求中表現不是很好。
4.2. 狀態管理層面的對比
雖然 swiper 有這樣的侷限性,但這一問題並不是不能解決的,還是有 hack 技巧的。比如說,可以先把 swiper-container 先漸隱,再觸發幻燈片切換,並在中途增加動畫類實現漸現。只是說這段 js 邏輯不太優雅而已:
componentDidMount() {
const { next, setBubbleList } = this.props;
const swiperIns = this.swiper;
const interval = setInterval(() => {
swiperIns.$wrapperEl?.addClass('swiperFadeOut');
delay(300)
.then(() => {
if (swiperIns.activeIndex === 0) {
clearInterval(interval);
next(2);
setBubbleList();
thrownewError('退出動畫');
}
// 設置上一個滑塊爲透明隱藏
(swiperIns.slides[swiperIns.activeIndex] as HTMLElement).style.opacity = '0';
swiperIns.slidePrev(500);
return delay(300);
})
.then(() => {
swiperIns.$wrapperEl.addClass('swiperFadeIn');
return delay(200);
})
.then(() => {
swiperIns.$wrapperEl.removeClass('swiperFadeOut');
swiperIns.$wrapperEl.removeClass('swiperFadeIn');
})
.catch((error) => {
if (error.message === '退出動畫') {
console.log('已退出');
}
});
}, 2000);
}
由此也可以發現,在狀態管理方面:
-
react-transition-group 可以讓我們大膽地設計數據結構,來管理組件的過渡狀態。
-
swiper 相對不太適合 react 的狀態管理,在需要動態增刪幻燈片的場景,它依賴於實例方法,不易做到數據同步。
4.3. 方案選擇
面對類似氛圍氣泡的需求,如何選擇 swiper 和 react-transition-group 這兩類實現方案?
其實只要觀察,數據列表的長度是靜態的,還是會動態改變的。
-
靜態:使用幻燈片組件,如 swiper
-
動態:使用 react 生態的組件,如 react-transition-group
其中原因,相信你已經有所理解~
5. CodeSandbox demo
-
swiper 氛圍氣泡
-
Transition demo
-
CSSTransition demo
-
SwitchTransition demo
-
基於 react-transition-group 的氛圍氣泡
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/7maQP3LbIj_XZoHogfUIpA