zoukankan      html  css  js  c++  java
  • React+React Router+React-Transition-Group实现页面左右滑动+滚动位置记忆

    2018年12月17日更新:

    修复在qq浏览器下执行pop跳转时页面错位问题

    本文的代码已封装为npm包发布:react-slide-animation-router

    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{
        position: fixed;
        opacity: 0;
        transition : left 1s;
    }
    .router-enter-active{
      position: relative;
      opacity: 0; /*js执行到到timeout函数后再出现,防止页面闪烁*/
    }
    .router-exit-active{
      position: relative;
      z-index: 1000;
    }

    这里有个问题:为什么enter的时候新页面position要设成fixed呢?是因为qq浏览器下如果执行history.pop会导致新页面先撑开文档再执行listen函数从而导致获取不到旧页面的滚动位置。为了在transition group提供的钩子函数onEnter中获得旧页面的滚动位置只能先将enter设为fixed。

    然后是最主要的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 (lastPathname === history.location.pathname) { return; }

    if (!history.location.key) { // 目标页为初始页
    historyKeys[0] = '';
    }
    const delay = 50; // 适当的延时以保证动画生效
    if (!isAnimating) { // 如果正在进行路由动画则不改变之前记录的bodyOverflowX
    bodyOverflowX = document.body.style.overflowX;
    }
    const routerWrap = document.getElementById(wrapId);
    const originPage = routerWrap.children[routerWrap.children.length - 1] as HTMLElement;
    const oPosition = originPage.style.position;
    setTimeout(() => { // 动画结束后还原相关属性
    document.body.style.overflowX = bodyOverflowX;
    originPage.style.position = oPosition;
    isAnimating = false;
    }, routeAnimationDuration + delay + 50); // 多50毫秒确保动画执行完毕
    document.body.style.overflowX = 'hidden'; // 防止动画导致横向滚动条出现

    if (history.location.state && history.location.state.noAnimate) { // 如果指定不要发生路由动画则让新页面直接出现
    setTimeout(() => {
    const wrap = document.getElementById(wrapId);
    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;
    originPage.style.top = -oldScrollTop + 'px'; // 防止页面滚回顶部
    originPage.style.position = 'fixed';
     
    setTimeout(() => { // 新页面已插入到旧页面之前
    isAnimating = true;
    const wrap = document.getElementById(wrapId);
    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(0, 0); // 如果是点击前进按钮或者是history.push则滚动位置归零
    if (action === 'PUSH') {
    historyKeys = historyKeys.slice(0, currentHistoryPosition + 1);
    historyKeys.push(currentRouterKey); // 如果是history.push则清除无用的key
    }
    } else {
    if (isRememberPosition) {
    setTimeout(() => {
    window.scrollTo(0, positionRecord[currentPath]); // 滚动到之前记录的位置
    console.log('scrollto' + positionRecord[currentPath]);
    }, 50);
    }

    // 删除滚动记录列表中所有子路由滚动记录
    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';

    newPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    newPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    oldPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    oldPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;

    setTimeout(() => {

    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 ${(routeAnimationDuration - delay) / 1000}s`;
    oldPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    newPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    newPage.style.webkitTransition = `left ${(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作为滚动位置的键
    }, 50);

    dPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    oldPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;

    setTimeout(() => {

    newPage.style.opacity = '1'; // 防止页面闪烁
    newPage.style.left = '0';
    oldPage.style.left = '-100%';

    console.log(newPage.style.left);
    console.log(oldPage.style.left);
    }, delay);
    } else {
    newPage.style.left = '-100%';
    oldPage.style.left = '0';
    setTimeout(() => {
    oldPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    oldPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    newPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
    newPage.style.webkitTransition = `left ${(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 :

    let currentScrollPosition = 0
    const syncScrollPosition = () => {  // 由于x5内核会先撑开文档再执行listen函数,所以要在onEnter的时候就去获得滚动条位置。
      currentScrollPosition = window.scrollY
    }
    
    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} 
                     onEnter={syncScrollPosition}>
                    <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-demo

    
    
  • 相关阅读:
    每日日报2020.12.1
    每日日报2020.11.30
    981. Time Based Key-Value Store
    1146. Snapshot Array
    565. Array Nesting
    79. Word Search
    43. Multiply Strings
    Largest value of the expression
    1014. Best Sightseeing Pair
    562. Longest Line of Consecutive One in Matrix
  • 原文地址:https://www.cnblogs.com/axel10/p/10090606.html
Copyright © 2011-2022 走看看