RN custom component package-magic move

Original address: https://github.com/SmallStoneSK/Blog/issues/4

1. Preface

Recently, I have been eyeing the animation effect in app store, which is quite fun. Hey hey ~ It’s on the weekend, so I’ll try it out when I get free. I don’t know if I try, but it’s actually quite simple when I finish it, so I’d like to share with you the process and idea of packaging this component.

2. Demand analysis

First of all, let’s look at the effect in app store, and see the following figure:

image

Wow, is this animation very interesting and amazing? To this end, you can give it a foreign name: magic moving, English name is magicMoving~

After the skin is finished, return to reality. How should this animation be realized?

Let’s look at this animation. First, it starts with a long list. Click on one of the cards and a floating layer pops up. And there is a transition effect from card enlargement to floating layer in the middle. At first glance, it seems quite difficult, but if the whole process is broken down, it seems to be solved.

  1. Render long lists with FlatList;
  2. When clicking the card, obtain the position of the clicked card in the screen (Pagex, Pagey);
  3. Clone clicks on a card to generate a floating layer, and animation is created by using Animated to control the width, height and displacement of the floating layer.
  4. When you click Close, use Animated to control the floating layer to shrink, and destroy the floating layer after the animation is finished.

Of course, the above idea only realizes a magic movement of the blank version. . . There are many details that can be restored better, such as the background is blurred, click on the card to reduce, etc., but these are not the focus of this article.

3. Concrete Implementation

Before implementation, we have to consider a problem: due to the commonality of components, floating layers may be called out in various scenarios, but they need to be able to be covered in full screen, so we can use Modal components.

Then, according to the general idea, we can build the framework code of the whole component first:

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 Construction List

The list is very simple, as long as the caller specifies data, it can be done with a FlatList. However, the specific style in the card should be determined by the caller, so we can expose the renderCardContent method. In addition, we also need to save the ref of each card, which plays a vital role in obtaining the card position at the back. Look at the code:

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 Get the Location of Click Card

Getting the location of the click card is the most critical link in the magical movement effect, so how do you get it?

In factRN Custom Component Package-Drag Calendar to Select DateIn this article, we have already tried our best.

UIManager.measure(findNodeHandle(ref), (x, y, width, height, pageX, pageY) => {
  // x:      相对于父组件的x坐标
  // y:      相对于父组件的y坐标
  // width:  组件宽度
  // height: 组件高度
  // pageX:  组件在屏幕中的x坐标
  // pageY:  组件在屏幕中的y坐标
});

Therefore, with UIManager.measure, we can easily obtain the coordinates of the card on the screen, and ref saved in the previous step is also used.

In addition, since there is a transitional animation in the process of the pop-up layer spreading from the position of the card to the full screen, we need to use Animated to control the change process. Let’s look at the code:

// 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'
  }
});

Looking closely at the effect in appStore, we will find that the floating layer will have a shaking effect when it is covered with full screen. In fact, it is spring movement, so here we use Animated.spring to transition the effect (to learn more, you can go to the official website to see more detailed introduction).

3.3 Content of Structural Floating Layer

After the first two steps, in fact, we have initially achieved the magical effect of moving, that is, no matter which card is clicked, the floating layer will spread out from the position of the card and cover the full screen. However, nothing has been added to the current floating layer, so let’s construct the floating layer content next.

Among them, the most important point in the floating layer is the banner area of the head, and the banner here should match the picture of the card. It should be noted that the banner picture here actually has an animation. Yes, it gets bigger as the floating layer expands. Therefore, we need to add an AnimatedValue to control the animation of banner pictures. Look at the code:

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: ...
  }
  
  // ...
}

As can be seen from the above code, we have two main changes.

  1. In order to ensure that popupLayer and bannerImage keep synchronized unfolding animation, we used Animated.parallel method.
  2. When rendering floating layer content, we can see that we have exposed two methods: renderPopupLayerBanner and renderPopupLayerContent. These are all to allow callers to customize the style and content they want to a greater extent.

After adding bannerImage, let’s not forget to add another close button to the floating layer. For better transition effect, we can even add a fade-in effect to the close button. Therefore, we have to add another 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 Add Float to Close Animation

The animation of floating layer closing is actually simple, as long as the corresponding AnimatedValue is all changed to 0. Why? Because when we open the floating layer, the generated mapping style defines the style when the floating layer is folded up, and it is impossible to break this mapping relationship before closing the floating layer. Therefore, the code is simple:

_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 Summary

In fact, the magical moving effect including unfolding/folding animation has basically been realized here. The key point is to use UIManager.measure to obtain the coordinate position of the click card in the screen, and then match it with Animated to control the animation.

However, there are still many small points that can be further improved. For example:

  1. The calling party controls the running time of unfolding/folding the floating layer animation;
  2. Expose events of expanding/collapsing floating layer: onPopupLayerWillShow, onPopupLayerDidShow, onPopupLayerDidHide
  3. Supports asynchronous loading of floating layer content

These small points will not be expanded in detail until the length of the article. You can view the complete code.

4. Actual combat

Whether it’s a mule or a horse, just walk it. I grabbed 10 articles from simple books as content, and used MagicMoving to make this demo. Let’s see how it works:

浮层数据内容已ready
浮层数据内容异步加载

5. Write at the end

After finishing this component, the biggest feeling is that some interactive animations that may seem novel may actually be fat and simple to make. . . The most important thing is to be more hands-on and familiar with it. This time, for example, I am more familiar with the usage of Animated and UIManager.measure In short, there is still a little sense of accomplishment, hia hia hia~

As usual, this article code address:

https://github.com/SmallStoneSK/react-native-magic-moving