LINE Engineering
Blog

LINEマンガ:Page Stackを使ってサクサクなページ遷移を実現できました

sunderls 2017.10.20

はじめに

こんにちは、LINEマンガでJavaScriptを使った開発を担当しているsunderlsです。
LINEの中でマンガを読めるようになったことに、皆さんお気づきでしょうか。
「···」をタップし、「LINEマンガ」アイコンをタップすると、マンガをサクサク読むことができます。

実はこの画面はWeb技術で実装しています。画面遷移のスムーズさは、体感的にはネイティブアプリに近いのではと思っていますが、いかがでしょうか。 どのような技術を使ったのか、簡単に解説したいと思います。

Webでの実装の課題

普段、ReactやVueを使っている方も多いと思います。
Routerにトランジションを加えれば、問題なく動作するのではないかと思われるかもしれません。
確かにその実装でも動作はしますが、画面遷移をスムーズにするには以下のようないくつかの課題があります。

  • 「戻る」ボタンをタップすると、遅延を感じる
  • RouterによってDOMが入れ替えられるのが原因です。LINEマンガの場合はトップページが長くて複雑なので、「戻る」ボタンの遅延はさらに感じやすいです。 LINE内で提供するサービスとして、できるだけ違和感を感じないようにしなければならないので、「戻る」ボタンの反応を素早くする必要があります。

  • 戻っても元のスクロール位置に戻らない
  • これはSPAにはよくある問題です。JavaScriptで頑張って位置を保存して正しい位置を復元できますが、簡単ではないですね。 縦スクロールのほかにも、カルーセルでスワイプするケースや無限スクロールをつけるケースなど、対応がどんどん大変になります。

  • Lazy Loadをかけた画像がチラつく
  • 画像にプレイスホルダーをつけるのはよくあるパターンですね。「戻る」ボタンをタップする場合も、Lazy Loadがもう一度トリガーされるので、チラつきが発生します。

    これらの課題を一言で言えば、「スクロールやタップなどのユーザーアクションで発生するDOMの変化を、遷移先から元のページに戻ったときにどのように巧みに復元するか」になります。

    ネイティブアプリの実装を参考に

    ネイティブアプリらしく見せるには、ネイティブの実装を見たほうがいいですね。
    iOSのUINavigationControllerについて見てみましょう。

    出典: https://developer.apple.com/documentation/uikit/uinavigationcontroller
    UINavigationControllerでは、Navigation StackでView controllerを管理し、ビューをプッシュ/ポップしています。

    func pushViewController(_ viewController: UIViewController, animated: Bool)
    func popViewController(animated: Bool) -> UIViewController?
    

    同じStackの形で実装すればページ遷移でのDOMの入れ替えがなくなり、前述した課題が解決されるのではないかと考えました。そこで思い切って、LINEマンガはNavigation Stackに似たStack構造で実装してみました。この構造を、ここからはPage Stackと呼びます。

    Page Stackの実装

    HTML構造

    LINEマンガでは以下の構造で実装しています。Page Stackには各ページを格納します。
    (ちなみに、モーダルもStackの形で実装しています)
    <div id="root">
        <PageStack>  
     
            <page>   
                <content />
                <mask />
            </page>
     
            <page>
                <content />
                <mask />
            </page>
             
            ...
      </PageStack>
      
      <ModalStack />
    </div>
    

    次にJavaScriptのコード(ベースはReactとreact-router)について説明します。解説用コードなので、一部のコード(CSS関連など)は省略しています。

    PageStack をつくる

    class Stack extends React.PureComponent {
        constructor(props) {
            super(props);
     
            this.state = {
                stack: []   // 各ページエレメントの格納場所です。
            };
     
            // 初期ページをプッシュします。
            this.state.stack.push(this.getPage(props.location));
        }
     
        componentStack = [];  // 各ページのコンポーネントを格納します。
     
        // react-routerに定義しているページを<page>にラップします。
        getPage(location) {
            return <Page
                onEnter={this.onEnter}
                onEntering={this.onEntering}
                onEntered={this.onEntered}
                onExit={this.onExit}
                onExiting={this.onExiting}
                onExited={this.onExited}
                >
                {
                    React.createElement(this.props.appRoute, { location })
                }
            </Page>
        }
     
        // 位置の変化に応じて、Stackを更新します。
        componentWillReceiveProps(nextProps) {
            // 「戻る」がタップされた場合は、最上位のページをポップします。
            if (nextProps.history.action === 'POP') {
                this.state.stack.pop();
            } else {
                if (nextProps.history.action === 'REPLACE') {
                    this.state.stack.pop();
                }
                // プッシュの場合は、新規ページをラップしてプッシュします。
                this.state.stack.push(this.getPage(nextProps.location));
            }
        }
     
        // スワイプをサポートする場合は、touchstartイベントをキャプチャフェーズで処理します。
        componentDidMount() {
            if (this.props.swipable) {
                this.slideContainer.addEventListener('touchstart', this.onTouchStart, true);
            }
        }
     
        // 左端からのスワイプの場合は、スワイプバックを適用します。
        onTouchStart = (e) => {
            if (this.touchStartX < 10 && this.state.stack.length > 1) {
                e.preventDefault();
                e.stopPropagation();
                this.slideContainer.addEventListener('touchmove', this.onTouchMove, true);
                this.slideContainer.addEventListener('touchend', this.onTouchEnd, true);
            }
        }
      
        // 指の動きに応じて、ページのtranslateXとmaskのopacityを更新します。
        onTouchMove = (e) => { ... }
        // 指が離れたとき、前のページに戻るかを判断します。
        onTouchEnd = (e) => { ... }
         
        // これらのフックではマスクのopacityなどを更新します。
        onEnter = () => {...}
        onEntering = () => {...}
        onExit = () => {...}
        onExiting = () => {...}
         
        // 新規ページがプッシュされたら、前のページのcomponentDidHideをトリガーします。
        onEntered = (component) => {
            this.componentStack.push(component);
            const prevTopComponent = this.componentStack[this.componentStack.length - 2];
            if (prevTopComponent && prevTopComponent.componentDidHide) {
                prevTopComponent.componentDidHide();
            }
        }
     
        // ページがポップされたら、前のページのcomponentDidTopをトリガーします。
        onExited = (component) => {
            this.componentStack.splice(this.componentStack.indexOf(component), 1);
            const topComponent = this.componentStack[this.componentStack.length - 1];
            if (topComponent && topComponent.componentDidTop) {
                topComponent.componentDidTop();
            }
        }
        render() {
            return <TransitionGroup>
                { this.state.stack }
            </TransitionGroup>;
        }
    }
     
    export default withRouter(Stack);
    

    ラッパーの page をつくる

    // まずトランジションを定義します。
    const Slide = ({ children, ...props }) => <CSSTransition classNames={'slide'}
        {...props}>
        { children }
    </CSSTransition>;
     
    export default class Page extends React.Component {
        constructor(props) {
            super(props);
        }
     
        // contextを使って、refPageを渡します。
        getChildContext() {
            return {
                refPage: (c) => {
                    this.page = c;
                }
            }
        }
     
        componentDidEnter = () => {
            if (this.props.onEntered) {
                this.props.onEntered(this);
            }
            if (this.page && this.page.componentDidEnter) {
                this.page.componentDidEnter();
            }
        }
         
        // ほかのフックにも同じ処理を入れます。
        componentDidExit = () => {...}
        componentDidTop = () => {...}
        componentDidHide = () => {...}
     
        render() {
            const props = this.props;
            return <Slide
                {...props}
                onEntered={this.componentDidEnter}
                onExited={this.componentDidExit}
                >
                    { props.children }
            </Slide>;
        }
    }
     
    Page.childContextTypes = {
        refPage: PropTypes.func
    }
    

    withStackをつくる

    class Wrapper extends React.Component {
        constructor(props) {
            super(props);
        }
      
        render() {
            return React.createElement(this.props.component, Object.assign({},
                this.props,
                {
                    ref: this.context.refPage
                }
            ));
        }
    }
     
    Wrapper.contextTypes = {
        refPage: PropTypes.func
    };
     
    // withStackではcontextにあるrefPageをリレーします。
    export default function withStack(Component) {
        return (props) => {
            return <Wrapper component={Component} {...props}/>;
        };
    }
    

    これで全体的な実装が終わりました。最後に使い方を見てみましょう。

    サンプルコード

    // A.js
    // サンプルページです。
    export default withStack(class A extends React.Component {
        constructor(props) {
            super(props);
        }
         
        // ページが表示されて、トランジションが終了しました。
        componentDidEnter() {...}
        // ページが非表示になって、トランジションが終了しました。
        componentDidExit() {...}
        // ページが再度最上位に移動しました。
        componentDidTop() {...}
        // ページが最上位から移動しました。
        componentDidHide() {...}
      
        render() {
            return <div> page A </div>;
        }
    });
     
    // appRoute.js
    export default function AppRoute(props) {
        return <Switch location={props.location} >
                <Route path="/a" component={A}/>
                <Route path="/b" component={B} />
                <Route path="/" component={Top} />
        </Switch>
    }
      
    // app.js
    class App extends React.Component {
        constructor(props) {
            super(props);
        }
     
        render() {
            return <Router>
                <PageStack
                    swipable={true}
                    appRoute={AppRoute}
                />
            </Router>
        }
    }
     
    ReactDom.render(<App/>, document.querySelector('#app'));
    

    実装結果

    Page Stackを実装した結果、スムーズに動作するようになりました。このとおり、スワイプバックも滑らかです。

    もっとゆっくり動作させてもスムーズです。

    終わりに

    快適な操作感を得るため、ほかにもいろいろ工夫していますが、Page Stackがその基礎になりました。
    いかがでしたでしょうか。よろしければ、LINEでマンガを読んでみてください。

    私たちLINE エンジニアは、常に最善を尽くしてサービスを作っています。フロントエンドエンジニアも絶賛募集中です。ご興味のある方は、是非応募してみてください。
    フロントエンドエンジニア【LINEプラットフォーム】
    ブリッジエンジニア(フロントエンド)【LINEプラットフォーム】

    LINEマンガ Page Stack

    sunderls 2017.10.20

    Add this entry to Hatena bookmark

    リストへ戻る