ios表情键盘的实现

效果展示

效果展示.gif

实现的功能

  • 支持将表情转换成字符串, 同时也可以将带有表情的字符串转换成表情图片
  • 可自定义表情包, 可自定义每页表情的行数和列数, 自定义表情包需要两步
    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作用是用来显示表情, 主要功能如下:
  1. 计算一页显示最多表情数量, 然后将表情控件循环创建出来
- (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;
}
  1. 提供给外界的接口用来更新该页的表情
- (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;
}
  1. 根据是否为大表情更新表情控件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;
}
  1. 长按手势, 显示预览表情
- (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];
    }
}

  1. 提供代理给外界, 实现表情点击以及删除方法
@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的话, 代码我拷贝的YLGifImageYLGifImageView

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

你可能感兴趣的:(ios表情键盘的实现)