微信号:guolin_blog

介绍:Android技术分享平台,在这里不仅可以学到各种Android相关的最新技术,还可以将你自己的技术总结分享给其他人,每周定期更新.

Android自定义IM聊天界面

2019-01-28 08:02 hard_Rui



今日科技快讯


哈啰顺风车近日在上海、广州、杭州、成都、合肥、东莞6座城市上线试运营。上述城市用户通过更新后的“哈啰出行”APP,可使用市内或跨城顺风车服务。猎云网在其中三座城市体验了下哈啰顺风车,结果——打车5次,失败4次。


作者简介


大家早上好,春节将至,很多朋友现在可能都已经放假回家了,还在上班的朋友也要记得守好最后一班岗。

本篇来自 hard_Rui 的投稿文章,分享了自己自定义的聊天界面,希望对大家有所帮助!

https://blog.csdn.net/qq_28931623


前言


最近因为项目中用到了IM聊天的功能,由于项目中并不准备集成第三方的sdk ,所以就自己写了一个ui界面来实现消息发送接收。大家如果需要的话直接移到自己的项目中就行,先展示一下实现的效果,然后再简单介绍一下怎么实现的:


整体布局


布局分三部分,聊天列表 ,输入框所在布局,底部表情和其他消息选择所在的布局

1. 聊天列表

这里是SwipeRefreshLayout和RecyclerView,我在这里用谷歌官方的下拉刷新控件SwipeRefreshLayout来实现下拉刷新获取历史消息,如果希望其他的样式更换下拉刷新控件就行。

2. 输入框和表情和更多选择所在布局

这里主要有几个部分,语音按钮,表情按钮,加号按钮,输入框,发送按钮,语音按住按钮,表情布局,更多布局,键盘,我们在代码里控制好这些控件的显示和隐藏即可。我这里就以点击加号按钮为例:

    //绑定底部加号按钮
    public ChatUiHelper bindToAddButton(View addButton) {
        mAddButton = addButton;
        addButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                 mEditText.clearFocus();
                 //隐藏长按发送语音按钮
                 hideAudioButton();
                  //表情和更多所在布局显示
                 if (mBottomLayout.isShown()){
                 //更多所在布局显示
                  if (mAddLayout.isShown()){
                     //显示软件盘时,锁定内容高度,防止跳闪。
                      lockContentHeight();
                      //隐藏更多布局,显示软件盘
                      hideBottomLayout(true);
                      //软件盘显示后,释放内容高度
                      unlockContentHeightDelayed();
                  }else{
                      //显示更多布局
                      showMoreLayout();
                      //隐藏表情布局
                      hideEmotionLayout();
                  }
              }else{
                 //更多所在布局没显示,键盘显示
                  if (isSoftInputShown()) {
                      hideEmotionLayout();
                      showMoreLayout();
                      lockContentHeight();
                      showBottomLayout();
                      unlockContentHeightDelayed();
                  } else {
                      //表情,更多所在布局,键盘没显示,直接显示更多布局
                       showMoreLayout();
                       hideEmotionLayout();
                       showBottomLayout();
                  }
              }
            }
        });
        return this;
    }

3. recycleview不同item所显示的布局:

我写的聊天界面主要有 文本消息,语音消息,图片消息,视频消息,文件消息这几种布局。改动的话直接到对应的item布局中修改即可。对于多布局的显示我用的BaseRecyclerViewAdapterHelper,一种类型对应一个布局。在发送后根据不同的type判断显示即可, 在发送完网络请求后,更新单个item即可。

private static final int SEND_TEXT = R.layout.item_text_send;
private static final int RECEIVE_TEXT = R.layout.item_text_receive;
private static final int SEND_IMAGE = R.layout.item_image_send;
private static final int RECEIVE_IMAGE = R.layout.item_image_receive;
private static final int SEND_VIDEO = R.layout.item_video_send;
private static final int RECEIVE_VIDEO = R.layout.item_video_receive;
private static final int SEND_FILE = R.layout.item_file_send;
private static final int RECEIVE_FILE = R.layout.item_file_receive;
private static final int RECEIVE_AUDIO = R.layout.item_audio_receive;
private static final int SEND_AUDIO = R.layout.item_audio_send;


表情功能实现


对于表情,看上去是一个图片,但传输的时候其实就是一个文本。对于不同的应用像微博里表情就是"[表情名字]"的接口,比如可爱的表情就是[可爱],QQ表情就是一个 "/表情字母"的结构,比如害羞的表情就是/hx。

像这些文本显示成图片,我们可以用SpannableString,SpannableString就是显示字符串的时候,根据字符串中包含的拓展字段显示相应的效果,像字体,颜色之类的。如果显示成图片,大概原理就是 先通过正则,由字符串得到对应匹配的表情图片,然后使用ImageSpan,把Bitmap设置到SpannableString中,这样显示的时候显示SpannableString但展示出来的就是一个图片。如果自己对应不同的规则android端与ios端需要保持一致,如果android匹配到不同的图片显示出来而ios没有就尴尬了。

这里我使用的是Unicode码, Unicode码就是对于每个字符规定一个数字用来表示该字符,每个Emoji 都有自己对应的Unicode码点,当我们把对应的Unicode码点转化为字符时,它会被渲染为图片显示.例如一个笑脸:new String(Character.toChars(128512)) 这样我们可以把Unicode码点转化为字符,然后渲染为笑脸的表示。在这个网站上http://unicode.org/emoji/charts/full-emoji-list.html#1f600我们可以找到不同的表情对应的不同的Unicode码,我这里的表情实现则是先建立数据库,在数据库中存储不同表情的Unicode码,然后再用Textview显示出来就展现的是现在的效果。

说一下代码具体的实现,对于表情键盘的实现:1.表情键盘实际上就是viewpager加一个指示器,在viewpager的每一页我们添加一个recycleview,然后通过GridLayoutManager显示出表情, 对于每一页的recycleview的adapter的数据源则是n*pagesize到(n+1)*pagesize 2.对于表情的删除键:我先从数据库中取出所有的表情对象,我设置一页显示21个表情字符,根据所有表情数目和每页显示数目算出总页数,这就是需要显示的删除键的个数。然后在list的21,42,63的位置添加一个新的emoji对象,在adapter中判断如果是新的emoji对象,我们就显示删除键。这样每一页最后一位就是删除键了,下面是具体的代码:

ChatUiHelper bindEmojiData(){
    //获取到所有表情
     mListEmoji = EmojiDao.getInstance().getEmojiBean();
     //表情底部滑动的viewpager和指示器所在布局
     LinearLayout  homeEmoji = (LinearLayout)mActivity.findViewById(R.id.home_emoji);
     //表情底部滑动的viewpager
     ViewPager vpEmoji = (ViewPager) mActivity.findViewById(R.id.vp_emoji);
     //指示器
     final IndicatorView indEmoji = (IndicatorView) mActivity.findViewById(R.id.ind_emoji);
     LayoutInflater inflater = LayoutInflater.from(mActivity);
     //每一页显示的数量
     int pageSize = EVERY_PAGE_SIZE;
     //创建删除表情
    EmojiBean mEmojiBean=new EmojiBean();
    mEmojiBean.setId(0);
    mEmojiBean.setUnicodeInt(000);
    //删除键的数量
    int deleteCount= (int) Math.ceil(mListEmoji.size() * 1.0/EVERY_PAGE_SIZE);//要显示的删除键的数量
    LogUtil.d(""+deleteCount);
    //添加删除键
    for (int i=1;i<deleteCount+1;i++){
        if(i==deleteCount){
            mListEmoji.add( mListEmoji.size(),mEmojiBean);
        }else{
            mListEmoji.add(i*EVERY_PAGE_SIZE-1,mEmojiBean);
        }
        LogUtil.d("添加次数"+i);
    }
    //计算出总页数
    int pageCount = (int) Math.ceil((mListEmoji.size()) * 1.0 / pageSize);//一共的页数
    LogUtil.d("总共的页数:"+pageCount);
    //每个页面创建一个recycleview
    List<View> viewList = new ArrayList<View>();
    for (int index = 0; index < pageCount; index++) {
        RecyclerView recyclerView = (RecyclerView) inflater.inflate(R.layout.item_emoji_vprecy, vpEmoji, false);
        recyclerView.setLayoutManager( new GridLayoutManager(mActivity, 7));
        EmojiAdapter entranceAdapter;
       if (index==pageCount-1){
           //如果最后一页传入adapter的数据
           List<EmojiBean> lastPageList=mListEmoji.subList(index*EVERY_PAGE_SIZE,mListEmoji.size());
           entranceAdapter = new EmojiAdapter( lastPageList  , index, EVERY_PAGE_SIZE);
       } else {
           //其他页数页传入adapter的数据
            entranceAdapter = new EmojiAdapter( mListEmoji.subList(index*EVERY_PAGE_SIZE, (index+1)*EVERY_PAGE_SIZE), index, EVERY_PAGE_SIZE);
        }
        //表情的点击事件
         entranceAdapter.setOnItemClickListener(new BaseQuickAdapter.OnItemClickListener() {
            @Override
            public void onItemClick(BaseQuickAdapter adapter, View view, int position) {
                 EmojiBean mEmojiBean=(EmojiBean)adapter.getData().get(position);
                if (mEmojiBean.getId()==0){
                    //如果是删除键
                    mEditText.dispatchKeyEvent(new KeyEvent(
                            KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
                }else{
                   mEditText.append(((EmojiBean)adapter.getData().get(position)).getUnicodeInt());
                }
          }
        });
        //为每个recycleview添加数据源
        recyclerView.setAdapter(entranceAdapter);
        viewList.add(recyclerView);
    }
    //设置viewpager和指示器
    EmojiVpAdapter adapter = new EmojiVpAdapter(viewList);
    vpEmoji.setAdapter(adapter);
    indEmoji.setIndicatorCount(vpEmoji.getAdapter().getCount());
    indEmoji.setCurrentIndicator(vpEmoji.getCurrentItem());
    vpEmoji.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
        @Override
        public void onPageSelected(int position) {
            indEmoji.setCurrentIndicator(position);
        }
    });
    return this;
}


录音功能


通过一个RecordButton,在这个button里执行一些显示触摸的逻辑。主要分为以下几部分:

1. 触摸事件:

主要对button的onTouchEvent事件进行处理,在监测到MotionEvent.ACTION_DOWN时弹出录音dialog 在ACTION_UP和ACTION_CANCEL时则停止录音

public boolean onTouchEvent(MotionEvent event) {
    int action = event.getAction();
    y = event.getY();
    if(mStateTV!=null && mStateIV!=null &&y<0){
        mStateTV.setText("松开手指,取消发送");
        mStateIV.setImageDrawable(getResources().getDrawable(R.drawable.ic_volume_cancel));
    }else if(mStateTV != null){
        mStateTV.setText("手指上滑,取消发送");
    }
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            setText("松开发送");
            initDialogAndStartRecord();
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            this.setText("按住录音");
             if(y>=0 && (System.currentTimeMillis() - startTime <= MAX_INTERVAL_TIME)){
                 finishRecord();
            }else if(y<0){  
                cancelRecord();
            }
            break;
    }

    return true;
}

2. dialog的显示

这部分主要涉及动画的显示和不同状态图片的替换。初始化Dialog的时候,则是为ImageViews设置ImageDrawable来开启动画,然后根据不同的操作,dialog中间显示不同的图片,这部分没什么难度

 mStateIV.setImageDrawable(getResources().getDrawable(R.drawable.anim_mic));
 anim = (AnimationDrawable) mStateIV.getDrawable();
 anim.start();
 -----------------------------------
 mStateIV.setImageDrawable(getResources().getDrawable(R.drawable.ic_volume_wraning));
 mStateTV.setText("录音时间太短");
 anim.stop();

3. 录音功能

录音通过MediaRecorder这个类,在Android中我们可以通过MediaRecorder来录制音频,分为下面几步:

1、创建MediaRecorder实例对象。

2、setAudioSource(int source)方法设置声音,里面的参数source设置为MediaRecorder.AudioSource.MIC,这个参数指定录音来源为主麦克风。

3、调用 setOutputFormat(int output_format) 设置在录制过程中产生的输出文件的格式

4、设置编码格式MediaRecord.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);//设置音频编码为amr_nb。

5、设置置录制音频文件的保存位置,通过调用MediaRecorder对象的setOutputFile(String path)方法,path传输出路径即可。

6、调用MediaRecorder的prepare()方法准备录制。

7、调用MediaRecorder对象的start()方法开始录制。

8、录制完成,调用MediaRecorder对象的stop()方法停止录制,并调用release()方法释放资源。

录完的文件我保存在data/data/包名/files目录下。

private void startRecording() {
    if (mRecorder != null) {
        mRecorder.reset();
    } else {
        mRecorder = new MediaRecorder();
    }
    mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
    mRecorder.setOutputFormat(MediaRecorder.OutputFormat.DEFAULT);
    mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);
    File file = new File(mFile);
    LogUtil.d("创建文件的路径:"+mFile);
     mRecorder.setOutputFile(mFile);
     try {
        mRecorder.prepare();
        mRecorder.start();
    }catch (Exception e){
        LogUtil.d("preparestart异常,重新开始录音:"+e.toString());
         e.printStackTrace();
        mRecorder.release();
        mRecorder = null ;
        startRecording();
    }
}

在recycleview中点击播放,我们通过MediaPlayer,同时并播放item的动画即可。当同时播放多个的时候根据在点击的时候通过判断变量是否为空来取消其他的播放。对于播放音频MediaPlayer的初始化与MediaRecorder的类似,具体的下面的代码展示了出来:

//item点击播放
mAdapter.setOnItemChildClickListener(new BaseQuickAdapter.OnItemChildClickListener() {
    @Override
    public void onItemChildClick(BaseQuickAdapter adapter, View view, int position) {
        if (ivAudio != null) {
            ivAudio.setBackgroundResource(R.mipmap.audio_animation_list_right_3);
            ivAudio = null;
            MediaManager.reset();
        }else{
             ivAudio = view.findViewById(R.id.ivAudio);
              MediaManager.reset();
              ivAudio.setBackgroundResource(R.drawable.audio_animation_right_list);
             AnimationDrawable  drawable = (AnimationDrawable) ivAudio.getBackground();
            drawable.start();
             MediaManager.playSound(ChatActivity.this,((AudioMsgBody)mAdapter.getData().get(position).getBody()).getLocalPath(), new MediaPlayer.OnCompletionListener() {
                @Override
                public void onCompletion(MediaPlayer mp) {
                    LogUtil.d("开始播放结束");
                    ivAudio.setBackgroundResource(R.mipmap.audio_animation_list_right_3);
                    MediaManager.release();
                 }
            });
        }
    }
});
----------------------------------------------------------------
//播放音频
public static void playSound(Context context ,String filePathString,
                             OnCompletionListener onCompletionListener)
 
{
    try {
        filepathstrings = filePathString ;
        mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
        mPlayer.setOnCompletionListener(onCompletionListener);
        mPlayer.setDataSource(filePathString);
        mPlayer.setVolume(90,90);
        mPlayer.setLooping(false);
        mPlayer.prepare();
        mPlayer.start();
        isStart = true;
    } catch (IllegalArgumentException e) {
        e.printStackTrace();
    } catch (SecurityException e) {
        e.printStackTrace();
    } catch (IllegalStateException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}


更多布局功能


这里比较简单,就是一个线性布局,然后对于底下布局不同模块的点击事件分别处理, 图片视频文件选择,我使用PictureSelector和MaterialFilePicker,然后在onactivity中回调后再发送消息,然后在recycleview根据不同消息的不同类型来显示出对用的布局即可。

代码里面的部分ui切图是从融云的sealtalk中移植过来的:

https://github.com/sealtalk/sealtalk-android

图片和视频选择我用的PictureSelector:

https://github.com/LuckSiege/PictureSelector

文件的选择我用的MaterialFilePicker:

https://github.com/nbsp-team/MaterialFilePicker

多布局显示的BaseRecyclerViewAdapterHelper

https://github.com/CymChad/BaseRecyclerViewAdapterHelper

最后附带上github地址,需要的话可以直接去下载:

https://github.com/huangruiLearn/HRLChatUi


 
郭霖 更多文章 没吃透Netty底层通讯原理,还能算的上Java老司机? 分析并优化 Android 应用内存 Android中的Lambda表达式 Android ClassLoader机制 Android仿美团拖拽效果采坑记
猜您喜欢 一文读懂Java 11的ZGC为何如此高效 ​​Appium + Celery实现网页兼容性测试 干货∣Angular 单元测试编写入门指南 之 孤立的单元测试 1000道Python题库系列分享八(29道) API 调用次数限制实现