Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Animated page transitions in react

The past couple of weeks I've been working on an app using React. So far everything is working fine, but now I want to add some transitions to it. These transitions are a bit more complex than any examples I managed to find.

I've got 2 pages, an overview and a detail page which I'd like to transition between.

I'm using react-router to manage my routes:

<Route path='/' component={CoreLayout}>

  <Route path=':pageSlug' component={Overview} />
  <Route path=':pageSlug/:detailSlug' component={DetailView} />

</Route>

Overview looks like this: enter image description here

Detailview looks like this: enter image description here

The idea of the transition is that you click on one of the elements of the Overview. This element which has been clicked moves towards the position it should have on the detailView. The transition should be initiated by a route change (I think) and should also be able to happen in reverse.

I've already tried using ReactTransitionGroup on the Layout, which has a render method which looks like this:

render () {
    return (
        <div className='layout'>
            <ReactTransitionGroup>
                React.cloneElement(this.props.children, { key: this.props.location.pathname })
            </ReactTransitionGroup>
        </div>
    )
}

This will give the child component the ability to receive the special lifecycle hooks. But I'd like to access the child components somehow during these hooks and still keep doing things the React way.

Could someone point me in the right direction for the next step to take? Or maybe point me to an example which I may have missed somewhere? In previous projects I used Ember together with liquid fire to get these kinds of transitions, is there maybe something like this for React?

I'm using react/react-redux/react-router/react-router-redux.

like image 869
jchn Avatar asked Jan 26 '16 18:01

jchn


1 Answers

Edit: Added a working example

https://lab.award.is/react-shared-element-transition-example/

(Some issues in Safari for macOS for me)


The idea is to have the elements to be animated wrapped in a container that stores its positions when mounted. I created a simple React Component called SharedElement that does exactly this.

So step by step for your example (Overview view and Detailview):

  1. The Overview view gets mounted. Each item (the squares) inside the Overview is wrapped in the SharedElement with a unique ID (for example item-0, item-1 etc). The SharedElement component stores the position for each item in a static Store variable (by the ID you gave them).
  2. You navigate to the Detailview. The Detailview is wrapped into another SharedElement that has the same ID as the item you clicked on, so for example item-4.
  3. Now this time, the SharedElement sees that an item with the same ID is already registered in its store. It will clone the new element, apply the old elements position to it (the one from the Detailview) and animates to the new position (I did it using GSAP). When the animation has completed, it overwrites the new position for the item in the store.

Using this technique, it's actually independent from React Router (no special lifecycle methods but componentDidMount) and it will even work when landing on the Overview page first and navigating to the Overview page.

I will share my implementation with you, but be aware that it has some known bugs. E.g. you have to deal with z-indeces and overflows yourself; and it doesn't handle unregistering element positions from the store yet. I'm pretty sure if someone can spend some time on this, you can make a great little plugin out of it.

The implementation:

index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

import Overview from './Overview'
import DetailView from './DetailView'

import "./index.css";

import { Router, Route, IndexRoute, hashHistory } from 'react-router'

const routes = (
    <Router history={hashHistory}>
        <Route path="/" component={App}>
            <IndexRoute component={Overview} />
            <Route path="detail/:id" component={DetailView} />
        </Route>
    </Router>
)

ReactDOM.render(
    routes,
    document.getElementById('root')
);

App.js

import React, {Component} from "react"
import "./App.css"

export default class App extends Component {
    render() {
        return (
            <div className="App">
                {this.props.children}
            </div>
        )
    }
}

Overview.js - Note the ID on the SharedElement

import React, { Component } from 'react'
import './Overview.css'
import items from './items' // Simple array containing objects like {title: '...'}
import { hashHistory } from 'react-router'
import SharedElement from './SharedElement'

export default class Overview extends Component {

    showDetail = (e, id) => {
        e.preventDefault()

        hashHistory.push(`/detail/${id}`)
    }

    render() {
        return (
            <div className="Overview">
                {items.map((item, index) => {
                    return (
                        <div className="ItemOuter" key={`outer-${index}`}>
                            <SharedElement id={`item-${index}`}>
                                <a
                                    className="Item"
                                    key={`overview-item`}
                                    onClick={e => this.showDetail(e, index + 1)}
                                >
                                    <div className="Item-image">
                                        <img src={require(`./img/${index + 1}.jpg`)} alt=""/>
                                    </div>

                                    {item.title}
                                </a>
                            </SharedElement>
                        </div>
                    )
                })}
            </div>
        )
    }

}

DetailView.js - Note the ID on the SharedElement

import React, { Component } from 'react'
import './DetailItem.css'
import items from './items'
import { hashHistory } from 'react-router'
import SharedElement from './SharedElement'

export default class DetailView extends Component {

    getItem = () => {
        return items[this.props.params.id - 1]
    }

    showHome = e => {
        e.preventDefault()

        hashHistory.push(`/`)
    }

    render() {
        const item = this.getItem()

        return (
            <div className="DetailItemOuter">
                <SharedElement id={`item-${this.props.params.id - 1}`}>
                    <div className="DetailItem" onClick={this.showHome}>
                        <div className="DetailItem-image">
                            <img src={require(`./img/${this.props.params.id}.jpg`)} alt=""/>
                        </div>
                        Full title: {item.title}
                    </div>
                </SharedElement>
            </div>
        )
    }

}

SharedElement.js

import React, { Component, PropTypes, cloneElement } from 'react'
import { findDOMNode } from 'react-dom'
import TweenMax, { Power3 } from 'gsap'

export default class SharedElement extends Component {

    static Store = {}
    element = null

    static props = {
        id: PropTypes.string.isRequired,
        children: PropTypes.element.isRequired,
        duration: PropTypes.number,
        delay: PropTypes.number,
        keepPosition: PropTypes.bool,
    }

    static defaultProps = {
        duration: 0.4,
        delay: 0,
        keepPosition: false,
    }

    storeNewPosition(rect) {
        SharedElement.Store[this.props.id] = rect
    }

    componentDidMount() {
        // Figure out the position of the new element
        const node = findDOMNode(this.element)
        const rect = node.getBoundingClientRect()
        const newPosition = {
            width: rect.width,
            height: rect.height,
        }

        if ( ! this.props.keepPosition) {
            newPosition.top = rect.top
            newPosition.left = rect.left
        }

        if (SharedElement.Store.hasOwnProperty(this.props.id)) {
            // Element was already mounted, animate
            const oldPosition = SharedElement.Store[this.props.id]

            TweenMax.fromTo(node, this.props.duration, oldPosition, {
                ...newPosition,
                ease: Power3.easeInOut,
                delay: this.props.delay,
                onComplete: () => this.storeNewPosition(newPosition)
            })
        }
        else {
            setTimeout(() => { // Fix for 'rect' having wrong dimensions
                this.storeNewPosition(newPosition)
            }, 50)
        }
    }

    render() {
        return cloneElement(this.props.children, {
            ...this.props.children.props,
            ref: element => this.element = element,
            style: {...this.props.children.props.style || {}, position: 'absolute'},
        })
    }

}
like image 82
tommy Avatar answered Sep 27 '22 20:09

tommy