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

SmallStoneSK發表於2018-06-11

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

1. 前言

最近盯上了app store中的動畫效果,感覺挺好玩的,嘿嘿~ 恰逢週末,得空就實現一個試試。不試不知道,做完了才發現其實還挺簡單的,所以和大家分享一下封裝這個元件的過程和思路。

2. 需求分析

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

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

哇,這個動畫是不是很有趣,很神奇。為此,可以給它取個洋氣的名字:神奇移動,英文名叫magicMoving~

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

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

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

3. 具體實現

在具體實現之前,我們得考慮一個問題:由於元件的通用性,浮層可能在各種場景下被喚出,但是又需要能夠鋪滿全屏,所以我們可以使用Modal元件。

然後,根據大概的思路我們可以先搭好整個元件的框架程式碼:

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,這個在後面獲取卡片位置有著至關重要的作用,看程式碼:

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。讓我們來看看效果怎麼樣:

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

5. 寫在最後

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

老規矩,本文程式碼地址:

github.com/SmallStoneS…

相關文章