1. 程式人生 > >React+React Router+React-Transition-Group實現頁面左右滑動+滾動位置記憶

React+React Router+React-Transition-Group實現頁面左右滑動+滾動位置記憶

React Router中,想要做基於路由的左右滑動,我們首先得搞清楚當發生路由跳轉的時候到底發生了什麼,和路由動畫的原理。

 

首先我們要先了解一個概念:historyhistory原本是內置於瀏覽器內的一個物件,包含了一些關於歷史記錄的一些資訊,但本文要說的historyReact-Router中內建的history,每一個路由頁面在props裡都可以訪問到這個物件,它包含了跳轉的動作(action)、觸發跳轉的listen函式、監聽每次跳轉的方法、location物件等。其中的location物件描述了當前頁面的pathnamequerystring和表示當前跳轉結果的

key屬性。其中key屬性只有在發生跳轉後才會有。

 

瞭解完history後,我們再來複習一下react router跳轉的流程。

當沒有使用路由動畫的時候,頁面跳轉的流程是:

使用者發出跳轉指令 -> 瀏覽器歷史接到指令,發生改變 -> 舊頁面銷燬,新頁面應用到文件,跳轉完成

當使用了基於React-Transition-Group的路由動畫後,跳轉流程將變為:

使用者發出跳轉指令 -> 瀏覽器歷史接到指令,發生改變 -> 新頁面插入到舊頁面的同級位置之前 -> 等待時間達到在React-Transition-Group中設定的timeout

後,舊頁面銷燬,跳轉完成。

當觸發跳轉後,頁面的url發生改變,如果之前有在historylisten方法上註冊過自己的監聽函式,那麼這個函式也將被呼叫。但是hisory要在元件的props裡才能獲取到,為了能在元件外部也能獲取到history物件,我們就要安裝一個包:https://github.com/ReactTraining/history。用這個包為我們建立的history替換掉react router自帶的history物件,我們就能夠在任何地方訪問到history物件了。

 

import { Router } from 'react-router-dom';

import { createBrowserHistory } from 
'history'; const history = createBrowserHistory() <Router history={history}> .... </Router>

 

 

這樣替換就完成了。註冊listener的方法也很簡單:history.listen(你的函式)即可。

這時我們能控制的地方有兩個:跳轉發生時React-Transition-Group提供的延時和enterexit類名,和之前註冊的listen函式。

本文提供的左右滑動思路為:判斷跳轉action,如果是push,則一律為當前頁面左滑離開螢幕,新頁面從右到左進入螢幕,如果是replace則一律為當前頁面右滑,新頁面自左向右進入。如果是pop則要判斷是使用者點選瀏覽器前進按鈕還是返回按鈕,還是呼叫了history.pop

由於無論使用者點選瀏覽器的前進按鈕或是後退按鈕,在history.listen中獲得的action都將為pop,而react router也沒有提供相應的api,所以只能由開發者藉助locationkey自行判斷。如果使用者先點選瀏覽器返回按鈕,再點選前進按鈕,我們就會獲得一個和之前相同的key

知道了這些後,我們就可以開始編寫程式碼了。首先我們先按照react router官方提供的路由動畫案例,將react transition group新增進路由元件:

<Router history={history}>
  <Route render={(params) => {
    const { location } = params
    return (
      <React.Fragment>
        <TransitionGroup id={'routeWrap'}>
          <CSSTransition classNames={'router'} timeout={350} key={location.pathname}>
            <Switch location={location} key={location.pathname}>
              <Route path='/' component={Index}/>
            </Switch>
          </CSSTransition>
        </TransitionGroup>
      </React.Fragment>
    )
  }}/>
</Router>

 

 

TransitionGroup元件會產生一個div,所以我們將這個divid設為'routeWrap'以便後續操作。提供給CSSTransitionkey的改變將直接決定是否產生路由動畫,所以這裡就用了location中的key

為了實現路由左右滑動動畫和滾動位置記憶,本文的思路為:利用history.listen,在發生動畫時當前頁面position設定為fixedtop設定為當前頁面的滾動位置,通過transitionleft進行左滑/右滑,新頁面position設定為relative,也是通過transitionleft進行滑動進入頁面。所有動畫均記錄location.key到一個數組裡,根據新的key和陣列中的key並結合action判斷是左滑還是右滑。並且根據location.pathname記錄就頁面的滾動位置,當返回到舊頁面時滾動到原先的位置。

先對思路中一些不太好理解的地方先解釋一下:

Q:為什麼當前頁面的position要設定為fixedtop

A:是為了讓當前頁面立即脫離文件流,使其不影響滾動條,設定top是為了防止頁面因positionfixed而滾回頂部。

Q:為什麼新頁面的position要設定為relative

A:是為了撐開頁面並出現滾動條。如果新頁面的高度足以出現滾動條卻將position設定為fixed或者absolute的話將導致滾動條不出現,即無法滾動。從而無法讓頁面滾動到之前記錄的位置。

Q:為什麼不用transform而要使用left來作為動畫屬性?

A:因為transform會導致頁面內positionfixed的元素轉變為absolute,從而導致排版混亂。

 

明白了這些之後,我們就可以開始動手寫樣式和listen函數了。由於篇幅有限,這裡就直接貼程式碼,不逐行解釋了。

先從動畫基礎樣式開始:

.router-enter-active{
  position: relative;
  opacity: 0; /*js執行到到timeout函式後再出現,防止頁面閃爍*/
}
.router-exit-active{
  position: relative;
  z-index: 1000;
}

 

然後是最主要的listen函式:

const config = {
  routeAnimationDuration: 350,
};


let historyKeys: string[] = JSON.parse(sessionStorage.getItem('historyKeys')); // 記錄history.location.key的列表。儲存進sessionStorage以防重新整理丟失

if (!historyKeys) {
  historyKeys = history.location.key ? [history.location.key] : [''];
}

let lastPathname = history.location.pathname;
const positionRecord = {};
let isAnimating = false;
let bodyOverflowX = '';

let currentHistoryPosition = historyKeys.indexOf(history.location.key); // 記錄當前頁面的location.key在historyKeys中的位置
currentHistoryPosition = currentHistoryPosition === -1 ? 0 : currentHistoryPosition;
history.listen((() => {
  if (!history.location.key) {  // 目標頁為初始頁
    historyKeys[0] = '';
  }
  const delay = 50; // 適當的延時以保證動畫生效
  if (!isAnimating) { // 如果正在進行路由動畫則不改變之前記錄的bodyOverflowX
    bodyOverflowX = document.body.style.overflowX;
  }
  setTimeout(() => { // 動畫結束後還原相關屬性
    document.body.style.overflowX = bodyOverflowX;
    isAnimating = false;
  }, config.routeAnimationDuration + delay);
  document.body.style.overflowX = 'hidden'; // 防止動畫導致橫向滾動條出現

  if (history.location.state && history.location.state.noAnimate) { // 如果指定不要發生路由動畫則讓新頁面直接出現
    setTimeout(() => {
      const wrap = document.getElementById('routeWrap');
      const newPage = wrap.children[0] as HTMLElement;
      const oldPage = wrap.children[1] as HTMLElement;
      newPage.style.opacity = '1';
      oldPage.style.display = 'none';
    });
    return;
  }
  const {action} = history;

  const currentRouterKey = history.location.key ? history.location.key : '';
  const oldScrollTop = window.scrollY;
  const originPage = document.getElementById('routeWrap').children[0] as HTMLElement;
  originPage.style.position = 'fixed';
  originPage.style.top = -oldScrollTop + 'px'; // 防止頁面滾回頂部
  setTimeout(() => { // 新頁面已插入到舊頁面之前
    isAnimating = true;
    const wrap = document.getElementById('routeWrap');
    const newPage = wrap.children[0] as HTMLElement;
    const oldPage = wrap.children[1] as HTMLElement;
    if (!newPage || !oldPage) {
      return;
    }
    const currentPath = history.location.pathname;

    const isForward = historyKeys[currentHistoryPosition + 1] === currentRouterKey; // 判斷是否是使用者點選前進按鈕

    if (action === 'PUSH' || isForward) {
      positionRecord[lastPathname] = oldScrollTop; // 根據之前記錄的pathname來記錄舊頁面滾動位置
      window.scrollTo({top: 0});  // 如果是點選前進按鈕或者是history.push則滾動位置歸零

      if (action === 'PUSH') {
        historyKeys = historyKeys.slice(0, currentHistoryPosition + 1);
        historyKeys.push(currentRouterKey); // 如果是history.push則清除無用的key
      }
    } else {
      window.scrollTo({ // 如果是點選回退按鈕或者呼叫history.pop、history.replace則讓頁面滾動到之前記錄的位置
        top: positionRecord[currentPath]
      });

      // 刪除滾動記錄列表中所有子路由滾動記錄
      for (const key in positionRecord) {
        if (key === currentPath) {
          continue;
        }
        if (key.startsWith(currentPath)) {
          delete positionRecord[key];
        }
      }
    }

    if (action === 'REPLACE') { // 如果為replace則替換當前路由key為新路由key
      historyKeys[currentHistoryPosition] = currentRouterKey;
    }
    window.sessionStorage.setItem('historyKeys', JSON.stringify(historyKeys)); // 對路徑key列表historyKeys的修改完畢,儲存到sessionStorage中以防重新整理導致丟失。

    // 開始進行滑動動畫
    newPage.style.width = '100%';
    oldPage.style.width = '100%';
    newPage.style.top = '0px';
    if (action === 'PUSH' || isForward) {
      newPage.style.left = '100%';
      oldPage.style.left = '0';

      setTimeout(() => {
        newPage.style.transition = `left ${(config.routeAnimationDuration - delay) / 1000}s`;
        oldPage.style.transition = `left ${(config.routeAnimationDuration - delay) / 1000}s`;
        newPage.style.opacity = '1'; // 防止頁面閃爍
        newPage.style.left = '0';
        oldPage.style.left = '-100%';
      }, delay);
    } else {
      newPage.style.left = '-100%';
      oldPage.style.left = '0';
      setTimeout(() => {
        oldPage.style.transition = `left ${(config.routeAnimationDuration - delay) / 1000}s`;
        newPage.style.transition = `left ${(config.routeAnimationDuration - delay) / 1000}s`;
        newPage.style.left = '0';
        oldPage.style.left = '100%';
        newPage.style.opacity = '1';
      }, delay);
    }
    currentHistoryPosition = historyKeys.indexOf(currentRouterKey); // 記錄當前history.location.key在historyKeys中的位置
    lastPathname = history.location.pathname;// 記錄當前pathname作為滾動位置的鍵
  });
}));

 

 

完成後我們再將路由中的延時配置為當前定義的config.routeAnimationDuration

export const routes = () => {
  return (
    <Router history={history}>
      <Route render={(params) => {
        const { location } = params;
        return (
          <React.Fragment>
            <TransitionGroup  id={'routeWrap'}>
              <CSSTransition classNames={'router'} timeout={config.routeAnimationDuration} key={location.pathname}>
                <Switch location={location} key={location.pathname}>
                  <Route path='/' exact={true} component={Page1} />
                  <Route path='/2' exact={true} component={Page2} />
                  <Route path='/3' exact={true} component={Page3} />
                </Switch>
              </CSSTransition>
            </TransitionGroup>
          </React.Fragment>
        );
      }}/>
    </Router>
  );
};

 

 

這樣路由動畫就大功告成了。整體沒有特別難的地方,只是對historycss相關的知識要求稍微嚴格了些。

附上本文的完整案例:https://github.com/axel10/react-router-slide-animation