今天,我终于更更更更博了。 接着上一篇聊天界面从0到1的实现 (一), 今天来聊一聊 聊天页面的底部横条。ios
原文地址 : 聊天界面从0到1的实现 (二)git
demo 地址: JPChatBottomBargithub
JPChatBottomBar
与如今主流的聊天页面的底部横条页面类似。 相似于微信中的:正则表达式
之因此先从这个横条来折腾,我的想法:从功能上来讲,这个模块能够从Im中独立出来,但又能够屏蔽掉因通讯部分第三方服务选择的不一样而带来的差别,服务于聊天的整个框架。之后若是框架发生变化,这一模块受到的影响也会是最小的。算法
JPChatBottomBar
虽然并不整个框架的核心,但却也提供着基础的服务功能——编辑消息。 本身在模仿实现一个横条的过程当中,也遇到了一些麻烦。windows
碍于篇幅,文章中主要用于记叙一些比较复杂的实现抑或是一些细节的问题,简单的逻辑判断实现就不出如今这里了。数组
demo的地址放在这里:JPChatBottomBar--github地址bash
结合前面的图:能够初步总结出 JPChatBottomBar
应该实现的功能,以下:服务器
这里,咱们经过 一个代理 JPChatBottomBarDelegate
来将用户的操做(文本消息、语音消息等等)向外传递,即向聊天框架中的其余模块提供服务。微信
先来对我所使用到的类来进行说明:
JPChatBottomBar
: 整个横条preview
文件中的类用于实现表情包的预览效果imageResource
文件夹中存放了此demo中所用到的图片资源JPEmojiManager
:这个类用于读取全部的表情包资源JPPlayerHelper
:这个类用于实现录音和播音的效果JPAttributedStringHelper
:实现表情包子符和表情包图片的互转model
,JPEmojiModel
用于绑定单独一个表情包,JPEmojiGroupModel
用于绑定一整组的表情包。category
中存放了一些经常使用的工具类下面,让我就上面所罗列的应该实现的功能,来说讲各功能我是如何实现或者是在实现的过程当中我所遇到的问题。
效果能够到个人博客或者下载demo中查看。
能够看到,在键盘弹出的过程当中,controller.view
要向上滑动,避免弹出的键盘遮挡住了用户的聊天页面。这也是很是基础的功能。
可是这里有个细节的地方:
这一块一开始我是想经过写死系统键盘的高度,经过监听textView.inputView
新旧值的变化(kvo实现参考demo里面): 从demo中的代码能够看出,在等到chatBottomBar
到达了该到的位置以后,再调用-textView reloadInputView
来唤醒键盘。如此就能够达到键盘从下弹出而且不会有小部分覆盖的效果。
可是发现,系统键盘的高度不是都同样的,例如汉语拼音的九宫格键盘要比26键高,而日语九宫格键盘要比26键低,因此不是很全。
因而最后仍是采用了监听键盘弹出的通知来实现 :
而微信在这一块的实现就没有这种覆盖效果,微信等到viewController.view
来到该到的位置以后,再让键盘从下面弹出。
监听键盘弹出的通知:
// JPChatBottomBar.m // 监听textView.inputView属性新旧值的变化 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillChangeRect:) name:UIKeyboardWillChangeFrameNotification object:nil]; - (void)keyboardWillChangeRect:(NSNotification *)noti { NSValue * aValue = noti.userInfo[UIKeyboardFrameBeginUserInfoKey]; self.oldRect = [aValue CGRectValue]; NSValue * newValue = noti.userInfo[UIKeyboardFrameEndUserInfoKey]; self.newRect = [newValue CGRectValue]; [UIView animateWithDuration:0.3 animations:^{ if(self.superview.y == 0) { self.superview.y -= self.newRect.size.height; }else { self.superview.y -=(self.newRect.size.height - self.oldRect.size.height); } } completion:^(BOOL finished) { }]; } 复制代码
路过的读者若是有更好的改进方法,能在切换键盘的时候避免这种覆盖,欢迎提出,我也是正在学习iOS 的小白😂。谢谢🙏🙏🙏
关于键盘的切换剩下的就是 根据用户的点击切换键盘的状态(变化相应的视图)。 这一部分就先到此☺️👌🏾。
在参考了别人的Demo(iOS仿微信录音控件Demo)以后,我也实现了一个。
先给出本身所使用的类的介绍:
JPPlayerHelper
: 实现录音和播音的功能。JPAudioView
: 展现录音的状态让我归纳一下 实现的大概步骤。 首先两个类之间并非相互依赖的,二者在JPChatBottomBar
中产生耦合。
我在JPAudioView中利用了下面着三个方法来让audioView对用户手势变化进行判断(开始点击、向上向下滑动、手指离开),并做出相应的处理,代码以下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event ;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
复制代码
在上面这三个方面中解决自身的UI问题,再经过block从外部来实现录音以及根据语音强度更新UI的效果:
/// AudioView block块的实现(JPChatBottomBar.m) - (JPAudioView *)audioView { if(!_audioView) { JPAudioView * tmpView = [[JPAudioView alloc] initWithFrame:CGRectMake(self.textView.x, self.textView.y, self.textView.width, _btnWH )]; [self addSubview:tmpView]; _audioView = tmpView; // 实现audioView的方法 __weak typeof (self) wSelf = self; _audioView.pressBegin = ^{ [wSelf.audioView setAudioingImage:[UIImage imageNamed:@"zhengzaiyuyin_1"] text:@"松开手指,上滑取消"]; // 开始录音 [wSelf.recoder jp_recorderStart]; }; _audioView.pressingUp = ^{ [wSelf.audioView setAudioingImage:[UIImage imageNamed:@"songkai"] text:@"松开手指,取消发送"]; }; _audioView.pressingDown = ^{ NSString * imgStr = [NSString stringWithFormat:@"zhengzaiyuyin_%d",imageIndex]; [wSelf.audioView setAudioingImage:[UIImage imageNamed:imgStr] text:@"松开发送,上滑取消"]; }; _audioView.pressEnd = ^{ [wSelf.audioView setAudioViewHidden]; [wSelf.recoder jp_recorderStop]; NSString * filePath = [wSelf.recoder getfilePath]; NSData * audioData = [NSData dataWithContentsOfFile:filePath]; /// 将语音消息data经过代理向外传递 if(wSelf.agent && [wSelf.agent respondsToSelector:@selector(msgEditAgentAudio:)]){ [wSelf.agent msgEditAgentAudio:audioData]; } if(wSelf.msgEditAgentAudioBlock){ wSelf.msgEditAgentAudioBlock(audioData); } }; } return _audioView; } 复制代码
其次就是这一块比较关键的点: 根据语音的强度来刷新audioView的UI
效果能够到博客或者下载demo查看。
咱们首先获取语音强度平均值的方法主要经过:
/// 更新测量值 - (void)updateMeters; /* call to refresh meter values */ /// 获取峰值 - (float)peakPowerForChannel:(NSUInteger)channelNumber; /// 获取平均值 - (float)averagePowerForChannel:(NSUInteger)channelNumber; 复制代码
在获取语音强度的时候,须要先updateMeters
更新一下测量值。 而后咱们能够经过测量值 、 峰峰值以后,根据必定的算法来计算出此时声音的相对大小强度。这里,算法很垃圾的我简单的设计了一个:
// JPPlayerHelper.m - (CGFloat)audioPower { [self.recorder updateMeters]; // 更新测量值 float power = [self.recorder averagePowerForChannel:0]; // 平均值 取得第一个通道的音频,注意音频的强度为[-160,0],0最大 // float powerMax = [self.recorder peakPowerForChannel:0]; // CGFloat progress = (1.0/160.0) * (power + 160); power = power + 160 - 50; int dB = 0; if (power < 0.f) { dB = 0; } else if (power < 40.f) { dB = (int)(power * 0.875); } else if (power < 100.f) { dB = (int)(power - 15); } else if (power < 110.f) { dB = (int)(power * 2.5 - 165); } else { dB = 110; } return dB; } 复制代码
关于这一块的算法,若是各位读者有更好的方法,欢迎提出,我也是个渴望知识的小白。
经过上面的方法能够获取相应的声音的分贝强度,咱们外部能够作一些处理:例如我作了,当新测量值比旧的测量值大必定值的时候,就作提升分贝的UI刷新操做,低的时候就作下降分贝UI的操做,具体能够看下面的代码:
// JPChatbottomBar.m - (void) jpHelperRecorderStuffWhenRecordWithAudioPower:(CGFloat)power{ NSLog(@"%f",power); NSString * newPowerStr =[NSString stringWithFormat:@"%f",[self.helper audioPower]]; if([newPowerStr floatValue] > [self.audioPowerStr floatValue]) { if(imageIndex == 6){ return; } imageIndex ++; }else { if(imageIndex == 1){ return; } imageIndex --; } if(self.audioView.state == JPPressingStateUp) { self.audioView.pressingDown(); } self.audioPowerStr = newPowerStr;; } 复制代码
其次,我在JPPlayerHepler
加了一个计时器来触发反复调用上面的代理方法(- (void) jpHelperRecorderStuffWhenRecordWithAudioPower:(CGFloat)power
) ,让其能够进行UI的刷新,由于若是不加计时器,咱们是没有事件去触发audioView
UI刷新的操做,计时器相关方法以下:
// JPPlayerHelper.m -(NSTimer *)timer{ if (!_timer) { _timer=[NSTimer scheduledTimerWithTimeInterval:0.35 target:self selector:@selector(doOutsideStuff) userInfo:nil repeats:YES]; } return _timer; } - (void)doOutsideStuff { if(self.delegate && [self.delegate respondsToSelector:@selector(jpHelperRecorderStuffWhenRecordWithAudioPower:)]){ [self.delegate jpHelperRecorderStuffWhenRecordWithAudioPower:[self audioPower]]; } } 复制代码
完成录音以后,最终咱们的语音数据经过JPChatBottomBarDelegate
的代理方法向外提供。
关于获取语音强度那一块的算法并非最优,我以为个人算法也是比较笨拙存在缺点(对用户语音强度的变化不敏感)。若是路过的读者有什么不错的建议,欢迎提出补充,我也会采纳,谢谢🙏🙏🙏。
JPChatBottomBar
里面的‘更多’键盘与微信的相似。
开发者在使用的时候若是想要键入不一样的功能实现,只要在/ImageResource/JPMoreBundle.bundle
的JPMorePackageList.plist
文件中添加相应的item
内部也已经作好了适配的效果,不过当item数量超过8个时候,没有完成像微信的那种分页效果,后期我会继续完善。
当用户点击了上面的某个item以后,咱们就将事件经过JPChatBottomBarDelegate
向外面传递,开发者能够再最外层作处理,根据点击哪一个item响应相应的方法功能,相似以下代码:
// ViewController.m NSString * kJPDictKeyImageStrKey = @"imageStr"; - (void)msgEditAgentClickMoreIVItem:(NSDictionary *)dict { NSString * judgeStr = dict[kJPDictKeyImageStrKey]; if([judgeStr isEqualToString:@"photo"]){ NSLog(@"点击了图册"); }else if([judgeStr isEqualToString:@"camera"]){ NSLog(@"点击了摄像头"); }else if([judgeStr isEqualToString:@"file"]) { NSLog(@"点击了文件"); }else if([judgeStr isEqualToString:@"location"]) { NSLog(@"点击了位置"); } } 复制代码
一开始没有想着将用户点击哪一个item暴露在外面,但后来想了开发者面临的业务多种多样,为了更好的扩展,简化JPChatBottomBar
的结构,就将这部分也经过代理写出来。
我花了比较多的时间在这一部分上面,以前没有真正的作嵌入表情包的方法,只是经过调用原生的表情来实现表情的编辑
这一部分主要思考 当用户点击表情包的时候咱们要作哪些处理。 先讲咱们的问题化简一下。
观察微信,表情包主要分两大种,一种是能够嵌入文本框的表情,而另外一种是当用户点击了该表情以后直接就发送给聊天对象,下面咱们称这两种表情分别为SmallEmoji(前者)和LargeEmoji(后者)。
后者的实现方式能够经过每一层间代理将其暴露在外。
// JPChatBottomBar.h
/**
* 用户点击了键盘的表情包按钮
* @param bigEmojiData : 大表情包的data
*/
- (void)msgEditAgentSendBigEmoji:(NSData *)bigEmojiData;
复制代码
关于“点击SmallEmoji嵌入文本”,我放后谈谈。
这里我经过JPEmojiManager
将表情包从/ImageResource/JPEmojiBundle.bundle
中加载出来,为一个表情包在JPEmojiPackageList.plist
中都有对应的item进行绑定,所以,若是后期咱们有新的表情包,只要把图片存进去,而且在plist文件中增长新的item便可以,代码实现用户动态添加表情包的方式也是同样的。
而为了不重复地读取文件,我将JPEmojiManager
写成了单例。
// JPEmojiManager.h /** * @return 获取全部的表情组 */ - (NSArray <JPEmojiGroupModel *> *)getEmogiGroupArr; /** * 根据位置获取相应的模型数组 * @param group : 选择了哪一组表情 * @param page : 页码 * @return 根据前面两个参数从全部数据中根据对应的位置和大小取出表情模型(<= 20个) */ - (NSArray <JPEmojiModel *> *)getEmojiArrGroup:(NSInteger)group page:(NSInteger)page; 复制代码
这里能够看到两个类
JPEmojiModel
用于绑定单独一个表情包,JPEmojiGroupModel
用于绑定一整组的表情包JPEmojiManager
中的这两个方法更可能是服务于分页表情包的效果(下面我将要谈到)
在看过github上面别人表情包demo以后,有一些并无实现滑动切换表情包组,因而本身实现了一个,效果能够到博客或者demo查看。
分也效果的实现方式:经过三个view去复用,在ScrollView
中去轮流展现。
// JPEmojiInputView.m #pragma mark 三个view复用 @property (strong, nonatomic) JPInputPageView * leftPV; @property (strong, nonatomic) JPInputPageView * currentPV; @property (strong, nonatomic) JPInputPageView * rightPV; 复制代码
经过前面JPEmojiManager
中取出对应页数的表情包以后,而后调用下面的方法讲每一页的表情包传入每个分页
// JPInputPageView.h /** * 赋予新的数组,从新刷新数据源 * @param emojiArr : 一页的表情(做为内置CollectionView的数据 */ - (void)setEmojiArr:(NSArray <JPEmojiModel *> *)emojiArr isShowLargeImage:(BOOL)value; 复制代码
先来说讲三个分页实现展现全部表情的效果:
self.currentPV
。leftPv
移动到了最右边,同时去除该页的表情包,作好展现的准备。完成这一步以后,就是更换杯子中的水的问题了,将三个复用view的相互赋值:// JPEmojiInputView.m
JPInputPageView * tmpView ;
tmpView = self.leftPV;
self.leftPV = self.currentPV;
self.currentPV = self.rightPV;
self.rightPV = tmpView;
复制代码
我经过下面的图片来展现这一块底层的实现,可能能够方便你们理解:
当用户手指向右滑展现上一页的时候,底部实现的方式也是相似,以此类推。 更多细节(如何计算当前页对应哪一组表情包的哪一页等)能够参考我写在JPEmojiInputView.m
里的- (void)scrollViewDidScroll:(UIScrollView *)scrollView
。
iOS 中textView和textField能够自动识别系统原生的表情:
而针对咱们开发者另外添加的小表情,textView和textField不能直接识别。
这里能够参考了几个主流app的实现方式,
而要注意的是,当咱们将‘图文混编’的文本消息发送出去通过咱们服务器的时候,通常是不对字符串中的图片信息进行解析,所以,底层依旧是向服务器传递纯文本消息,而对里面的图片信息作了处理转换成了表情包的描述文本,下面我用一张图片解释这个问题:
能够看到咱们本地须要对这些“图文混编”的文本转换成纯文本才能发送至服务端。这里主要经过两个系统的类来进行表情包和其描述文本的匹配。
NSTextAttachment
: 文本中的‘插件’,咱们经过这个类来插入图片。NSRegularExpression
: 使用正则表达式来匹配字符串中的表情包描述文本。关于正则表达式,这里有一篇比较全的语法:正则表达式。
这里个人正则匹配的字符以下:
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\[.+?\\]" options:0 error:NULL];
在匹配出每个表情包描述文本以后,会生成一个数组存放这些匹配结果(描述文本、图片资源、描述文本在原字符串的位置),而后遍历这个数组,将这些描述文本经过插入图片插件textAttachment
来替换,这里注意,每次替换,后面尚未被替换的表情包文本的range就会发送变化,咱们须要递减他们原来的位置range.location
。 具体实现的方式可参考下面代码:
// JPAttributedStringHelper.m - (NSAttributedString *)getTextViewArrtibuteFromStr:(NSString *)str { if(str.length == 0) { return nil; } NSMutableAttributedString * attStr = [[NSMutableAttributedString alloc] initWithString:str attributes:[JPAttributedStringConfig getAttDict]]; NSMutableParagraphStyle * paraStyle = [[NSMutableParagraphStyle alloc] init]; paraStyle.lineSpacing = 5; [attStr addAttribute:NSParagraphStyleAttributeName value:paraStyle range:NSMakeRange(0, attStr.length)]; NSArray<JPEmojiMatchingResult *> * emojiStrArr = [self analysisStrWithStr:str]; if(emojiStrArr && emojiStrArr.count != 0) { NSInteger offset = 0; // 表情包文本的偏移量 for(JPEmojiMatchingResult * result in emojiStrArr ){ if(result.emojiImage ){ // 表情的特殊字符 NSMutableAttributedString * emojiAttStr = [[NSMutableAttributedString alloc] initWithAttributedString:[NSAttributedString attributedStringWithAttachment:result.textAttachment]]; if(!emojiAttStr) { continue; } NSRange actualRange = NSMakeRange(result.range.location - offset, result.range.length); [attStr replaceCharactersInRange:actualRange withAttributedString:emojiAttStr]; // 一个表情占一个长度 offset += (result.range.length-1); } } return attStr; }else { return [[NSAttributedString alloc] initWithString:str attributes:[JPAttributedStringConfig getAttDict]];; } } 复制代码
实现的效果能够到博客或者下载demo查看。
而在按下删除键,要实现删除表情包描述文本,咱们须要判断textView.selectedRange
所在的位置是否为表情描述文本,代码参考以下:
// 点击了文本消息和或者表情包键盘的删除按钮 - (void)clickDeleteBtnInputView:(JPEmojiInputView *)inputView { NSString * souceText = [self.textView.text substringToIndex:self.textView.selectedRange.location]; if(souceText.length == 0) { return; } NSRange range = self.textView.selectedRange; if(range.location == NSNotFound) { range.location = self.textView.text.length; } if(range.length > 0) { [self.textView deleteBackward]; return; }else { // 正则表达式匹配要替换的文字的范围 if([souceText hasSuffix:@"]"]){ // 表示该选取字段最后一个是表情包 if([[souceText substringWithRange:NSMakeRange(souceText.length-2, 1)] isEqualToString:@"]"]) { // 表示这只是一个单独的字符@"]" [self.textView deleteBackward]; return; } // 正则表达式 NSString * pattern = @"\\[[a-zA-Z0-9\\u4e00-\\u9fa5]+\\]"; NSError *error = nil; NSRegularExpression * re = [NSRegularExpression regularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive error:&error];if (!re) {NSLog(@"%@", [error localizedDescription]);} NSArray *resultArray = [re matchesInString:souceText options:0 range:NSMakeRange(0, souceText.length)]; if(resultArray.count != 0) { /// 表情最后一段存在表情包字符串 NSTextCheckingResult *checkingResult = resultArray.lastObject; NSString * resultStr = [souceText substringWithRange:NSMakeRange(0, souceText.length - checkingResult.range.length)]; self.textView.text = [self.textView.text stringByReplacingCharactersInRange:NSMakeRange(0, souceText.length) withString:resultStr]; self.textView.selectedRange = NSMakeRange(resultStr.length , 0); }else { [self.textView deleteBackward]; } }else { // 表示最后一个不是表情包 [self.textView deleteBackward]; } } // textView自适应 [self textViewDidChange:self.textView]; } 复制代码
实现效果你们能够看看demo🤓。
看到这里,我已经写了近5k字了😂
表情包的预览效果分为
前者的底部视图是一张已经画好的图片,
后者的底部视图我经过重绘机制(QuartzCore
框架而且重写-drawRect:
方法)来进行描边以及视图颜色的填充(关于重绘制:iOS开发之drawRect的做用和调用机制),代码:
// JPGIfPreview.m
- (void)drawRect:(CGRect)rect {
CGContextRef context = UIGraphicsGetCurrentContext();
//1.添加绘图路径
CGContextMoveToPoint(context,0,_filletRadius);
CGContextAddLineToPoint(context, 0, _squareHeight - _filletRadius);
CGContextAddQuadCurveToPoint(context, 0, _squareHeight ,_filletRadius, _squareHeight);
CGContextAddLineToPoint(context, (_squareWidht - _triangleWdith )/2,_squareHeight);
CGContextAddLineToPoint(context,BaseWidth /2 , BaseHeight);
CGContextAddLineToPoint(context, (_squareWidht + _triangleWdith )/2,_squareHeight);
CGContextAddLineToPoint(context, _squareWidht - _filletRadius,_squareHeight);
CGContextAddQuadCurveToPoint(context, _squareWidht, _squareHeight ,_squareWidht, _squareHeight - _filletRadius);
CGContextAddLineToPoint(context, _squareWidht ,_filletRadius);
CGContextAddQuadCurveToPoint(context, _squareWidht, 0 ,_squareWidht - _filletRadius, 0);
CGContextAddLineToPoint(context,_filletRadius ,0);
CGContextAddQuadCurveToPoint(context, 0, 0 ,0, _filletRadius);
//2.设置颜色属性
CGFloat backColor[4] = {1,1,1, 0.86};
CGFloat layerColor[4] = {0.9,0.9,0.9,0};
//3.设置描边颜色,填充颜色
CGContextSetFillColor(context, backColor);
CGContextSetStrokeColor(context, layerColor);
//4.绘图
CGContextDrawPath(context, kCGPathFillStroke);
}
复制代码
在完成了布局以后,接下来就是要将咱们的预览视图添加到界面上。
在collectionView上面添加长按收拾longPress
,监听手势的状态,而且计算手势所在位置对应的cell,对其内容进行预览效果的展现。
这里我选择了[UIApplication sharedApplication].windows.lastobject
做为superView,即emojiInputView
所在的window。
这里有个要注意的点,上面的windowCGPointZero
是从手机左上角开始算起,所以换算坐标(cell是每个表情包,补充一下我是用collectionView来展现每个分页上的表情包)时,我将cell.frame转换成了在window上的frame:
CGRect rect = [[UIApplication sharedApplication].windows.lastObject convertRect:cell.frame fromView:self.collectionView];
复制代码
坐标换算完成以后,剩下的就是添加上去。
gif的播放效果,我也是第一次接触,这里看到一篇不错的文章:iOS-Gif图片展现N种方式(原生+第三方),里面有介绍原生和第三方的实现。 考虑到减小项目的依赖库,这里我就采用了里面原生方式的代码,具体能够点开连接看内部代码,这里不作过多叙述了(5.3k字了😂😂😂)。
这些文章对我提供了必定的帮助,也但愿对你有用
WWDC 2017 - 优化输入体验的关键:keyboard技巧全介绍
在完成JPChatBottomBar
以后,整个框架访问用户编辑的消息或者是用户的其余操做均可以经过JPChatBottomBarDelegate
获取。
JPChatBottomBar
部分就先到这里,完成这部份内容,从demo到文章落笔完成之间,遇到了挺多问题😂。 例如‘切换键盘覆盖的问题’那一块本身就用了两种方法来实现,亦或者是‘表情包组别的切换’,本身都花了有些时间。
针对个人文章和demo中技术的实现,若是读者有更好的方法,欢迎提出🙏,谢谢🙏。
也但愿个人文章可以给你带来帮助。
若是对你有所帮助,请给我个Star吧✨。谢谢!