【LINE証券 FrontEnd】Page Stack の進化と課題

この記事は、LINE証券の開発メンバーが、LINE証券のフロントエンドにまつわる様々なトピックや、開発中に得た知見を共有する【LINE証券 FrontEnd】 シリーズの2記事目です。

はじめに

こんにちは、sunderlsです。2年前Page Stackについての記事「LINEマンガ:Page Stackを使ってサクサクなページ遷移を実現できました」を出しました。LINE証券でもこのアプローチをとっています。

この記事ではPage Stackはどう進化したのか、どんな課題を抱えているのかを説明します。

Header Parallax Transition

Page StackはNative Appのように、ページ遷移してもDOMを消さずに順序通りにStackされる仕組みです。DOM(Document Object Model)は消さないため、前のページに戻る時は瞬時に行われ、チラつく問題も解消されます。以下はDemoです。

よく見たら、Native Appと少し違います。Native Appの場合は、ヘッダーの部分は違うトランジションをつけているケースが多いです。Page Stackでも、ヘッダーとボディーを別々のコンポーネントにすれば、同じエフェクトが得られます。LINE証券ではこう実装して、こんな綺麗なトランジションを作りました。

swipe gestureの改善

Page Stackは最初からSwipe Gestureをサポートしていましたが、実装が甘かったです。指の移動距離だけをみて、ナビゲーションをトリガーしていました。Native Appの場合は、移動速度も見ているケースが多いので、私たちで改善してみました。実装はシンプルです。

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

componentCanSwipe

Swipe Gestureはいい感じに対応したが、一つの問題に直面しました。「全てのページにSwipe Gestureを適用することはよくない」。複数のページを跨いでるユーザーの入力機能を想像してください。前のページに戻る時に、入力がある場合は確認ダイアログを出すのはよくありますね。こんな時にSwipe Gestureを提供すると、ユーザーの操作がややこしくなります。

各ページでSwipe Gestureの適用を指定できるよう、Page Stackに `componentCanSwipe`のサポートを追加しました。仕組みはシンプルです。Swipe Gestureは完全にPage Stackに制御されているため、発動する前に、Page Stackにある一番上にあるページコンポーネントの`componentCanSwipe`を見れば、トランジションを中断することができます。

 // PageStack
canSwipe = () => {
   // 最後のページだったら、Swipe Gestureは発動しない
   if (componentStackLength < 2) {
       return false
   }
    
   // トランジションの途中でもSwipe Gestureを無効に
   if (transitionLayerCount > 0) {
      return false
   }
  
 // 一番上のページのプロパーティーをみる
   return topComponent.componentCanSwipe
}
 
 
// Page Component では簡単にswipe gesture適用かどうか指定できる
class Page extends React.Component {
   componentCanSwipe = true
   render() {}
} 

componentWillExit

すでに気づいたかもしれませんが、上記の対策はSwipe Gestureを禁止することを可能にしたが、離脱確認ダイアログを出していません。SPAではない場合は、`onbeforeunload`を使って簡単に対応できます。SPAの場合は、react-routerは `<Prompt/>`を提供していますが、native UIの`window.confirm`などしかサポートしていません。ここでUIの綺麗さに妥協したくないから、私たちは頑張りました。

Page Stackは唯一history changeをみているコンポーネントなので、history changeをみて独自の確認UIを出すことが可能です。そこに追加したのは、`componentWillExit`です。中身はざっくりこんな感じです。 

 // PageStack
async componentDidUpdate(prevProps: Props) {
  if (this.props === prevProps) return
 
 
  // POP の場合
  if (this.props.history.action === 'POP') {
    const lastPoppedLocation = {
      ...prevProps.location
    }
  
    // 一番上のページはexitを許容しているかどうか
    if (await topComponent.componetnWillExit()) {
      // 許容している場合は、そのまま何もしなくていい
    } else {
      // もし許容していない場合は、前popされたhistoryを再構築する必要がある
      // history.pushは呼ばれる
      // history.pushの一連を動作を中断するために、このフラッグを設置する
      this.isPreventingGoBack = true
      prevProps.history.push(lastPoppedLocation)
      return
    }
 
    this.popStack()
  } else {
    // もしこのフラッグが設置される場合は
    // page stackが入れたhistoryなので、無視する
    if (this.isPreventingGoBack) {
      this.isPreventingGoBack = false
      return
    }
    this.pushStack(...)
  }
} 

`componentWillExit`はasyncなので、どんなUIでも出せます。

Page Stackは膨大化になるのでは?

はい、DOMは消さないからです。Web Appのサービスロジックにより、問題になるかは別々です。Native Appも同じ問題を持っているが、OSレベルでちゃんと対応されています。Page Stackではsliding windowを実装すれば、ある程度似ている仕組みを作ることができます。その実装は最初のバージョンからすでに含まれています。

Page Stackには `maxDepth`と`componentShouldSleep`、 `componentShouldAwake`のフックをサポートしています。例えば、maxDepthを超える時に、一番下にあるページコンポーネントの`componentShouldSleep` が呼ばれ、DOM削除の処理を入れることができます。

 // 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()
       }
   }
} 

BrowserのNative Swipe Gestureとコンフリクトしない?

いい質問です。今までのPage Stackは主にブラウザーではなくてwebviewをターゲットにしていました。Browser環境の場合は、二つの壁があります、native swipe gesture と navigation button。

対応方法は模索してきました。

Navigation Buttons

Page Stackはhistory changeをみているので、バックワードボタンは問題ありません。フォーワードボタンについては、結構ややこしくて、個人的にはダミーのhistoryを入れて禁止したほうがいいと思います。

Native swipe gesture.

フォーワードボタンより、Native Swipe Gestureを利用しているユーザが多いでしょう。何もしなければ、Page Stackのトランジションと重複します。

この問題を解決するため、色々試しました。結論から言うと、ブラウザー環境ではPage StackのSwipe Gestureとトランジションを無効にするのは一番適切な対応だと思います。Page StackのCSSコードをいじれば、簡単にできます。

トランジションがないのはちょっと寂しいが、Native Swipe Gestureがあるので、逆にいい感じになります。

ブラウザー環境ではPage Stackを使う意味あるのか?

いい質問です。上記の対応したら、Page Stackを使っても、使わない場合と比べると見える変化がないようです。

確かにそうですが、Page Stackを使うメリットは他にあります。

  1. 「戻る」は瞬時に行われる. Page Stackは基本的にDOM消さないので、他のアプローチと比べて「戻る」のは一番早いです。
  2. チラつきはない. 横スクロール位置とかのUI状態は自動的に維持されます。
  3. Native Appの考えと似ている. Web Appを開発している私たちはできる限りにNativeに近づけようとしているので、考え方も近づけたほうが目的達成しやすいかと思います。

Functional Componentでは動けるか?

現在のPage Stackは`withStack`を経由して、ページコンポーネントにライフサイクルメソッドを有効化にしていますが、Functional Componentではref使えないので、Page Stackは動きません。2年前では大して問題ではないが、今はhooksの時代でFunctional Componentはほとんどになるため、動かないのは問題になっています。

Function ComponentでPage Stackはどう動くべきなのか、まだ模索中です。もし興味のある方、私たちと一緒にその方法を見つけてみませんか??
採用:フロントエンドエンジニア / LINE Financial / 証券

その方法が見つかるまで、ワークアラウンドとして`<PageStackGlue/>`を使って、Functional ComponentでもPage Stackを動かせます。

 // PageStackGlueはproxyみたいにlifecycle hookを提供する
const PageStackGlue = withStack(
  class extends React.PureComponent {
    componentDidTop() {
      if (this.props.componentDidTop) {
        this.props.componentDidTop()
      }
    }
    componentDidEnter() { ... }
 
    componentDidHide() {... }
 
    componentCanSwipe() { ... }
 
    render() {
      return null
    }
  }
)
 
 
// これでfunctional componentでも使える
const A = () => {
   return <div>
    <PageStackGlue componentCanSwipe />
   </div>
} 

まとめ

私たちはPage Stackを積極的に利用しています。便利さを得るとともに煩わしいこともついてきます。しかし、常に自分の限界を突破することが、フロントエンドの開発の醍醐味でしょう。
この記事は、【LINE証券 FrontEnd】シリーズの第2回です。次回以降もLINE証券メンバーが様々なトピックをお届けします。お楽しみに。

【採用情報】

Related Post