【React Native填坑之旅】撸一个简易聊天表情组件-ChatUI

目录

一、需求

二、实现思路

参考资料

一、需求

笔者是做直播类App的,近期项目准备用React Native对直播间进行改造,其中涉及一个基础的功能点就是在直播间中点击一个按钮,弹出输入框进行快速发言,这个输入框有自定义的表情。Google了下由于没有现成的库拿来用,只能撸起袖子自己干了!

二、实现思路

讲解之前,先来看看最终的效果图:

rn-chatui.png
1、Emoji表情

由于App自有一套表情体系,这里找了微信聊天表情来演示,建立一个公共的DataSource


//符号->图片路径
export const EMOTIONS_DATA = {
  '/{weixiao': require('./emotions/weixiao.png'), /* 微笑 */
  '/{piezui': require('./emotions/piezui.png'), /* 撇嘴 */
  '/{se': require('./emotions/se.png'), /* 色 */
  ......
};

//符号->中文
export const EMOTIONS_ZHCN = {
  '/{weixiao':'[微笑]',
  '/{piezui': '[撇嘴]',
  '/{se': '[色]',
  ......
};

//反转对象的键值
export const invertKeyValues = obj =>
  Object.keys(obj).reduce((acc, key) => {
    acc[obj[key]] = key;
    return acc;
  }, {});

EMOTIONS_DATA :变量与图片资源的映射

EMOTIONS_ZHCN :变量与中文语义之间的映射,因为React Native中TextInput不支持插入图片,这么做一是为了兼容以前的App版本,二是为了消除用户对形如‘/{abc’特殊字符的歧义

invertKeyValues : 反转对象的键值,也即反转上面的EMOTIONS_ZHCN,这个后面在图文混排显示时会用到

2、输入框区域

输入框区域布局比较简单,用flexbox,这里着重讲ChatInputBar,


  render() {
    if (Platform.OS === 'android'){
      return this._renderAndroidView();
    }

    return this._renderIosView();

  }

可以看到render方法中,根据平台作了适配,渲染不同View。之所以要区分得从一个需求点说起,注意到需求是点击一个按钮弹出一个输入框,不同于IM聊天界面输入框是固定在底部的,所以这里自然想到用弹窗Dialog,而React Native官方提供了一个叫Modal的组件,该组件可以用来覆盖包含React Native根视图的原生视图,但在实际开发过程中遇到了一个坑,即如果在Android系统上用原生的Modal,里面包裹ViewPagerAndroid组件,手机主动关屏再次亮屏后,会出现无法滑动ViewPagerAndroid的情况,具体原因还不清楚,后续会继续研究下,至于解决方案是我采用了第三方库react-native-modalbox,来看看_renderAndroidView()里面的代码


_renderAndroidView(){
    return <ModalBox
      swipeToClose={false}
      backdropOpacity={0}
      backButtonClose={true}
      onClosed={() => this._onModalBoxClosed()}
      style={[styles.container]} position={'bottom'} ref={'modal'}>

      <TouchableWithoutFeedback onPress={() => this.closeInputBar()}>
        <View style={styles.box_container}/>
      </TouchableWithoutFeedback>
      <View style={styles.inputContainer}>
        <View style={styles.textContainer}>
          <TouchableWithoutFeedback
            onPress={() => this._toogleShowEmojiView()}>
            <Image style={styles.emojiStyle} source={require('./emotions/ic_emoji.png')}/>
          </TouchableWithoutFeedback>

          <TextInput
            ref="textInput"
            style={styles.inputStyle}
            underlineColorAndroid="transparent"
            multiline = {true}
            autoFocus={true}
            editable={true}
            placeholder={'说点什么'}
            placeholderTextColor={'#bababf'}
            onSelectionChange={(event) => this._onSelectionChange(event)}
            onChangeText={(text) => this._onInputChangeText(text)}
            onFocus={() => this._onFocus()}
            defaultValue={this.state.inputValue}/>

          <TouchableWithoutFeedback onPress={() => this._onSendMsg()}>
            <View ref="sendBtnWrapper" style={[styles.sendBtnStyle]}>
              <Text ref="sendBtnText" style={[styles.sendBtnTextStyle]}>发送</Text>
            </View>
          </TouchableWithoutFeedback>
        </View>
        <Line lineColor={'#bababf'}/>
        {
          this.state.isEmotionsVisible &&
          <EmotionsView onSelected={(code) => this._onEmojiSelected(code)}/>
        }
      </View>
    </ModalBox>;
  }
  

这里比较复杂的是TextInput组件,遇到两个比较坑的问题,首先我们知道在Android中EditText是可以通过SpannableString来插入图片的,而React Native中TextInput不支持插入图片,只能插入纯文本,既然不能插入表情图只能用表情符号替代了;另外的,TextInput没有直接获取当前正在编辑文本光标所在位置的方法,怎么办呢,自己保存一个全局的cursorIndex变量呗....,经过摸索,可以通过onSelectionChange来获取第一次点击时光标所在的位置,之后如果通过输入法或者表情插入文本时,就需要自己维护这个光标索引了,以下是引用其文档的解释

onSelectionChange function

长按选择文本时,选择范围变化时调用此函数,传回参数的格式形如 { nativeEvent: { selection: { start, end } } }

在代码中通过cursorIndex状态变量来保存


  _onSelectionChange(event){

    this.setState({
      cursorIndex:event.nativeEvent.selection.start,
    });
  }
  

_renderIosView()_renderAndroidView()里面的代码大致相同,只是用了原生的Modal


_renderIosView(){
    return <Modal
      animationType={'slide'} transparent={true} visible={this.state.modalVisible}>
      ......
      <KeyboardAvoidingView behavior={'position'}>
        ......

            <TextInput
              ref="textInput"
              style={styles.inputStyle}
              underlineColorAndroid="transparent"
              multiline = {true}
              autoFocus={true}
              editable={true}
              keyboardType={'default'}
              selectionColor={'#56b2f0'}
              returnKeyType={'send'}
              placeholder={'说点什么'}
              enablesReturnKeyAutomatically={true}
              placeholderTextColor={'#bababf'}
              onSelectionChange={(event) => this._onSelectionChange(event)}
              onChangeText={(text) => this._onInputChangeText(text)}
              onFocus={() => this._onFocus()}
              defaultValue={this.state.inputValue}/>
         ......
      </KeyboardAvoidingView>
    </Modal>;
  } 

还有个不同的地方是_renderIosView()中使用了KeyboardAvoidingView进行包裹,该组件用于解决一个常见的尴尬问题:手机上弹出的键盘常常会挡住当前的视图。本组件可以自动根据键盘的位置,调整自身的position或底部的padding,以避免被遮挡,那为什么_renderAndroidView()没有用该组件包裹呢?说多了都是泪,因为不起作用啊。。。不过你在Android平台运行ChatUI项目后发现,输入法并没有遮挡住输入框视图,原因是ChatUI项目的Andorid工程里,AndroidManifest注册的MainActivity设置了android:windowSoftInputMode="adjustResize"属性,这个属性可以让视图根据软键盘的弹出自动调整,所以注意了,如果你的项目是原生和React Native混合开发的,在React Native依赖的那个Activity中,必须设置相应的android:windowSoftInputMode属性来适配相关的需求,哈哈哈!😝

3、表情选择区域

分页显示的表情视图,在网上找了一个开源库rn-viewpager,简单浏览了下源码,在IOS端是通过ScrollView是实现的,Android端还是封装了React Native的ViewPagerAndroid组件,使用第三方库的好处是能够快速适配两个端,贴切需求,但坑也是大大滴!先来看一段ViewPagerAndroid文档中的一段话:

ViewPagerAndroid

一个允许在子视图之间左右翻页的容器。每一个ViewPagerAndroid的子容器会被视作一个单独的页,并且会被拉伸填满。
注意所有的子视图都必须是纯View,而不能是自定义的复合容器。

注意,所有的子视图都必须是纯View,而不能是自定义的复合容器

以下是我的EmotionsView修改前:


  _renderPagerView(){
   ......
   
    viewItems.push(<EmotionsChildView key={0} />);
    viewItems.push(<EmotionsChildView key={1} />);
    viewItems.push(<EmotionsChildView key={2} />);
    return viewItems;
  }

  render() {
    return (
      <IndicatorViewPager
        style={styles.wrapper}
        indicator={this._renderDotIndicator()}>
        { this._renderPagerView() }
      </IndicatorViewPager>
    );
  }

修改后:


  _renderPagerView(){
   ......
   
    viewItems.push(<View key={0}><EmotionsChildView/></View>);
    viewItems.push(<View key={1}><EmotionsChildView/></View>);
    viewItems.push(<View key={2}><EmotionsChildView/></View>);
    return viewItems;
  }

  render() {
    return (
      <IndicatorViewPager
        style={styles.wrapper}
        indicator={this._renderDotIndicator()}>
        { this._renderPagerView() }
      </IndicatorViewPager>
    );
  }

其中EmotionsChildView是我自己封装的一个复合表格View,如果在ViewPagerAndroid中的子View不是纯View,而是一个复合容器的话,将会导致子View内部无法测量正确的高度!

4、表情图文混排显示

RichTextWrapper 封装的一个富文本组件,为ChatInputBar而生,仅有一个textContent属性


    <RichTextWrapper textContent={this.state.chatMsg}/>
    

内部的实现:

    
  let emojiReg = new RegExp('\\/\\{[a-zA-Z_]{1,18}'); //表情符号正则表达式
    
  componentWillReceiveProps(nextProps) {

    this.state.Views.length=0;

    let textContent = nextProps.textContent;
    this._matchContentString(textContent);

  }

  _matchContentString(textContent){

    // 匹配得到index并放入数组中
    let emojiIndex = textContent.search(emojiReg);

    let checkIndexArray = [];

    // 若匹配不到,则直接返回一个全文本
    if (emojiIndex === -1) {
      this.state.Views.push(<Text key ={'emptyTextView'+(Math.random()*100)}>{textContent}</Text>);

    } else {

      if (emojiIndex !== -1) {
        checkIndexArray.push(emojiIndex);
      }

      // 取index最小者
      let minIndex = Math.min(...checkIndexArray);

      // 将0-index部分返回文本
      this.state.Views.push(<Text key ={'firstTextView'+(Math.random()*100)}>{textContent.substring(0, minIndex)}</Text>);

      // 将index部分作分别处理
      this._matchEmojiString(textContent.substring(minIndex));
    }
  }

  _matchEmojiString(emojiStr) {

    let castStr = emojiStr.match(emojiReg);
    let emojiLength = castStr[0].length;

    this.state.Views.push(<Image key={emojiStr} style={styles.subEmojiStyle resizeMethod={'auto'3} source={EMOTIONS_DATA[castStr]}/>);

    this._matchContentString(emojiStr.substring(emojiLength));

  }
     

this.state.Views是一个View数组,在constructor()中定义,用于存放截取的各个子View;componentWillReceiveProps()接收外部传递进来的字符串textContent,具体匹配在_matchContentString ()进行匹配:将一个字符串按表情符正则匹配规则查找,如果没有表情符index返回-1,直接push一个Text子View到Views数组;如果有表情符,取出checkIndexArray中index最小者,将字符串拆分成三部分,0-index,index,index-end,其中0-index直接返回纯文本,index部分就是表情符,这里转换成Image子View并push到Views数组中,将index-end部分进行再次递归,直到最终的index为-1返回纯文本为止。

最后,奉上源码,有需要的童鞋自行去下载咯~

GitHub:https://github.com/WangGanxin/react-native-chatUI

参考资料

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

推荐阅读更多精彩内容