React 中的动画页面转换

Animated page transitions in react

过去几周我一直在使用 React 开发应用程序。到目前为止一切正常,但现在我想为其添加一些过渡。这些转换比我设法找到的任何示例都要复杂一些。

我有 2 个页面,一个概述页面和一个详细信息页面,我想在它们之间切换。

我正在使用 react-router 来管理我的路线:

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

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

</Route>

概览如下所示:

详细视图如下所示:

过渡的想法是您单击概述的元素之一。这个被点击的元素移动到它应该在 detailView 上的位置。转换应该由路由更改(我认为)启动,并且也应该能够反向发生。

我已经尝试在布局上使用 ReactTransitionGroup,它有一个渲染方法,如下所示:

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

这将使子组件能够接收特殊 lifecycle hooks。但我想在这些挂钩期间以某种方式访问​​子组件,并且仍然继续以 React 方式做事。

有人可以为我指明下一步的正确方向吗?或者也许给我指出一个我可能在某处错过的例子?在以前的项目中,我使用 Ember together with liquid fire 来获得这些类型的转换,React 是否有类似的东西?

我正在使用 react/react-redux/react-router/react-router-redux

编辑:添加了一个工作示例

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

(我在 macOS 版 Safari 中的一些问题)


想法是将要动画的元素包裹在一个容器中,该容器在安装时存储其位置。我创建了一个名为 SharedElement 的简单 React 组件,它就是这样做的。

所以一步一步为你的例子(Overview视图和Detailview):

  1. Overview 视图已装载。概览中的每个 item(方块)都包含在具有唯一 ID 的 SharedElement 中(例如 item-0item-1 等)。 SharedElement 组件将每个项目的位置存储在静态 Store 变量中(通过您给它们的 ID)。
  2. 您导航到 Detailview。 Detailview 被包装到另一个 SharedElement 中,它与您单击的项目具有相同的 ID,例如 item-4.
  3. 这一次,SharedElement 发现具有相同 ID 的项目已在其商店中注册。它将克隆新元素,将旧元素位置应用于它(来自 Detailview 的元素)并动画到新位置(我使用 GSAP 完成)。动画完成后,它会覆盖商店中商品的新位置。

使用这种技术,它实际上独立于 React Router(没有特殊的生命周期方法,但 componentDidMount),甚至在首先登陆概览页面并导航到概览页面时它也可以工作。

我将与您分享我的实现,但请注意它有一些已知的错误。例如。你必须自己处理 z-index 和溢出;并且它还没有处理 unregistering 商店中的元素位置。我很确定如果有人可以花一些时间在这上面,你可以用它制作一个很棒的小插件。

实施:

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 - 注意 SharedElement

上的 ID
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 - 注意 SharedElement

上的 ID
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'},
        })
    }

}

我实际上有一个类似的问题,我有一个搜索栏并希望它移动并换行到不同的大小并放置在特定的路线上(比如导航栏中的一般搜索和专用搜索页面)。为此,我创建了一个与上面的 SharedElement 非常相似的组件。

该组件需要一个 singularKey 和一个 singularPriority 作为 props 并且比您在多个位置渲染该组件,但该组件只会渲染最高优先级并对其进行动画处理。

组件在 npm 作为 react-singular-compoment 这是 GitHub page for the docs.