RN自定義組件封裝 - 神奇移動

原文地址:https://github.com/SmallStoneSK/Blog/issues/4javascript

1. 前言

最近盯上了app store中的動畫效果,感受挺好玩的,嘿嘿~ 恰逢週末,得空就實現一個試試。不試不知道,作完了才發現其實還挺簡單的,因此和你們分享一下封裝這個組件的過程和思路。java

2. 需求分析

首先,咱們先來看看app store中的效果是怎麼樣的,看下圖:react

哇,這個動畫是否是頗有趣,很神奇。爲此,能夠給它取個洋氣的名字:神奇移動,英文名叫magicMoving~git

皮完以後再回到現實中來,這個動畫該如何實現呢?咱們來看看這個動畫,首先一開始是一個長列表,點擊其中一個卡片以後彈出一個浮層,並且這中間有一個從卡片放大到浮層的過渡效果。乍一看好像挺難的,但若是把整個過程分解一下彷佛就迎刃而解了。github

  1. 用FlatList渲染長列表;
  2. 點擊卡片時,獲取點擊卡片在屏幕中的位置(pageX, pageY);
  3. clone點擊的卡片生成浮層,利用Animated建立動畫,控制浮層的寬高和位移;
  4. 點擊關閉時,利用Animated控制浮層縮小,動畫結束後銷燬浮層。

固然了,以上的這個思路實現的只是一個毛胚版的神奇移動。。。還有不少細節能夠還原地更好,好比背景虛化,點擊卡片縮小等等,不過這些不是本文探討的重點。spring

3. 具體實現

在具體實現以前,咱們得考慮一個問題:因爲組件的通用性,浮層可能在各類場景下被喚出,可是又須要可以鋪滿全屏,因此咱們可使用Modal組件。react-native

而後,根據大概的思路咱們能夠先搭好整個組件的框架代碼:bash

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>
    );
  }
}
複製代碼

3.1 構造列表

列表很簡單,只要調用方指定了data,用一個FlatList就能搞定。可是card中的具體樣式,咱們應該交由調用方來肯定,因此咱們能夠暴露renderCardContent方法出來。除此以外,咱們還須要保存下每一個card的ref,這個在後面獲取卡片位置有着相當重要的做用,看代碼:app

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} /> ); } // ... } 複製代碼

3.2 獲取點擊卡片的位置

獲取點擊卡片的位置是神奇移動效果中最爲關鍵的一環,那麼如何獲取呢?框架

其實在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來過渡效果(要了解更多的,能夠去官網上看更詳細的介紹哦)。

3.3 構造浮層內容

通過前兩步,其實咱們已經初步達到神奇移動的效果,即不管點擊哪一個卡片,浮層都會從卡片的位置展開鋪滿全屏。只不過如今的浮層還未添加任何內容,因此接下來咱們就來構造浮層內容。

其中,浮層中最重要的一點就是頭部的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: ... } // ... } 複製代碼

從上面的代碼中能夠看到,咱們主要有兩個變化。

  1. 爲了保證popupLayer和bannerImage保持同步的展開動畫,咱們用上了Animated.parallel方法。
  2. 在渲染浮層內容的時候,能夠看到咱們暴露出了兩個方法:renderPopupLayerBanner和renderPopupLayerContent。而這些都是爲了可讓調用方能夠更大限度地自定義本身想要的樣式和內容。

添加完了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>
    );
  };
  
  // ...
}
複製代碼

3.4 添加浮層關閉動畫

浮層關閉的動畫其實肥腸簡單,只要把相應的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});
  });
};
複製代碼

3.5 小結

其實到這兒,包括展開/收起動畫的神奇移動效果基本上已經實現了。關鍵點就在於利用UIManager.measure獲取到點擊卡片在屏幕中的座標位置,再配上Animated來控制動畫便可。

不過,仍是有不少能夠進一步完善的小點。好比:

  1. 由調用方控制展開/收起浮層動畫的運行時長;
  2. 暴露展開/收起浮層的事件:onPopupLayerWillShow,onPopupLayerDidShow,onPopupLayerDidHide
  3. 支持浮層內容異步加載
  4. ...

這些小點限於文章篇幅就再也不展開詳述,能夠查看完整代碼。

4. 實戰

是騾子是馬,遛遛就知道。隨便抓了10篇簡書上的文章做爲內容,利用MagicMoving簡單地作了一下這個demo。讓咱們來看看效果怎麼樣:

5. 寫在最後

作完這個組件以後最大的感悟就是,有些看上去可能比較新穎的交互動畫其實作起來可能肥腸簡單。。。貴在多動手,多熟悉。就好比此次,也是更加熟悉了Animated和UIManager.measure的用法。總之,仍是小有成就感的,hia hia hia~

老規矩,本文代碼地址:

github.com/SmallStoneS…

相關文章
相關標籤/搜索