React Router+React-Transition-Group to Realize Page Sliding and Rolling Location Memory

Keywords: React Fragment Attribute JSON

 

 

Updated 17 December 2018:

Fix page misalignment when pop jump is executed in qq browser

The code in this article has been encapsulated as npm package publishing: react-slide-animation-router

 

In React Router, if we want to do left-right sliding based on routing, we must first understand what happens when a route jump occurs, and the principle of routing animation.

First we need to understand a concept: history. History is an object built into browser and contains some information about history. But this article is about history built in React-Router. Every routing page can access this object in props. It includes jump action, listen function triggering jump, method listening for each jump, location object. And so on. The location object describes the pathname, querystring of the current page, and key attributes that represent the current jump results. The key attribute will only occur after a jump occurs.

After learning about history, let's review the process of react router jump.

When routing animation is not used, the process of page Jump is:

Users issue jump instructions - > Browser history receives instructions, changes - > Old pages destroyed, new pages applied to documents, jump completed

When the React-Transition-Group based routing animation is used, the jump process becomes:

Users issue jump instructions - > Browser history receives instructions, changes - > before the new page is inserted into the same level of the old page - > wait time reaches the timeout set in React-Transition-Group, the old page is destroyed and the jump is completed.

When the jump is triggered, the url of the page changes. If you have registered your own listener function on the history listen method before, the function will also be called. But hisory can only be obtained in the props of the component. In order to get history objects outside the component, we need to install a package: https://github.com/ReactTraining/history . Replacing the history object that react router comes with with with the history created for us by this package allows us to access the history object anywhere.

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

const history = createBrowserHistory()
<Router history={history}>
    ....
</Router>

So the replacement is complete. The way to register listener s is also simple: history. listen (your function).

There are two things we can control at this point: delay and enter, exit class names provided by React-Transition-Group at the time of the jump-forward birth, and listen functions previously registered.

The left and right sliding ideas provided in this paper are: Judging jump action, if push, then all the current page left slides away from the screen, new page from right to left enters the screen, if replace, all the current page right slides, and new page from left to right enters. If it's pop, you need to decide whether the user clicks the browser's forward button or the return button, or whether history.pop is called.

Because no matter the user clicks the browser's forward button or backward button, the action obtained in history.listen will be pop, and react router does not provide the corresponding api, so it can only be judged by the developer with the help of location key. If the user first clicks the browser return button and then clicks the forward button, we will get the same key as before.

With that in mind, we can start coding. First, we add react transition group to the routing component according to the routing animation case provided by react router.

 

<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>

The Transition Group component generates a div, so we set the id of the div to'routeWrap'for subsequent operations. The key changes provided to CSS Transition will directly determine whether or not routing animation is generated, so the pathname in location is used here. If the pathname changes, the routing animation is generated by default. (search/querystring does not belong to pathname, so modifications do not generate animations.)

In order to realize the left-right sliding animation and scrolling position memory of routing, the idea of this paper is: using history.listen, when animation occurs, the current page position is set to fixed, top is set to the scrolling position of the current page, left/right sliding is done through transition and left, the new page position is set to relative, and sliding into the page through transition and left. All animations record location.key into an array, and judge whether to slide left or right according to the new key and the key in the array combined with action. And record the scrolling position of the page according to location.pathname, and scroll to the original position when returning to the old page.

First of all, I would like to explain some incomprehensible points in my thinking.

Q: Why set position:fixed and top for the current page?

A: To get the current page out of the document stream immediately without affecting the scrollbar, top is set to prevent the page from rolling back to the top because position is fixed.

Q: Why should the position of the new page be set to relative?

A: It's to open the page and scroll. If the height of the new page is high enough for a scrollbar to appear but the position is fixed or absolute, the scrollbar will not appear, that is, it will not scroll. This prevents the page from scrolling to the location of the previous record.

Q: Why not use transform instead of left as an animation attribute?

A: Because transform ation causes elements in the page that are position ed to be fixed to be absolute, leading to typesetting confusion.

With that in mind, we can start writing styles and listen functions by hand. Because of the limited space, the code is pasted directly here, not explained line by line.

Start with the basic animation style:

.router-enter{
    position: fixed;
    opacity: 0;
    transition : left 1s;
}
.router-enter-active{
  position: relative;
  opacity: 0; /*js Execute to the timeout function and appear again to prevent page flickering*/
}
.router-exit-active{
  position: relative;
  z-index: 1000;
}

Here's the question: Why should the new page position be fixed when enter? Because if history.pop is executed in qq browser, the new page will open the document first and then execute the listen function, which will result in the scrolling position of the old page can not be obtained. In order to get the scrolling position of the old page in the hook function onEnter provided by transition group, you can only set enter to fixed first.

Then the main listen function:

 

const config = {
  routeAnimationDuration: 350,
};


let historyKeys: string[] = JSON.parse(sessionStorage.getItem('historyKeys')); // Record a list of history.location.key. Store in session store to prevent refresh loss

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); // Record the location.key of the current page in historyKeys
currentHistoryPosition = currentHistoryPosition === -1 ? 0 : currentHistoryPosition;
history.listen((() => {

  if (lastPathname === history.location.pathname) { return; }

  if (!history.location.key) {  // The target page is the initial page
    historyKeys[0] = '';
  }
  const delay = 50; // Appropriate delay to ensure the animation takes effect
  if (!isAnimating) { // If routing animation is in progress, the previously recorded body Overflow X will not be changed
    bodyOverflowX = document.body.style.overflowX;
  }
  const originPage = document.getElementById('routeWrap').children[0] as HTMLElement;
  const oPosition = originPage.style.position;
  setTimeout(() => { // Return related attributes after animation
    document.body.style.overflowX = bodyOverflowX;
    originPage.style.position = oPosition;
    isAnimating = false;
  }, config.routeAnimationDuration + delay + 50); // More than 50 milliseconds to ensure that the animation is finished
  document.body.style.overflowX = 'hidden'; // Prevent animation from causing horizontal scrollbars to appear

  if (history.location.state && history.location.state.noAnimate) { // If you specify that no routing animation should occur, let the new page appear directly
    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;
  originPage.style.position = 'fixed';
  originPage.style.top = -oldScrollTop + 'px'; // Prevent pages from rolling back to the top
  setTimeout(() => { // The new page has been inserted before the old page
    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; // Determine if the user clicks the forward button?

    if (action === 'PUSH' || isForward) {
      positionRecord[lastPathname] = oldScrollTop; // Record the scrolling position of the old page based on the pathname previously recorded
      window.scrollTo(0,0);  // If you click the forward button or history.push, scroll to zero

      if (action === 'PUSH') {
        historyKeys = historyKeys.slice(0, currentHistoryPosition + 1);
        historyKeys.push(currentRouterKey); // If history.push clears the useless key
      }
    } else {
      // If you click the back button or call history.pop, history.replace, scroll the page to the location of the previous record.
      window.scrollTo(0,positionRecord[currentPath]);

      // Delete all subrouting scroll records in the scroll record list
      for (const key in positionRecord) {
        if (key === currentPath) {
          continue;
        }
        if (key.startsWith(currentPath)) {
          delete positionRecord[key];
        }
      }
    }

    if (action === 'REPLACE') { // If replace, replace the current routing key with the new routing key
      historyKeys[currentHistoryPosition] = currentRouterKey;
    }
    window.sessionStorage.setItem('historyKeys', JSON.stringify(historyKeys)); // The modification of the path key list history keys is completed and stored in the session store to prevent the refresh from losing.

    // Start sliding animation
    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'; // Prevent page flicker
        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); // Record the current history.location.key location in history keys
    lastPathname = history.location.pathname;// Keys that record the current pathname as the scroll position
  });

}));

After completion, we configure the delay in the routing as config. routeAnimation Duration as currently defined:

let currentScrollPosition = 0
const syncScrollPosition = () => {  // Because the x5 kernel will open the document first and then execute the listen function, you need to get the scrollbar position when 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>
  );
};

So the routing animation is done. There's nothing particularly difficult about history as a whole, just a slightly stricter requirement for knowledge about history and css.

Posted by Mew151 on Wed, 29 May 2019 04:42:50 -0700