# 如何在React製作跳轉動畫 :::info :bulb: 我會將這次分享分為大三部份,盡可能把這幾天學到的做個匯總,有興趣可以再深入了解。 ::: ## :book: 關於我在miniproject做的 :::success 我在這次miniproject中,用的方式是利用CSS寫好一個利用==keyframes==做的小動畫,再讓它3秒後fade out。 ::: ### 📑 代碼 ```jsx= import $ from 'jquery'; import {useState, useEffect} from 'react'; import {useLocation} from 'react-router-dom'; import './LoadingAnimation.css'; const LoadingAnimation = () => { // 當URL改變時useState會回傳一個新的包含目前URL的狀態和位置的物件函數。 // 每當URL有變更,則useLocation資訊也將更新。 const location = useLocation(); const [dis, setDis] = useState(true); useEffect(() => { setTimeout(() => {setDis(false);}, 3000); },[location]); if(dis === false) $(".loader-wrapper").fadeOut("slow"); return( <div className="loader-wrapper" > // span主要是用作inline容器,如果是要包裹一個block的情況,則是使用div。 <span className="loader"> <span className="loader-inner"></span> </span> </div> ); } export default LoadingAnimation; ``` 關於CSS的[animation屬性](https://www.oxxostudio.tw/articles/201803/css-animation.html)的補充 ```CSS= .loader-wrapper{ width: 100%; height: 100%; position: absolute; top: 0; left: 0; background-color: #242f3f; display:flex; justify-content: center; align-items: center; } .loader{ display: inline-block; width: 30px; height: 30px; position: relative; border: 4px solid #Fff; animation: loader 2s infinite ease; } .loader-inner{ vertical-align: top; display: inline-block; width: 100%; background-color: #fff; animation: loader-inner 2s infinite ease-in; } @keyframes loader{ 0% {transform: rotate(0deg);} 25% {transform: rotate(180deg);} 50% {transform: rotate(180deg);} 75% {transform: rotate(360deg);} 100% {transform: rotate(360deg);} } @keyframes loader-inner{ 0% {height: 0%;} 25% {height: 0%;} 50% {height: 100%;} 75% {height: 100%;} 100% {height: 0%;} } ``` ### ✒️ 實際效果 #### 🖼〈圖一〉我的日程本頁面載入動畫。 ![](https://img1.imgtp.com/2023/03/24/ZdG5o2OE.gif) ## :book: react-transition-group轉場動畫 :::success 為甚麼要用==react-transition-group==? react-transition-group給元素添加的enter,enter-active,exit,exit-active這一系列hook,簡直就是為我們的頁面入場離場而設計的。 ::: 當我們使用react-router在路由切換時,是完全沒有過渡效果,而是直接替換的,會顯得非常生硬。 在介紹實現轉場動畫之前,我們先來講講react-transition-group提供的==CSSTransition==和==TransitionGroup==這兩個組件。 ### 💾 CSSTransition CSSTransition的==in==屬性置為true時,CSSTransition首先會給其子組件加上==xxx-enter==的class,然後在下個tick時馬上加上==xxx-enter-active==的class。所以我們可以利用這一點,通過css的transition屬性,讓元素在兩個狀態之間平滑過渡,從而得到相應的動畫效果。 相反地,當==in==屬性置為false時,CSSTransition會給子組件加上==xxx-exit==和==xxx-exit-active==的class。 也就是說,我們只要寫好對應的class和css樣式就行了。 ```jsx= // src/App2/index.js export default class App1 extends React.PureComponent{ state = {show: true}; onToggle = () => this.setState({show: !this.state.show}); render() { const {show} = this.state; return ( <div className={'container'}> <div className={'square-wrapper'}> <CSSTransition in={show} timeout={500} classNames={'fade'} unmountOnExit={true} > <div className={'square'} /> </CSSTransition> </div> <Button onClick={this.onToggle}>toggle</Button> </div> ); } } ``` ```css= /* src/App2/index.css */ .fade-enter { opacity: 0; transform: translateX(100%); } .fade-enter-active { opacity: 1; transform: translateX(0); transition: all 500ms; } .fade-exit { opacity: 1; transform: translateX(0); } .fade-exit-active { opacity: 0; transform: translateX(-100%); transition: all 500ms; } ``` #### 🖼〈圖二〉用紅色正方形div示範左淡進右淡出。 ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34c05aa03c~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp) ### 💾 TransitionGroup TransitionGroup組件就是用來管理一堆節點==mounting==和==unmounting==過程的組件,非常適合處理我們這裡多個頁面的情況。 ```jsx= // src/App3/index.js export default class App2 extends React.PureComponent { state = {num: 0}; onToggle = () => this.setState({num: (this.state.num + 1) % 2}); render() { const {num} = this.state; return ( <div className={'container'}> <TransitionGroup className={'square-wrapper'}> <CSSTransition key={num} timeout={500} classNames={'fade'} > <div className={'square'}>{num}</div> </CSSTransition> </TransitionGroup> <Button onClick={this.onToggle}>toggle</Button> </div> ); } } ``` #### 🖼〈圖三〉用紅色正方形div示範左淡進右淡出。 ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34dec2ae0e~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp) 這次CSSTransition沒有in屬性了,而是用到了key屬性。 我們先來想想一個問題 **react的[dom diff](https://juejin.cn/post/6844904165026562056)機制用到了key屬性,如果前後兩次key不同,react會卸載舊節點,掛載新節點。那為何上面的代碼key變了,舊節點難道不是應該立馬消失嗎?為何我們還能看到它淡出的動畫過程?** 答案就在TransitionGroup身上,它在感知到其children變化時,會先保存住即將要被移除的節點,而在其動畫結束時才會真正移除該節點。 ``` <TransitionGroup> <div>0</div> </TransitionGroup> ⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️ <TransitionGroup> <div>0</div> <div>1</div> </TransitionGroup> ⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️ <TransitionGroup> <div>1</div> </TransitionGroup> ``` 因此我們能夠利用key值的變化來讓TransitionGroup接管我們在過渡時的頁面創建和銷毀工作,而在製作動畫效果時,僅僅需要關注如何選擇合適的key值以及需要什麼樣css樣式來實現就行了。 ### :rocket: 頁面轉場動畫 剛剛提到,用了TransitionGroup後我們的重點就放在如何選擇合適的key值。 既然我們是在頁面切換的時候觸發轉場動畫,自然是跟路由相關的值作為key值合適了。而react-router中的location對象就有一個key屬性,隨著瀏覽器中的地址發生變化而變化。然而,在實際場景中似乎並不適合,因為query參數或者hash變化也會導致location.key發生變化,但往往這些場景下並不需要觸發轉場動畫。 key值的選取還是得根據不同的項目而視。大部分情況下,推薦用==location.pathname==作為key值,因為它恰是我們不同頁面的路由。 ```jsx= // src/App3/index.js const Routes = withRouter(({location}) => ( <TransitionGroup className={'router-wrapper'}> <CSSTransition timeout={5000} classNames={'fade'} key={location.pathname} > <Switch location={location}> <Route exact path={'/'} component={HomePage} /> <Route exact path={'/about'} component={AboutPage} /> <Route exact path={'/list'} component={ListPage} /> <Route exact path={'/detail'} component={DetailPage} /> </Switch> </CSSTransition> </TransitionGroup> )); export default class App4 extends React.PureComponent { render() { return ( <BrowserRouter> <Routes/> </BrowserRouter> ); } } ``` #### 🖼〈圖四〉頁面跳轉右進左出。 ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34ff2602f7~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp) App3的代碼思路跟App2大致相同。 [withRouter](https://stackoverflow.com/questions/69934351/withrouter-is-not-exported-from-react-router-dom)是react-router提供的一個高階組件,可以為你的組件提供location,history等對象。因為我們這裡要用location.pathname作為CSSTransition的key值,所以用到了它。 這邊還有個細節,Switch組件會用這個對象來匹配其children中的路由,而且默認用的就是當前瀏覽器的url。如果在上面的例子中我們不給它[指定](https://www.delftstack.com/zh-tw/howto/react/exact-path-react-router/),那麼在轉場動畫中會發生很奇怪的現象,就是同時有兩個相同的節點在移動。 #### 🖼〈圖五〉兩個相同的節點在移動。 ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e355a80e3b4~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp) 這是因為TransitionGroup組件雖然會保留即將被remove的Switch節點,但是當location變化時,舊的Switch節點會用變化後的location去匹配其children中的路由。由於location都是最新的,所以兩個Switch匹配出來的頁面是相同的。好在我們可以改變Switch的location屬性,這樣它就不會總是用當前的location匹配了。 ### :rocket: 頁面動態過渡動畫 雖然那個動畫其實還存在一個嚴重的問題。明明應該是上個頁面從左側淡入,當前頁面從右側淡出。但是為什麼卻變成當前頁面從左側淡出,下個頁面從右側淡入,跟進入下個頁面的效果是一樣的。 要解決這問題其實也很簡單: 我們原本是把路由改變分成forward和back兩種操作。在forward操作時,當前頁面的exit效果是向左淡出;在back操作時,當前頁面的exit效果是向右淡出。所以我們只用fade-exit和fade-exit-active這兩個class,得到的動畫效果肯定是一致的。 因此我們只要用兩套class來分別管理forward和back操作時的動畫效果就可以了。 ```css= /* src/App4/index.css */ .forward-enter { opacity: 0; transform: translateX(100%); } .forward-enter-active { opacity: 1; transform: translateX(0); transition: all 500ms; } .forward-exit { opacity: 1; transform: translateX(0); } .forward-exit-active { opacity: 0; transform: translateX(-100%); transition: all 500ms; } .back-enter { opacity: 0; transform: translateX(-100%); } .back-enter-active { opacity: 1; transform: translateX(0); transition: all 500ms; } .back-exit { opacity: 1; transform: translateX(0); } .back-exit-active { opacity: 0; transform: translate(100%); transition: all 500ms; } ``` 不過光有css的支持還不行,我們還得在不同的路由操作時加上合適的class才行。那麼問題又來了,在TransitionGroup的管理下,一旦某個組件掛載後,其exit動畫其實就已經確定了,可以看官網上的這個issue。也就是說,就算我們動態地給CSSTransition添加不同的ClassNames屬性來指定動畫效果,但其實是無效的。 如何解決呢?我們可以藉助TransitionGroup的ChildFactory屬性以及React.cloneElement方法來強行覆蓋其className。比如: ```jsx= <TransitionGroup childFactory={child => React.cloneElement(child, { classNames: 'your-animation-class-name' })}> <CSSTransition> ... </CSSTransition> </TransitionGroup> ``` 上述幾個問題都解決之後,剩下的問題就是如何選擇合適的動畫class了。而這個問題的實質在於如何判斷當前路由的改變是forward還是back操作了。好在react-router已經貼心地給我們準備好了,其提供的history對像有一個action屬性,代表當前路由改變的類型,其值是 =='PUSH'== | =='POP'== | =='REPLACE'==。 ```jsx= // src/App4/index.js const ANIMATION_MAP = { PUSH: 'forward', POP: 'back' } const Routes = withRouter(({location, history}) => ( <TransitionGroup className={'router-wrapper'} childFactory={child => React.cloneElement( child, {classNames: ANIMATION_MAP[history.action]} )} > <CSSTransition timeout={500} key={location.pathname} > <Switch location={location}> <Route exact path={'/'} component={HomePage} /> <Route exact path={'/about'} component={AboutPage} /> <Route exact path={'/list'} component={ListPage} /> <Route exact path={'/detail'} component={DetailPage} /> </Switch> </CSSTransition> </TransitionGroup> )); ``` #### 🖼〈圖六〉成品。 ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34bf80c976~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp) 雖然說是完成了,但還有更多的細節可以學,有興趣可以參考這兩篇: [文章一](https://juejin.cn/post/6844903818073899022) [文章二](https://juejin.cn/post/6887471865720209415) #### 🖼〈圖七〉增加一個可由下而上淡入淡出的頁面。 ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34bfdbd299~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp)