原文地址: https://github.com/SmallStoneSK/Blog/issues/4javascript
最近盯上了app store中的动画效果,感受挺好玩的,嘿嘿~ 恰逢周末,得空就实现一个试试。不试不知道,作完了才发现其实还挺简单的,因此和你们分享一下封装这个组件的过程和思路。java
首先,咱们先来看看app store中的效果是怎么样的,看下图:react
哇,这个动画是否是颇有趣,很神奇。为此,能够给它取个洋气的名字:神奇移动,英文名叫magicMoving~git
皮完以后再回到现实中来,这个动画该如何实现呢?github
咱们来看这个动画,首先一开始是一个长列表,点击其中一个卡片以后弹出一个浮层,并且这中间有一个从卡片放大到浮层的过渡效果。乍一看好像挺难的,但若是把整个过程分解一下彷佛就迎刃而解了。spring
固然了,以上的这个思路实现的只是一个毛胚版的神奇移动。。。还有不少细节能够还原地更好,好比背景虚化,点击卡片缩小等等,不过这些不是本文探讨的重点。react-native
在具体实现以前,咱们得考虑一个问题:因为组件的通用性,浮层可能在各类场景下被唤出,可是又须要可以铺满全屏,因此咱们可使用Modal组件。app
而后,根据大概的思路咱们能够先搭好整个组件的框架代码:框架
export class MagicMoving extends Component { constructor(props) { super(props); this.state = { selectedIndex: 0, showPopupLayer: false }; } _onRequestClose = () => { // TODO: ... } _renderList() { // TODO: ... } _renderPopupLayer() { const {showPopupLayer} = this.state; return ( <Modal transparent={true} visible={showPopupLayer} onRequestClose={this._onRequestClose} > {...} </Modal> ); } render() { const {style} = this.props; return ( <View style={style}> {this._renderList()} {this._renderPopupLayer()} </View> ); } }
列表很简单,只要调用方指定了data,用一个FlatList就能搞定。可是card中的具体样式,咱们应该交由调用方来肯定,因此咱们能够暴露renderCardContent方法出来。除此以外,咱们还须要保存下每一个card的ref,这个在后面获取卡片位置有着相当重要的做用,看代码:异步
export class MagicMoving extends Component { constructor(props) { // ... this._cardRefs = []; } _onPressCard = index => { // TODO: ... }; _renderCard = ({item, index}) => { const {cardStyle, renderCardContent} = this.props; return ( <TouchableOpacity style={cardStyle} ref={_ => this._cardRefs[index] = _} onPress={() => this._onPressCard(index)} > {renderCardContent(item, index)} </TouchableOpacity> ); }; _renderList() { const {data} = this.props; return ( <FlatList data={data} keyExtractor={(item, index) => index.toString()} renderItem={this._renderCard} /> ); } // ... }
获取点击卡片的位置是神奇移动效果中最为关键的一环,那么如何获取呢?
其实在RN自定义组件封装 - 拖拽选择日期的日历这篇文章中,咱们就已经小试牛刀。
UIManager.measure(findNodeHandle(ref), (x, y, width, height, pageX, pageY) => { // x: 相对于父组件的x坐标 // y: 相对于父组件的y坐标 // width: 组件宽度 // height: 组件高度 // pageX: 组件在屏幕中的x坐标 // pageY: 组件在屏幕中的y坐标 });
所以,借助UIManager.measure咱们能够很轻易地得到卡片在屏幕中的坐标,上一步保存下来的ref也派上了用场。
另外,因为弹出层从卡片的位置展开成铺满全屏这个过程有一个过渡的动画,因此咱们须要用到Animated来控制这个变化过程。让咱们来看一下代码:
// Constants.js export const DeviceSize = { WIDTH: Dimensions.get('window').width, HEIGHT: Dimensions.get('window').height }; // Utils.js export const Utils = { interpolate(animatedValue, inputRange, outputRange) { if(animatedValue && animatedValue.interpolate) { return animatedValue.interpolate({inputRange, outputRange}); } } }; // MagicMoving.js export class MagicMoving extends Component { constructor(props) { // ... this.popupAnimatedValue = new Animated.Value(0); } _onPressCard = index => { UIManager.measure(findNodeHandle(this._cardRefs[index]), (x, y, width, height, pageX, pageY) => { // 生成浮层样式 this.popupLayerStyle = { top: Utils.interpolate(this.popupAnimatedValue, [0, 1], [pageY, 0]), left: Utils.interpolate(this.popupAnimatedValue, [0, 1], [pageX, 0]), width: Utils.interpolate(this.popupAnimatedValue, [0, 1], [width, DeviceSize.WIDTH]), height: Utils.interpolate(this.popupAnimatedValue, [0, 1], [height, DeviceSize.HEIGHT]) }; // 设置浮层可见,而后开启展开浮层动画 this.setState({selectedIndex: index, showPopupLayer: true}, () => { Animated.spring(this.popupAnimatedValue, {toValue: 1, friction: 6}).start(); }); }); }; _renderPopupLayer() { const {data} = this.props; const {selectedIndex, showPopupLayer} = this.state; return ( <Modal transparent={true} visible={showPopupLayer} onRequestClose={this._onRequestClose} > {showPopupLayer && ( <Animated.View style={[styles.popupLayer, this.popupLayerStyle]}> {this._renderPopupLayerContent(data[selectedIndex], selectedIndex)} </Animated.View> )} </Modal> ); } _renderPopupLayerContent(item, index) { // TODO: ... } // ... } const styles = StyleSheet.create({ popupLayer: { position: 'absolute', overflow: 'hidden', backgroundColor: '#FFF' } });
仔细看appStore中的效果,咱们会发现浮层在铺满全屏的时候会有一个抖一抖的效果。其实就是弹簧运动,因此在这里咱们用了Animated.spring来过渡效果(要了解更多的,能够去官网上看更详细的介绍哦)。
通过前两步,其实咱们已经初步达到神奇移动的效果,即不管点击哪一个卡片,浮层都会从卡片的位置展开铺满全屏。只不过如今的浮层还未添加任何内容,因此接下来咱们就来构造浮层内容。
其中,浮层中最重要的一点就是头部的banner区域,并且这里的banner应该是和卡片的图片相匹配的。须要注意的是,这里的banner图片其实也有一个动画。没错,它随着浮层的展开变大了。因此,咱们须要再添加一个AnimatedValue来控制banner图片动画。来看代码:
export class MagicMoving extends Component { constructor(props) { // ... this.bannerImageAnimatedValue = new Animated.Value(0); } _updateAnimatedStyles(x, y, width, height, pageX, pageY) { this.popupLayerStyle = { top: Utils.interpolate(this.popupAnimatedValue, [0, 1], [pageY, 0]), left: Utils.interpolate(this.popupAnimatedValue, [0, 1], [pageX, 0]), width: Utils.interpolate(this.popupAnimatedValue, [0, 1], [width, DeviceSize.WIDTH]), height: Utils.interpolate(this.popupAnimatedValue, [0, 1], [height, DeviceSize.HEIGHT]) }; this.bannerImageStyle = { width: Utils.interpolate(this.bannerImageAnimatedValue, [0, 1], [width, DeviceSize.WIDTH]), height: Utils.interpolate(this.bannerImageAnimatedValue, [0, 1], [height, DeviceSize.WIDTH * height / width]) }; } _onPressCard = index => { UIManager.measure(findNodeHandle(this._cardRefs[index]), (x, y, width, height, pageX, pageY) => { this._updateAnimatedStyles(x, y, width, height, pageX, pageY); this.setState({ selectedIndex: index, showPopupLayer: true }, () => { Animated.parallel([ Animated.timing(this.closeAnimatedValue, {toValue: 1}), Animated.spring(this.bannerImageAnimatedValue, {toValue: 1, friction: 6}) ]).start(); }); }); }; _renderPopupLayerContent(item, index) { const {renderPopupLayerBanner, renderPopupLayerContent} = this.props; return ( <ScrollView bounces={false}> {renderPopupLayerBanner ? renderPopupLayerBanner(item, index, this.bannerImageStyle) : ( <Animated.Image source={item.image} style={this.bannerImageStyle}/> )} {renderPopupLayerContent(item, index)} {this._renderClose()} </ScrollView> ); } _renderClose() { // TODO: ... } // ... }
从上面的代码中能够看到,咱们主要有两个变化。
添加完了bannerImage以后,咱们别忘了给浮层再添加一个关闭按钮。为了更好的过渡效果,咱们甚至能够给关闭按钮加一个淡入淡出的效果。因此,咱们还得再加一个AnimatedValue。。。
export class MagicMoving extends Component { constructor(props) { // ... this.closeAnimatedValue = new Animated.Value(0); } _updateAnimatedStyles(x, y, width, height, pageX, pageY) { // ... this.closeStyle = { justifyContent: 'center', alignItems: 'center', position: 'absolute', top: 30, right: 20, opacity: Utils.interpolate(this.closeAnimatedValue, [0, 1], [0, 1]) }; } _onPressCard = index => { UIManager.measure(findNodeHandle(this._cardRefs[index]), (x, y, width, height, pageX, pageY) => { this._updateAnimatedStyles(x, y, width, height, pageX, pageY); this.setState({ selectedIndex: index, showPopupLayer: true }, () => { Animated.parallel([ Animated.timing(this.closeAnimatedValue, {toValue: 1, duration: openDuration}), Animated.spring(this.popupAnimatedValue, {toValue: 1, friction: 6, duration: openDuration}), Animated.spring(this.bannerImageAnimatedValue, {toValue: 1, friction: 6, duration: openDuration}) ]).start(); }); }); }; _onPressClose = () => { // TODO: ... } _renderClose = () => { return ( <Animated.View style={this.closeStyle}> <TouchableOpacity style={styles.closeContainer} onPress={this._onPressClose}> <View style={[styles.forkLine, {top: +.5, transform: [{rotateZ: '45deg'}]}]}/> <View style={[styles.forkLine, {top: -.5, transform: [{rotateZ: '-45deg'}]}]}/> </TouchableOpacity> </Animated.View> ); }; // ... }
浮层关闭的动画其实肥肠简单,只要把相应的AnimatedValue全都变为0便可。为何呢?由于咱们在打开浮层的时候,生成的映射样式就是定义了浮层收起时候的样式,而关闭浮层以前是不可能打破这个映射关系的。所以,代码很简单:
_onPressClose = () => { Animated.parallel([ Animated.timing(this.closeAnimatedValue, {toValue: 0}), Animated.timing(this.popupAnimatedValue, {toValue: 0}), Animated.timing(this.bannerImageAnimatedValue, {toValue: 0}) ]).start(() => { this.setState({showPopupLayer: false}); }); };
其实到这儿,包括展开/收起动画的神奇移动效果基本上已经实现了。关键点就在于利用UIManager.measure获取到点击卡片在屏幕中的坐标位置,再配上Animated来控制动画便可。
不过,仍是有不少能够进一步完善的小点。好比:
这些小点限于文章篇幅就再也不展开详述,能够查看完整代码。
是骡子是马,遛遛就知道。随便抓了10篇简书上的文章做为内容,利用MagicMoving简单地作了一下这个demo。让咱们来看看效果怎么样:
作完这个组件以后最大的感悟就是,有些看上去可能比较新颖的交互动画其实作起来可能肥肠简单。。。贵在多动手,多熟悉。就好比此次,也是更加熟悉了Animated和UIManager.measure的用法。总之,仍是小有成就感的,hia hia hia~
老规矩,本文代码地址: