原文地址: 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~
老規矩,本文代碼地址: