LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


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

はじめに

こんにちは、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 エンジニアは、常に最善を尽くしてサービスを作っています。フロントエンドエンジニアも絶賛募集中です。ご興味のある方は、是非応募してみてください。