all articles

LINE Manga: smooth transition with Page Stack

2017-10-21 @sunderls

js react transition

this article is translated from the original Japanese one on LINE Engineer Blog

at the beginning

Hello everyone, I'm @sunderls who writes JavaScript in LINE Manga team.

Do you know that you can view free manga right from LINE app ?

Tap 「···」> 「LINEマンガ」and you can view free manga smoothly.

The screen here is in fact html. I think that the transition between pages feels smooth enough to be compared to native solution, do you think so?

I'll do some explanation on how we accomplished this.

problems on Web

I think that most of you use React or Vue to do front-end developing. Maybe some think that this could be done by just adding transition to router, and yes, it'll work, but not that smooth if you do it in an ordinary way, why?

lag when tapping Back Button

This is because Router replaces DOM by default, for long pages like LINE Manga Home page, returning to it will be very slow because of DOM manipulation.

As we are providing our service through LINE, we have to do it as smooth as we can, and this is no negotiable.

scroll position is not restored

This is a common problem in SPA, though we can restore the scroll position by some JavaScript workaround, it is NOT EASY and prone to page flickering.

And besides, we may have carousel we leaves horizontal scrolling or auto paging which needs more rounds of loading before we can restore. So this is not a very good approach I think.

Lazy-Load components reloads and flickers

It is a common technique to first show a placeholder element before loading images, and do the request only images are scrolled to the visible viewport. But in default router options, these lazy-loaded components will be re-rendered one more time, flickers, though only a blink of an eye.

In one word, problem is when returning to the last page, how can we restore the DOM changes caused by user interaction before coming in.

let's see how iOS handles this

well to be looked like native, we'd better look at how native implements, this is UINavigationController from IOS:

image is from : https://developer.apple.com/documentation/uikit/uinavigationcontroller

UINavigationController use a Navigation Stack to manage View controller and push/pop views:

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

If we use the same way to manage our pages, DOM is no more needed to be replaced every time, so problems mentioned above can be solved maybe. And that's why we implemented the Page Stack in LINE Manga.

Code

HTML

LINE Manga uses following DOM structure, <PageStack> contains all the pages. (BTW, Modals are also handled in a stack way)

<div id="root">
    <PageStack>  

        <page>   
            <content />
            <mask />
        </page>

        <page>
            <content />
            <mask />
        </page>

        ...
  </PageStack>

  <ModalStack />
</div>

From here we are gonna show some code on how we implement the stack (based on React and react-router, most code is omitted for simplicity)

PageStack

class Stack extends React.PureComponent {
    constructor(props) {
        super(props);

        this.state = {
            stack: []   // this stacks all page elements
        };

        // push the first page on load 
        this.state.stack.push(this.getPage(props.location));
    }

    componentStack = [];  // page components are put here

    // this wraps page elements from routes, adding transition effects
    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>
    }

    // update Stack according to location changes
    componentWillReceiveProps(nextProps) {
        // when return, pop the top page
        if (nextProps.history.action === 'POP') {
            this.state.stack.pop();
        } else {
            if (nextProps.history.action === 'REPLACE') {
                this.state.stack.pop();
            }
            // push when a new page come in 
            this.state.stack.push(this.getPage(nextProps.location));
        }
    }

    // to support swiping back, we listen to touchstart event in capture phase
    componentDidMount() {
        if (this.props.swipable) {
            this.slideContainer.addEventListener('touchstart', this.onTouchStart, true);
        }
    }

    // only swipe back when touchstart on the very left area
    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);
        }
    }

    // update page translateX and mask's opacity according to finger moves
    onTouchMove = (e) => { ... }
    // when finger leaves, decides whether to return or do nothing
    onTouchEnd = (e) => { ... }

    // update opacity of masks in these hooks
    onEnter = () => {...}
    onEntering = () => {...}
    onExit = () => {...}
    onExiting = () => {...}

    // when a new page is pushed, trigger componentDidHide on the previous top page
    onEntered = (component) => {
        this.componentStack.push(component);
        const prevTopComponent = this.componentStack[this.componentStack.length - 2];
        if (prevTopComponent && prevTopComponent.componentDidHide) {
            prevTopComponent.componentDidHide();
        }
    }

    // when pop a top page, trigger the componentDidTop under it
    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);

wrapper - Page

// transition
const Slide = ({ children, ...props }) => <CSSTransition classNames={'slide'}
    {...props}>
    { children }
</CSSTransition>;

export default class Page extends React.Component {
    constructor(props) {
        super(props);
    }

    // use context to pass down 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();
        }
    }

    // these hooks do almost the same
    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
}

helper - 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 just relays refPage from context中
export default function withStack(Component) {
    return (props) => {
        return <Wrapper component={Component} {...props}/>;
    };
}

OK now, the implementation of Page Stack is basically done, let's see the usage at last.

sample code of the usage

// sample page A.js
export default withStack(class A extends React.Component {
    constructor(props) {
        super(props);
    }

    // when page comes in and transition is done
    componentDidEnter() {...}
    // when page exits and transition is done
    componentDidExit() {...}
  // when page became top again
    componentDidTop() {...}
    // when page came down from top, covered by another page
    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'));

result

Now page transitions became very smooth.

let's see a slower version.

At last

To accomplish a smooth user experience, LINE Manga has done a lot other improvements, but Page Stack is the base.

How is it ? If you'd like to , please try LINE Manga right from LINE App.

We, engineers from LINE, are always trying our best to provide users the best services. And we are hiring front-end engineers, following are the positions ( in Japanese):

フロントエンドエンジニア【LINEプラットフォーム】

ブリッジエンジニア(フロントエンド)【LINEプラットフォーム】