9

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

1. 前言

最近盯上了app store中的动画效果,感觉挺好玩的,嘿嘿~ 恰逢周末,得空就实现一个试试。不试不知道,做完了才发现其实还挺简单的,所以和大家分享一下封装这个组件的过程和思路。

2. 需求分析

首先,我们先来看看app store中的效果是怎么样的,看下图:

image

哇,这个动画是不是很有趣,很神奇。为此,可以给它取个洋气的名字:神奇移动,英文名叫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。让我们来看看效果怎么样:

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

5. 写在最后

做完这个组件之后最大的感悟就是,有些看上去可能比较新颖的交互动画其实做起来可能肥肠简单。。。贵在多动手,多熟悉。就比如这次,也是更加熟悉了Animated和UIManager.measure的用法。总之,还是小有成就感的,hia hia hia~

老规矩,本文代码地址:

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


小石头若海
1.4k 声望1.4k 粉丝

努力不一定成功,但不努力会很轻松哦~