RN自定义组件封装 - 神奇移动

原文地址: 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,475评论 4 372
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,744评论 2 307
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,101评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,732评论 0 221
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,141评论 3 297
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,049评论 1 226
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,188评论 2 320
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,965评论 0 213
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,716评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,867评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,341评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,663评论 3 263
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,376评论 3 244
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,200评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,990评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,179评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,979评论 2 279

推荐阅读更多精彩内容