效果展示
实现的功能
- 支持将表情转换成字符串, 同时也可以将带有表情的字符串转换成表情图片
- 可自定义表情包, 可自定义每页表情的行数和列数, 自定义表情包需要两步
1: 添加表情包到EmojiPackage.bundle目录下
2: 按照demo中的格式修改EmojiPackageList.plist文件- 支持长按预览, 大表情支持gif, 删除表情
- YBEmojiTextView实现了拷贝粘贴剪切功能, 所以如果需要支持该功能, 输入框需要继承自该类
- 支持修改部分外观, 具体请查看YBEmojiConfig.h文件
- 适配iPhone X
思路
数据的处理
<1> 表情数据使用plist文件存储, 根目录是一个数组, 每一个对象是一个表情包字典, 每一个表情包中有表情包的属性, 包括
- cover_pic: 表情包封面图片, 要放在对应的表情目录下
- folderName: 表情图片对应的文件夹名字, 封面图片就放在该目录下
- isLargeEmoji: 是否是大表情
- title: 标题, 暂时没用到, 可用于以后扩展
- emojis: 表情数组, 每一个对象是一个字典, 里边有两个字段
desc: 表情对应的字符串, 用来在长按或者大表情下方显示, 文字提取自[/
和]
中间的字符串, 例如: 对应的字符串就是[/哈哈]
格式一定要对
image: 表情图片的名字, 用来找到对应的表情图片
<2> 每一个表情包图片都在一个文件夹中, 文件夹存在EmojiPackage.bundle目录下, EmojiPackageList.plist文件也在改目录下
<3> 根据plist文件创建对应的数据模型
<4> 对于数据我们这里使用单例模式, 同时提供一个表情字符和表情图片相互转换的接口, 用于外部文字和字符串进行互转
真正的键盘
其实系统提供好接口给我们自定义键盘, 如下
// Presented when object becomes first responder. If set to nil, reverts to following responder chain. If
// set while first responder, will not take effect until reloadInputViews is called.
@property (nullable, readwrite, strong) UIView *inputView;
@property (nullable, readwrite, strong) UIView *inputAccessoryView;
UITextView和UITextField都有这两个属性, 所以我们只需要搭建好UI, 修改inputView
属性, 然后调用reloadInputViews
就可以了, 这样体验就会和系统的一样键盘一样了, 如果需要按键音的话需要我们自定义的inputView
类遵循UIInputViewAudioFeedback
协议, 同时实现 enableInputClicksWhenVisible
方法并返回YES, 这样就可以在点击表情的时候调用[[UIDevice currentDevice] playInputClick]
方法发出按键音了, Demo中没有实现, 需要的话可以自己添加
接下来就是搭建UI了
分析: 通常的表情键盘都分为三部分, 上方为可以左右滑动的表情视图YBEmojiContentView
, 中间为页码指示器UIPageControl
, 最下方为表情包按钮YBEmojiTabbar
, 用来切换表情包, 接下来创建一个继承自 UIView 的 YBEmojiInputView 作为inputView
, 然后将上边三部分分别添加到YBEmojiInputView上, 然后实现具体的每一部分
YBEmojiInputView主要用来调节三个模块的关系以及提供一些代理给外部使用, 外部只需要实现UITextView的代理方法, 对文字做对应的处理就可以了, 代码基本上是固定的, 这样输入框部分就不受限制了, 可以随意定制自己的UI, 满足不同用户对UI的需求
@protocol YBEmojiInputViewDelegate
// 点击表情
- (void)inputView:(YBEmojiInputView *)inputView clickedEmojiWith:(YBEmojiItemModel *)emoji;
// 点击大表情
- (void)inputView:(YBEmojiInputView *)inputView clickedBigEmojiWith:(YBEmojiItemModel *)emoji;
// 点击删除
- (void)inputView:(YBEmojiInputView *)inputView clickedDeleteWith:(UIButton *)button;
// 点击发送
- (void)inputView:(YBEmojiInputView *)inputView clickedSendWith:(UIButton *)button;
@end
指示器和底部栏比较简单, 我们这里主要来说YBEmojiContentView
1. YBEmojiContentView
既然可以左右滑动, 那就不用考虑了, 先建一个UIScrollView, 至于contentSize
是计算出来的, 具体怎么算用脚指头想都知道
然后考虑到内存, 我们不能一下创建那么按钮上去, 所以我这边的做法是创建一个YBEmojiPageView作为一页, 然后创建三页, 然后在scrollView滑动的时候来交换三个YBEmojiPageView的位置以及frame
同时更新表情图片即可
为了避免重复更新图片消耗性能, 所以这边创建一个pageFlag
来记录当前页码, 当前页已经更新了就不在更新了
核心代码
// 更新三个pageView位置
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (_delegate && [_delegate respondsToSelector:@selector(contentView:didScrollViewToIndex:)]) {
NSInteger pageIndex = roundf(self.scrollView.contentOffset.x / self.bounds.size.width);
[_delegate contentView:self didScrollViewToIndex:pageIndex];
}
// 当表情也小于等于两页的时候就不需要更新了
if (self.totalPage <= 2) { return; }
[self updatePagesView];
}
// 更新pageView位置以及内容向右滑动后, 将最右边的pageView放在最左边, 重新赋值, 向左滑动也一样>
- (void)updatePagesView {
CGFloat pageOffset = self.scrollView.contentOffset.x / self.bounds.size.width;
// 页码四舍五入取整
NSInteger page = roundf(pageOffset);
if (page != self.pageFlag) {
YBEmojiPageView *aView = nil;
if (pageOffset > page) { // 向右滑动
// 更新表情
[self.rightPageView configEmojisButtonWith:self.groupModel pageIndex:page - 1];
// 交换位置
aView = self.rightPageView;
self.rightPageView = self.centerPageView;
self.centerPageView = self.leftPageView;
self.leftPageView = aView;
}else { // 向左滑动
// 更新表情
[self.leftPageView configEmojisButtonWith:self.groupModel pageIndex:page + 1];
// 交换位置
aView = self.leftPageView;
self.leftPageView = self.centerPageView;
self.centerPageView = self.rightPageView;
self.rightPageView = aView;
}
// 更新pageViews的frame
[self layoutPageViewsWith:page];
}
self.pageFlag = page;
}
// 根据页码更新pageView的frame
- (void)layoutPageViewsWith:(NSInteger)page {
self.leftPageView.frame = CGRectMake((page - 1) * self.bounds.size.width, 0, self.bounds.size.width, self.bounds.size.height);
self.centerPageView.frame = CGRectMake(page * self.bounds.size.width, 0, self.bounds.size.width, self.bounds.size.height);
self.rightPageView.frame = CGRectMake((page + 1) * self.bounds.size.width, 0, self.bounds.size.width, self.bounds.size.height);
}
YBEmojiPageView作用是用来显示表情, 主要功能如下:
- 计算一页显示最多表情数量, 然后将表情控件循环创建出来
- (instancetype)initWithConfig:(YBEmojiConfig *)config {
if (self = [super init]) {
self.config = config;
self.backgroundColor = config.pageViewBackgroundColor;
self.emojiItems = [NSMutableArray array];
// 初始化, 循环创建表情按钮(每页的按钮数量 = 行数 x 列数, 最后一个为删除按钮, 所以表情按钮数量要 -1<大表情就不需要-1了>)
NSInteger btnCount = MAX(config.smallEmojiLineCount*config.smallEmojiColumnCount-1, config.largeEmojiLineCount*config.largeEmojiColumnCount);
for (NSUInteger i = 0; i < btnCount; i++) {
YBEmojiItemView *emojiItem = [[YBEmojiItemView alloc] init];
[emojiItem addTarget:self action:@selector(clickedEmojiItemView:)];
[_emojiItems addObject:emojiItem];
[self addSubview:emojiItem];
}
// 删除按钮
self.deleteBtn = [UIButton buttonWithType:UIButtonTypeCustom];
[self.deleteBtn setImage:self.config.pageViewDeleteButtonImage forState:UIControlStateNormal];
[self.deleteBtn addTarget:self action:@selector(clickedDeleteButtonAction:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:self.deleteBtn];
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressPageView:)];
longPress.minimumPressDuration = 0.25;
[self addGestureRecognizer:longPress];
}
return self;
}
- 提供给外界的接口用来更新该页的表情
- (void)configEmojisButtonWith:(YBEmojiGroupModel *)groupModel pageIndex:(NSInteger)pageIndex {
self.isLargeEmoji = groupModel.isLargeEmoji;
// 重置表情按钮图片
NSArray *emojis = [self emojiItemWith:groupModel atPageIndex:pageIndex];
self.hidden = emojis.count == 0;
for (int i = 0; i < self.emojiItems.count; i ++) {
YBEmojiItemView *emojiItemView = self.emojiItems[i];
// 设置表情图片 当表情数量不满一整页的时候, 其余按钮图片置空
YBEmojiItemModel *emoji = i < emojis.count ? emojis[i] : nil;
emojiItemView.isShowTitle = groupModel.isLargeEmoji;
[emojiItemView setEmoji:emoji];
}
[self setNeedsLayout];
}
// 获取表情包对应页码的模型数组
- (NSArray *)emojiItemWith:(YBEmojiGroupModel *)groupModel atPageIndex:(NSInteger )pageIndex {
if (!groupModel || !groupModel.emojis.count) {
return nil;
}
NSInteger columnCount = self.isLargeEmoji ? self.config.largeEmojiColumnCount : self.config.smallEmojiColumnCount;
NSInteger lineCount = self.isLargeEmoji ? self.config.largeEmojiLineCount : self.config.smallEmojiLineCount;
NSInteger emojiCOuntOfPage = self.isLargeEmoji ? columnCount * lineCount : columnCount * lineCount - 1;
NSUInteger totalPage = (groupModel.emojis.count / emojiCOuntOfPage) + 1;
if (pageIndex >= totalPage || pageIndex < 0) {
return nil;
}
BOOL isLastPage = (pageIndex == totalPage - 1 ? YES : NO);
// 截取的初始位置
NSUInteger beginIndex = pageIndex * emojiCOuntOfPage;
// 截取长度
NSUInteger length = isLastPage ? (groupModel.emojis.count - pageIndex * emojiCOuntOfPage) : emojiCOuntOfPage;
NSArray *emojis = [groupModel.emojis subarrayWithRange:NSMakeRange(beginIndex, length)];
return emojis;
}
- 根据是否为大表情更新表情控件frame
- (void)layoutSubviews {
[super layoutSubviews];
NSInteger columnCount = self.isLargeEmoji ? self.config.largeEmojiColumnCount : self.config.smallEmojiColumnCount;
NSInteger lineCount = self.isLargeEmoji ? self.config.largeEmojiLineCount : self.config.smallEmojiLineCount;
// 计算表情按钮宽度
CGFloat width = (self.bounds.size.width - self.config.pageViewEdgeInsets.left - self.config.pageViewEdgeInsets.right - ((columnCount - 1) * self.config.pageViewMinColumnSpace)) / (CGFloat)columnCount;
// 计算表情按钮高度
CGFloat heigh = (self.bounds.size.height - self.config.pageViewEdgeInsets.top - self.config.pageViewEdgeInsets.bottom - ((lineCount - 1) * self.config.pageViewMinLineSpace)) / (CGFloat)lineCount;
// 表情按钮为正方形, 所以取一个最小值作为宽高, 那么久需要重新计算行间距列间距
CGFloat minSize = MIN(width, heigh);
// 计算行间距
CGFloat lineSpace = (self.bounds.size.height - self.config.pageViewEdgeInsets.top - self.config.pageViewEdgeInsets.bottom - minSize * lineCount) / (CGFloat)(lineCount + 1);
// 计算列间距
CGFloat columnSpace = (self.bounds.size.width - self.config.pageViewEdgeInsets.left - self.config.pageViewEdgeInsets.right - minSize * columnCount) / (CGFloat)(columnCount + 1);
// 遍历设置表情按钮的frame
for (int i = 0; i < self.emojiItems.count; i ++) {
NSInteger line = i / columnCount; // 当前行数
NSInteger column = i % columnCount; // 当前列数
// 表情按钮的最小 x 和最小 y
CGFloat minX = self.config.pageViewEdgeInsets.left + column * minSize + ((column + 1) * columnSpace);
CGFloat minY = self.config.pageViewEdgeInsets.top + (line * minSize) + ((line + 1) * lineSpace);
CGRect frame = CGRectMake(minX, minY, minSize, minSize);
self.emojiItems[i].frame = frame;
}
// 删除按钮
self.deleteBtn.frame = CGRectMake(self.bounds.size.width - self.config.pageViewEdgeInsets.right - minSize, self.bounds.size.height - self.config.pageViewEdgeInsets.bottom - minSize - lineSpace, minSize, minSize);
self.deleteBtn.hidden = self.isLargeEmoji;
}
- 长按手势, 显示预览表情
- (void)longPressPageView:(UILongPressGestureRecognizer *)longPress {
YBEmojiItemView *emojiItemView = nil;
CGPoint point = [longPress locationInView:self];
// 遍历当前页所有按钮, 找到手指所在的按钮
for (YBEmojiItemView *emojiItem in self.emojiItems) {
// 大表情长按时候有背景颜色, 小表情则没有
if (CGRectContainsPoint(emojiItem.frame, point)) {
emojiItemView = emojiItem;
if (self.isLargeEmoji) {
// 大表情长按预览的背景颜色
emojiItem.backgroundColor = self.config.largeEmojiHighlightBackgroundColor;
}else {
break;
}
}else {
emojiItem.backgroundColor = UIColor.clearColor;
}
}
if (longPress.state == UIGestureRecognizerStateFailed ||
longPress.state == UIGestureRecognizerStateCancelled ||
longPress.state == UIGestureRecognizerStateEnded ||
emojiItemView.emoji == nil) {
// hide preview
self.emojiPreview.hidden = YES;
// 清除在大表情的时候长按的背景颜色
if (self.isLargeEmoji) {
emojiItemView.backgroundColor = UIColor.clearColor;
}
}else {
// show preview
self.emojiPreview.hidden = NO;
UIWindow *window = [[[UIApplication sharedApplication] windows] lastObject];
// 先计算出相对于window的位置, 然后计算预览视图的frame
CGRect rectOfWindow = [emojiItemView convertRect:emojiItemView.bounds toView:window];
// 预览视图的宽度
CGFloat preview_w = self.isLargeEmoji ? self.config.largeEmojiPreviewSize.width : self.config.emojiPreviewSize.width;
// 预览视图的高度
CGFloat preview_h = self.isLargeEmoji ? self.config.largeEmojiPreviewSize.height : self.config.emojiPreviewSize.height;
// 预览视图的x
CGFloat preview_x = CGRectGetMaxX(rectOfWindow) - preview_w + (preview_w - rectOfWindow.size.width) / 2.0;
// 预览视图的y
CGFloat preview_y = self.isLargeEmoji ? CGRectGetMinY(rectOfWindow) - preview_h : CGRectGetMaxY(rectOfWindow) - preview_h;
// 计算大表情三角指示器的偏移量
CGFloat angleOffset_x = 0;
if (self.config.largeEmojiPreviewBorderMargin != 0 && self.isLargeEmoji) {
if (preview_x < self.config.largeEmojiPreviewBorderMargin) {
angleOffset_x = preview_x - self.config.largeEmojiPreviewBorderMargin;
preview_x = self.config.largeEmojiPreviewBorderMargin;
}
if (preview_x + preview_w > UIScreen.mainScreen.bounds.size.width - self.config.largeEmojiPreviewBorderMargin) {
angleOffset_x = self.config.largeEmojiPreviewBorderMargin + preview_x + preview_w - UIScreen.mainScreen.bounds.size.width;
preview_x = UIScreen.mainScreen.bounds.size.width - self.config.largeEmojiPreviewBorderMargin - preview_w;
}
}
CGRect frame = CGRectMake(preview_x, preview_y, preview_w, preview_h);
// 将当前手指所在位置的表情模型给预览视图进行显示
[self.emojiPreview setEmojiItemModel:emojiItemView.emoji isLargeEmoji:self.isLargeEmoji];
self.emojiPreview.frame = frame;
// 用来调整大表情三角指示器的居中
[self.emojiPreview setAngleOffset:angleOffset_x];
}
}
- 提供代理给外界, 实现表情点击以及删除方法
@protocol YBEmojiPageViewDelegate
// 点击表情
- (void)pageView:(YBEmojiPageView *)pageView clickedEmojiWith:(YBEmojiItemModel *)emoji;
// 点击大表情
- (void)pageView:(YBEmojiPageView *)pageView clickedBigEmojiWith:(YBEmojiItemModel *)emoji;
// 点击删除
- (void)pageView:(YBEmojiPageView *)pageView clickedDeleteWith:(UIButton *)button;
@end
至于YBEmojiPreviewView是一个预览视图, 比较简单, 只需要提供接口用来更改显示的图片, frame在外界根据当前手指所在的按钮来进行计算, 在绘制一个大表情的背景线框就可以了, 具体请查看demo, 显示gif的话, 代码我拷贝的YLGifImage
和YLGifImageView
2. UIPageControl 这个就不多说了, 不会的面壁去吧
3. YBEmojiTabbar作用是显示表情包封面图片, 以及提供点击方法
底部无非就是若干个表情包按钮和一个发送按钮, UI以及代码都比较简单, 最后提供一个代理给外部使用就可以了
拷贝 粘贴 剪切
如果输入框中显示有表情图片, 那么拷贝,剪切的时候, 我们需要将其创转成字符串, 如果粘贴的时候文字中有表情字符串则需要转换成表情图片
所以Demo中提供了一个继承自UITextView的YBEmojiTextView, 已经实现拷贝粘贴剪切的功能, 使用者只需要继承自该类即可, 具体的代码实现:
// 注: 以下代码拷贝自 PPStickerKeyboard 也给我提供了一些思路, 还有其他一些相关代码, 在此表示感谢
// 他的地址https://www.jianshu.com/p/9359b562a76f
- (void)cut:(id)sender {
NSString *string = [YBEmojiDataManager.manager plainStringWith:self.attributedText range:self.selectedRange];
if (string.length) {
[UIPasteboard generalPasteboard].string = string;
NSRange selectedRange = self.selectedRange;
NSMutableAttributedString *attributeContent = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText];
[attributeContent replaceCharactersInRange:self.selectedRange withString:@""];
self.attributedText = attributeContent;
self.selectedRange = NSMakeRange(selectedRange.location, 0);
if (self.delegate && [self.delegate respondsToSelector:@selector(textViewDidChange:)]) {
[self.delegate textViewDidChange:self];
}
}
}
- (void)copy:(id)sender {
NSString *string = [YBEmojiDataManager.manager plainStringWith:self.attributedText range:self.selectedRange];
if (string.length) {
[UIPasteboard generalPasteboard].string = string;
}
}
- (void)paste:(id)sender {
NSString *string = UIPasteboard.generalPasteboard.string;
if (string.length) {
NSMutableAttributedString *attributedPasteString = [[NSMutableAttributedString alloc] initWithString:string];
attributedPasteString = [YBEmojiDataManager.manager replaceEmojiWithAttributedString:attributedPasteString attributes:self.attributes];
NSRange selectedRange = self.selectedRange;
NSMutableAttributedString *attributeContent = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText];
[attributeContent replaceCharactersInRange:self.selectedRange withAttributedString:attributedPasteString];
self.attributedText = attributeContent;
self.selectedRange = NSMakeRange(selectedRange.location + attributedPasteString.length, 0);
if (self.delegate && [self.delegate respondsToSelector:@selector(textViewDidChange:)]) {
[self.delegate textViewDidChange:self];
}
}
}
以上就是实现表情键盘的一些思路以及要点, 具体请查看Demo, 代码已上传至Github
解决在ios14上图片显示不出来的bug
// 在YBEmojiGifImageView中对displayLayer方法做如下修改
- (void)displayLayer:(CALayer *)layer
{
if (!self.animatedImage || [self.animatedImage.images count] == 0) {
if (@available(iOS 14.0, *)) {
[super displayLayer:layer];
}
return;
}
//NSLog(@"display index: %luu", (unsigned long)self.currentFrameIndex);
if(self.currentFrame && ![self.currentFrame isKindOfClass:[NSNull class]]) {
layer.contents = (__bridge id)([self.currentFrame CGImage]);
}
}
注: 暂不支持YYText