LINE Securities: Page Stack revisited

2 years ago I posted an article about why and how we achieved Smooth page transition with Page StackWe adopted this approach for LINE Securities, and pushed it a little further. In this article, I am going to show you what we have done to improve it, and also what problems we are currently facing.

Header parallax transition

 In case you haven’t read my previous post, allow me to do a brief recap on what Page Stack is. Page Stack is basically an approach that mimics native app behavior, using a stack to push or pop screens and pages without fully destroying the DOM (Document Object Model). By using Page Stack we were able to achieve instant go-back interaction with a nice swipe gesture without any flickering problems. Below is a demo.

Yet compared to native iOS apps, Page Stack doesn’t feel native enough. Usually native app headers are separated from the body for better transition. If we split pages into two parts—head and body—we could actually achieve something similar by applying different transitions. This is what we are doing at LINE Securities. Pretty neat, right?

Improved swiping gesture

In the previous implementation, we did support a swiping gesture but in a relatively rudimentary way. We only checked the distance traveled until triggering navigation. But in most native apps, the speed of the swiping gesture is also an important factor, so we also tried to improve that. The implementation is as follows.

private onTouchEnd = () => {
   const distance = touch.clientX - touchStartClientX
   const velocity = distance / elapsedTime
   if (distance > distanceThreshhold || velocity > velocityThreshhold) {
      // OK, trigger history.back()
   }
}

componentCanSwipe

We improved the fantastic swipe gesture, but we had a problem—not all pages need it. For some page flows which need user input, we need to show a discard confirmation dialog before users go back; which is pretty common in web apps. To enable customization on different pages, we added the componentCanSwipe hook in Page Stack. The idea is simple, the swipe gesture is controlled totally by Page Stack, so when we detect the swipe, we could first check if the top most page has enabled componentCanSwipe. This can be extended to boolean or function.

// in PageStack
canSwipe = () => {
   // if only there is only one page in the stack, no swipe gesture
   if (componentStackLength < 2) {
       return false 
   }
   
   // if currently transitioning, we need to ignore the swipe gesture
   if (transitionLayerCount > 0) {
      return false
   }

   return topComponent.componentCanSwipe
}


// in Page Component we could customize the behavior
class Page extends React.Component {
   componentCanSwipe = true
   render() {}
}

componentWillExit

You might have noticed that in the above case, componentCanSwipe actually just prevents the swipe gesture, but doesn’t enable the discard confirmation. To achieve this in non-SPA (Single Page Application), onbeforeunload is the common solution. In SPA, react-router has <Prompt/>, but it only support native blocking methods like window.confirm, which doesn’t conform with our UI consistency.

Good news is that because Page Stack is the only place where changes in history are handled, we can detect history changes and trigger a new life-cycle hook—let’s call it componentWillExit. The code looks something like this:

// in PageStack
async componentDidUpdate(prevProps: Props) {
  // avoid infinite rendering
  if (this.props === prevProps) return


  // if it is POP
  if (this.props.history.action === 'POP') {
    const lastPoppedLocation = {
      ...prevProps.location
    }

    // we check if the page permits go back 
    if (await topComponent.componetnWillExit()) {
      // if allowed, just pop the top page component
    } else {
      // if not allowed, need to restore the previous history
      // so history.push is called
      // but by default, history.push() triggers new page being added
      // use `isPreventingGoBack` to prevent that.
      this.isPreventingGoBack = true
      prevProps.history.push(lastPoppedLocation)
      return
    }

    this.popStack()
  } else {
    // if isPreventingGoBack is set
    // means this history entry is just dummy 
    if (this.isPreventingGoBack) {
      this.isPreventingGoBack = false
      return
    }
    this.pushStack(...)
  }
}

Notice that componentWillExit can be asynchronous and we can add any kind of UI we want.

Will Page Stack bloat after too many pages are stacked?

The answer is yes; some web apps are more prone to this problem because of their service logic. Native apps also face this problem, but the OS handles it itself. With Page Stack, we could do something similar by adding a sliding window on the stack to avoid too many DOM nodes being rendered. We have already implemented this in the first version, with a maxDepthcomponentShouldSleep, and componentShouldAwake. The logic is as follows.

// in PageStack
async componentDidUpdate(prevProps: Props) {
   if (this.props.history.action === 'POP') {
      if (this.componentStack.length > this.maxDepth) {
          this.componentStack[this.componentStack.length - this.maxDepth].componentShouldAwake()
      }
   } else {
       if (this.componentStack.length > this.maxDepth) {
          this.componentStack[this.componentStack.length - this.maxDepth].componentShouldSleep()
       }
   }
}

How can you cope with native swipe gestures on browsers?

Every time we introduce Page Stack we are asked about how it works in a real browser environment because native browsers have two important features—native swipe gestures and navigation buttons. This indeed is a problem, and we did some testing.

Navigation buttons

Page Stack handles back buttons well, and componentWillExit works. I’d suggest disabling forward buttons by hijacking the history change. The forward button causes browser on some specific browsers, and if you treat everything as a pure web page, it is suitable. However, it is not suitable for SPA—think of a form that needs validation to go ahead, enabling forwarding would break it.

Native swipe gesture

This is a more fundamental problem. Transitions provided by Page Stack will conflict with native swipe gestures. Look at the behavior depicted in the image below. The Page Stack transition is triggered after a native swipe gesture, causing a redundant transition.

To solve this, we tested a few patterns and reached the conclusion that it would be best to disable our own swipe gesture and transition on browser environments. We did this by making a few simple tweaks to Page Stack’s CSS code. The result would be something like this.

 It might look shabby, but with a native swipe gesture, it actually looks great.

Is it still worth trying Page Stack in a browser environment?

I know you would ask this question after seeing the demo above, because it looks like page stack does nothing visible. Well, there are surely some trade-offs, but I’d say Page Stack is still a worthy approach even on browsers, for the following reasons:

  • Instant go-back: It beats all other solutions that destroy and regenerate DOM.
  • No flickering: Because Page Stack keeps the DOM, pure UI states such as horizontal scroll positions are kept intact.
  • Similarity to native apps: As we are trying to create native-like web apps, adopting the same paradigm might help.

Does it work on functional components?

Page Stack provides withStack to add extra life-cycle methods to components. Functional components do not work directly right now, because Page Stack can’t access it through ref. 2 years ago this was not a big problem, but in the era of hooks, functional components are the trend. We are still discovering new possible implementations of Page Stack to support functional components, we just need more time.

For the time being, we use <PageStackGlue/> as a workaround. It’s a no-op component which just expose ref to Page Stack.

// PageStackGlue
const PageStackGlue = withStack(
  class extends React.PureComponent {
    componentDidTop() {
      if (this.props.componentDidTop) {
        this.props.componentDidTop()
      }
    }
    componentDidEnter() { ... }

    componentDidHide() {... }

    componentCanSwipe() { ... }

    render() {
      return null
    }
  }
)


// now we can use Page Stack lifecycle methods even under functional component
const A = () => {
   return <div>
    <PageStackGlue componentCanSwipe />
   </div>
}

Conclusion

Page Stack is an approach we are progressively using, but of course every blade has two edges. With all the cool things Page Stack provides for us, we also need to handle extra problems, but isn’t this what we love about front-end development? We gladly embrace the challenges.

LINE Securities has a bunch of ideas and achievements we’d like to share with you, so stay tuned. If you are interested, why not join us?

フロントエンドエンジニア / 証券 / LINE Financial (Page in Japanese only)