はじめに
こんにちは、LINEマンガでJavaScriptを使った開発を担当しているsunderlsです。
LINEの中でマンガを読めるようになったことに、皆さんお気づきでしょうか。
「···」をタップし、「LINEマンガ」アイコンをタップすると、マンガをサクサク読むことができます。
実はこの画面はWeb技術で実装しています。画面遷移のスムーズさは、体感的にはネイティブアプリに近いのではと思っていますが、いかがでしょうか。
どのような技術を使ったのか、簡単に解説したいと思います。
Webでの実装の課題
普段、ReactやVueを使っている方も多いと思います。
Routerにトランジションを加えれば、問題なく動作するのではないかと思われるかもしれません。
確かにその実装でも動作はしますが、画面遷移をスムーズにするには以下のようないくつかの課題があります。
- 「戻る」ボタンをタップすると、遅延を感じる
RouterによってDOMが入れ替えられるのが原因です。
LINEマンガの場合はトップページが長くて複雑なので、「戻る」ボタンの遅延はさらに感じやすいです。
LINE内で提供するサービスとして、できるだけ違和感を感じないようにしなければならないので、「戻る」ボタンの反応を素早くする必要があります。
- 戻っても元のスクロール位置に戻らない
これはSPAにはよくある問題です。JavaScriptで頑張って位置を保存して正しい位置を復元できますが、簡単ではないですね。
縦スクロールのほかにも、カルーセルでスワイプするケースや無限スクロールをつけるケースなど、対応がどんどん大変になります。
- Lazy Loadをかけた画像がチラつく
画像にプレイスホルダーをつけるのはよくあるパターンですね。「戻る」ボタンをタップする場合も、Lazy Loadがもう一度トリガーされるので、チラつきが発生します。
これらの課題を一言で言えば、「スクロールやタップなどのユーザーアクションで発生するDOMの変化を、遷移先から元のページに戻ったときにどのように巧みに復元するか」になります。
ネイティブアプリの実装を参考に
ネイティブアプリらしく見せるには、ネイティブの実装を見たほうがいいですね。
iOSの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 エンジニアは、常に最善を尽くしてサービスを作っています。フロントエンドエンジニアも絶賛募集中です。ご興味のある方は、是非応募してみてください。