开发一个IM应用,发送emoji和表情包贴图的是很受欢迎的feature.微信的实现有很好的用户体验,用户也接受了这种表情的选择和发送方式。那么在React Native中如何实现呢?

1.需要实现的效果

    1. 分类显示表情
    1. 指示器显示当前页在分类中的位置
    1. 滑动翻页切换表情,并且自动切换分类
    1. 点击分类跳转到分类的表情

demo.gif

2. 如何实现呢?

拆分组件

可以看到最终我们实现的效果是不错的。接着分析实现。按照React的组件化的思想。从上往下,从下往上分割组件实现都是可以的。这里方便描述从上往下拆分组件。如下图所示,不同颜色代表不同的组件。作为表情选择组件本身定义为StcikerPciker结合多个子组件。这里比较疑惑的是黄色和橙色矩形区域所代表的组件。因为是可以滑动分页切换的所以黄色代表的是可滑动组件,橙色是每一页内容的组件。接着是绿色部分指示当前页在分类中的位置,随着滑动和切换分类时变化。最下面是分类选择,可以高亮显示当前分类,并且可点击选择分类,同时滑动分页切换分类,也跟随切换分类。
组件拆分

组件的实现

按照以上的分析,除了滑动分页组件其它子组件都需要自己实现,最终组合成StickerPicker.滑动分页怎么做比较好呢?查看官方文档,发现ScrollView 是可以做到的。pagingEnabled 置为true 即可。

<ScrollView
        ref={v => this.scrollView = v}
        style={[styles.scrollview, { height: viewHeight }]}
        automaticallyAdjustContentInsets={false}
        horizontal={true}
        pagingEnabled={true}
        showsHorizontalScrollIndicator={false}
        onMomentumScrollEnd={this.onContentHorizontalScrollEnd}
        scrollEventThrottle={16}
      >

除此之外需要设置horizontal=true,水平方向布局。也不需要显示Indicator。可以看到每一页都是需要换行布局的,这里定义GridView 组件实现。通过传入数据,和行数,以及renderItem 实现渲染每一页的表情。动态换行需要计算宽高,并且动态换行添加子组件。

GridView 换行添加子组件

const itemContainers: React.ReactElement[] = [];
    const maxLine = Math.ceil(data.length / numColumns);
    for (let i = 0; i < maxLine; i++) {
      const itemContainer: React.ReactElement[] = [];
      let startIndex = 0;
      for (let j = 0; j < numColumns; j++) {
        startIndex = j + i * numColumns;
        if (startIndex < data.length) {
          const child = renderItem(data[startIndex]);
          itemContainer.push(child);
        } else {
          break;
        }
      }

      itemContainers.push(<View style={styles.itemContainer} key={i}>{itemContainer}</View>);
    }

也许会问,这里为什么需要单独实现GridView.而不使用FlatList。没错,FlatList 是可以满足实现的,只是FlatList 主要是用于无限列表的加载,很重量级。在这里使用未免大材小用,并导致过度绘制。所以这里实现GridView主要是为了性能。除此之外实现GridView 不是很复杂,只是需要计算相关数组操作,临界值的处理,以及宽高的处理。
到这里基本上可以跑起来了,可以实现数据的添加以及滑动。数据的添加在StcikerPicker 是很重要以及复杂的,这里的实现主要花的时间精力在这里。后面部分会详细讲,这里先对组件的实现进行讲解。到这里还有两个指示器未实现。先看到分页指示器 SegmentControl。其实时间是很简单的,主要通过显示多个小圆圈,和选中的圆圈指定选中的样式即可实现.以下是核心代码

 render() {
        const { length } = this.props;
        return (
            <View style={styles.view}>
                {new Array(length).fill(1).map(this.renderItem)}
            </View>
        );
    }

    private renderItem = (item, index) => {
        const { currentIndex, color, currentColor } = this.props;
        const bgColor = (value) => ({ backgroundColor: value });
        const style = index === currentIndex ?
            [styles.cur, bgColor(currentColor)] :
            [styles.other, bgColor(color)];
        return <View key={index} style={style} />;
    }

。接着是分类指示器 CategoryControl,与SegmentControl 实现是很相似的只是,对单个item 的实现略微复杂一些。
到这里主要组件的实现基本上完成了。接着就需要把组件组合起来,并赋予它们事件和逻辑互相关联起来作为一个整体存在。

组件的整合

可以看到滑动sticker和会触发两个指示器的变化。点击分类指示器同样也会触发另一个指示器和sticker页面的变化。这就需要事件的处理了。先看到对滑动sticker事件的处理,ScrollView 滑动结束会触发onMomentumScrollEnd回调。我们实现即可,回调传入的参数也很详细。

 private onContentHorizontalScrollEnd = (event) => {
    const offsetX = event.nativeEvent.contentOffset.x;
    const newIndex = Math.round(offsetX / this.state.width);
    if (newIndex !== this.state.curIndex) {
      if (StickerManager.getInstance().checkCategoryChanged(this.state.curIndex, newIndex)) {
        this.onCategoryChanged();
        this.setState({
          curIndex: newIndex,
          categoryCount: StickerManager.getInstance().getCagegorySizeByIndex(newIndex)
        });
      } else {
        this.setState({
          curIndex: newIndex,
        });
      }
    }
  }

滑动到下一页或者上一页,我们需要获取到滑动到的页面的index,这个index的取值是大于0小于页数。offsetX 对于我们获取到index是非常有用的。通过offset / this.state.width 。偏移量除以页面的宽度即为index的值,然后就可以做下一步的操作。通过给StickerManager的checkCategoryChanged 传入curIndex,newIndex 可以判断是否分类改变,如果改变触发onCategoryChanged,并且获取当亲分类的categoryCount,传给指示器。如果分类未改变传给state curIndex新值。通过这里的处理我们已经可以实现滑动页面分类的变化了。还有很重要的一步,点击分类页面的变化。我们在StcategoryControl 设置onSelect 属性。当点击分类调用,然后在StickerPicker 添加实现

private onCategorySelect = (category: { name: string, image: NodeRequire }) => {
    // 1. category选中,点击的item 2. 滑动到选中category 的分类
    const newIndex = StickerManager.getInstance().getIndexByCategory(category.name);
    this.setState({
      curIndex: newIndex,
      categoryCount: StickerManager.getInstance().getCagegorySizeByIndex(newIndex)
    });
    this.scrollView && this.scrollView.scrollTo({
      y: 0,
      x: this.state.width * newIndex,
      animated: false
    });
  }

通过返回的category 并传递个StickerManager的getIndexByCategory 获取选中分类的newIndex.然后setState 新分类的categoryCount,同样是通过StickerManager获取。
然后让scrollView 滑动到分类的位置,通过调用scrollTo方法,x值为,width * newIndex .到这里已经对事件的处理有一个比较完整的实现了,可以看到涉及到数据离不开StickerManager。接下来进行分析

数据的处理

StickerManager 封装了对sticker的处理。在整个app生命周期中,应该是只加载一次就可以,所以设计为单例模式的。可以看到数据是在一个json文件中-- sticker.ts 作为一个json对象,通过Object的entires方法。获取了key:value值。然后转变了StickerCategory[]. 这里对每个category 没有占满分类最大值的情况,还有添加占位的placeholder .这些处理都在loadSticker中进行了完整的实现。然后就是剩下的十来个左右的数据处理函数,这些函数都是在StickerPicker的实现中一步步添加的。当然也是有总体的设计。

  public getAllStickers(): StickerItem[] {
    if (this.stickerCategories.length === 0) {
      return [];
    }
    return this.stickerCategories.map(cagegory => cagegory.getStickers()).reduce((pre, cur) => {
      return pre.concat(cur);
    });
  }

以getAllStickers 为例,获取所有sticker包括placeholder 通过该方法。主要涉及到对数组相关函数的使用。如果感兴趣,可以看看源码,由于时间还有写作表达的不住,可能在以上的介绍中存在一些理解偏差。结合代码使用更好额。我是源码

可能出现的问题:

SHA-1 for file E:\private_project\great_frontend\react-native-app\RNStickerPicker\asset\stickers\Asongsongmeow-resized\A1.gif
出现这个问题,可是个大坑。可以看到问题开头SHA-1 for file xxxx.那么这个SHA-1 是在那个那里产生的呢。在编译期metro 会进行校验。然而不止咋的报错了。所以暴力的处理方式通过,把报错的代码进行注释即可,至于有没有其它副作用?暂时还没有遇到。