自定义表情键盘开发

前言:开发一套自定义表情包需求,类似于小红书的表情键盘,技术点其实在系统键盘和表情键盘的切换、核心是富文本的处理,包括文本转表情([哈哈]=>😆)和表情转文本(😆=>[哈哈])细节很多,坑也很多,工具类实现文件在最后,文中用到了可以查阅

系统键盘
表情键盘

分析下整个需求点和开发过程:
1、表情包JSON数据格式规范
2、服务端下发表情JSON文件,对于表情库的存取操作
3、页面展示文本转化
4、表情键盘以及键盘切换
5、最近使用表情逻辑开发
6、键盘上表情点击,长按展示浮窗动图、删除键功能
7、复制粘贴功能(包含粘贴时长度大于字数限制时截取处理)
8、输入框高度有高度限制时的动态变化逻辑
9、输入框输入或者粘贴的时候,上下抖动问题的处理
10、上传服务器

1、表情包JSON数据格式规范

首先我们与服务端要定好表情JSON数据的格式,

{
    "emojis": {
        "[握手]": {
            "code": "1514202019436744729",
            "name": "[握手]",
            "url": "https:\/\/moment-image.oss-cn-hangzhou.aliyuncs.com\/20220413\/1514202017163431937.png",
            "originUrl": "https:\/\/moment-image.oss-cn-hangzhou.aliyuncs.com\/20220413\/1514202017058574338_B.png"
        },
        "[高兴]": {
            "code": "1514202019436744732",
            "name": "[高兴]",
            "url": "https:\/\/moment-image.oss-cn-hangzhou.aliyuncs.com\/20220413\/1514202015296966658.png",
            "originUrl": "https:\/\/moment-image.oss-cn-hangzhou.aliyuncs.com\/20220413\/1514202015192109058_B.png"
        },
        "[流泪]": {
            "code": "1514202019436744723",
            "name": "[流泪]",
            "url": "https:\/\/moment-image.oss-cn-hangzhou.aliyuncs.com\/20220413\/1514202015972249602.png",
            "originUrl": "https:\/\/moment-image.oss-cn-hangzhou.aliyuncs.com\/20220413\/1514202015842226177_B.png"
        },
        "[sayhi]": {
            "code": "1514202019436744725",
            "name": "[sayhi]",
            "url": "https:\/\/moment-image.oss-cn-hangzhou.aliyuncs.com\/20220413\/1514202019264778242.png",
            "originUrl": "https:\/\/moment-image.oss-cn-hangzhou.aliyuncs.com\/20220413\/1514202019143143425_B.png"
        },
    },
    "emojiNames": ["[sayhi]", "[握手]", "[流泪]", "[高兴]"],
    "emojiVersion":1
}

注意看,我们定的数据有三个字段,
emojis:是个字典形式,方便我们后续直接通过key来匹配对应的表情模型,而不是数组每次都要循环匹配
emojiNames:因为后台给的字典我们拿到的可能就是 无序 的,也就是说不能保证顺序是对的,所以我们需要一个正确的顺序,用来在表情键盘中正确的排序
emojiVersion:当运营没有对表情库做任何处理的时候,服务端不需要每次都下发JSON文件给我们,所以emojiVersion我们要保存的本地,每次进入APP调用接口的时候,作为参数给服务器,服务器会判断是否传JSON给我们。

这里需要注意的是,如果通过 [[NSUserDefaults standardUserDefaults] setInteger:[dictionary[@"emojiVersion"] intValue] forKey:kMomentsEmojiVersion];这种方式本地保存的话,后面跟上[[NSUserDefaults standardUserDefaults] synchronize];这样会实时同步,避免短时间再次请求接口的时候还没有保存起来,造成不必要的麻烦

2、服务端下发表情JSON文件,对于表情库的存取操作

存取使用的逻辑是包含以下几种情况:

1、第一次打开APP,此时 emojiVersion 传值为空,请求表情接口
  如果请求失败:那么就从本地的JSON文件中拿数据(最初本地会放一份JSON文件做兜底方案)。
  如果请求成功:那么就把请求下来的JSON文件,保存到本地,版本号别忘了也要存起来

2、如果本地已经存储过之前请求下来的数据了,那么随后进入APP的时候请求表情接口
  如果请求失败:拿上次存储下来的JSON文件使用
  如果请求成功:①库没有变化,服务端没有下发JSON文件,那么还是拿上次存储下来的JSON文件使用。②库变化了,服务端新下发了JSON文件,那么就把原来存储的给替换掉,版本号别忘了也要存起来

接下来是代码部分:

//接口请求部分:
JYMomentsEmojiManager 是我写的一个单例工具类,关于表情的一些操作都在这个类里完成
//表情包下载
- (void)configEmojiData{
    NSInteger version = [[NSUserDefaults standardUserDefaults] integerForKey:kMomentsEmojiVersion];
    NSDictionary *paramsDict = @{
        @"action":@"******",
        @"emojiVersion":@(version)
    };
    [[JYNetworkManager shareManager] momentsPostApiWithParas:paramsDict success:^(NSDictionary *responseData) {
        if ([responseData[@"code"] integerValue] == kRequestSuccessCode && responseData[@"data"][@"emojis"]) {
            //使单利内可以获取到表情json,以便于其他方法匹配
            [[JYMomentsEmojiManager sharedInstance] saveEmojiDataWithDictionary:responseData[@"data"]];
        }else{
            //使单例内可以获取到表情json,以便于其他方法匹配
            [[JYMomentsEmojiManager sharedInstance] fetchAllEmojiDictionary];
        }
    } failure:^(NSError *error) {
            //使单例内可以获取到表情json,以便于其他方法匹配
        [[JYMomentsEmojiManager sharedInstance] fetchAllEmojiDictionary];
    }];
}

接下来看看[[JYMomentsEmojiManager sharedInstance] fetchAllEmojiDictionary];做了哪些逻辑

- (NSDictionary *)fetchAllEmojiDictionary{
//首先从接口请求数据的本地存储路径中查找,是否存储过文件,如果有,则从这里拿,如果没有,说明以前还没有请求接口成功过,还没有存储到本地数据,这时就用我们自己在项目中放的一份JSON文件
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSString *filepath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
    NSString *newPath = [filepath stringByAppendingPathComponent:storeEmojiPath];
    if ([fileManager fileExistsAtPath:newPath]) {
        NSData *data = [NSData dataWithContentsOfFile:newPath];
        NSDictionary *dic = [NSKeyedUnarchiver unarchiveObjectWithData:data];
        if (dic && dic.allKeys.count > 0) {
            self.allEmojiDictionary = dic;
            return dic;
        }
    }
    //用我们自己在项目中放的一份JSON文件
    [self useLocalEmojiData];
    return self.allEmojiDictionary;
}

//使用本地JSON文件
- (void)useLocalEmojiData{
//这里是组建化的写法,独立项目直接就是[[NSBundle mainBundle] ...],这里是获取项目中JSON文件的路径,要跟存储接口请求下来的数据路径区分开
    NSString *path = [JYMomentsBundle() pathForResource:storeLocalEmojiPath ofType:@"json"];
    NSData *jsonData = [[NSData alloc] initWithContentsOfFile:path];
    NSError *error;
    NSDictionary *jsonObj = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers error:&error];
    if (jsonObj && !error) {
        self.emojiNames = jsonObj[@"emojiNames"];
        [[NSUserDefaults standardUserDefaults] setObject:self.emojiNames forKey:kMomentsEmojiNamesArray];
        [[NSUserDefaults standardUserDefaults] synchronize];
        self.allEmojiDictionary = [self dictionaryToModelWithDictionary:jsonObj[@"emojis"]];
    }
}

//字典中套的字典转模型,方便后续开发中使用
- (NSDictionary *)dictionaryToModelWithDictionary:(NSDictionary *)dictionary{
    NSMutableDictionary *dict = [NSMutableDictionary dictionary];
    for (NSString *key in dictionary.allKeys) {
        if (key.length > 0 && ![dict.allKeys containsObject:key]) {
            JYMomentsEmojiModel *model = [JYMomentsEmojiModel mj_objectWithKeyValues:dictionary[key]];
            [dict setObject:model forKey:key];
        }
    }
    return [dict copy];
}

以上是走失败的方案或者表情库没有更新的逻辑,下面是库更新了,我们更换JSON文件的代码

//存储表情包数据文件
- (void)saveEmojiDataWithDictionary:(NSDictionary *)dictionary{
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSString *filepath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
    NSString *newPath = [filepath stringByAppendingPathComponent:storeEmojiPath];
    if ([fileManager fileExistsAtPath:newPath]) {
        [fileManager removeItemAtPath:newPath error:nil];
    }
    //存储数据,这里也用到了把字典转model进行存储
    self.allEmojiDictionary = [self dictionaryToModelWithDictionary:dictionary[@"emojis"]];
    self.emojiNames = dictionary[@"emojiNames"];
    //存储表情名字数组
    [[NSUserDefaults standardUserDefaults] setObject:self.emojiNames forKey:kMomentsEmojiNamesArray];
    [[NSUserDefaults standardUserDefaults] synchronize];
    //存储
    NSData *data = [NSKeyedArchiver archivedDataWithRootObject:self.allEmojiDictionary];
    [data writeToFile:newPath atomically:YES];
    
    //存储版本号
    [[NSUserDefaults standardUserDefaults] setInteger:[dictionary[@"emojiVersion"] intValue] forKey:kMomentsEmojiVersion];
    [[NSUserDefaults standardUserDefaults] synchronize];
}

以上就是表情库数据的存储读取逻辑处理

3、页面展示文本转化

注意:我们跟服务端是用 [高兴] 形式交互的,我们给服务端也是这种形式,服务端给我们也是这种,表情的展示完全是我们移动端自己处理的逻辑

对于页面展示文本内容中有表情,例如:今天我很[高兴],我们要做转换处理

//使用工具类里的方法:
//表情字符串原文本转表情文本 (例如:[OK]了吗 => 了吗) pageid是为了埋点
- (NSAttributedString *)fetchEmojiContentWithEmojiString:(NSString *)emojiString isWebp:(BOOL)isWebp font:(UIFont *)font pageId:(NSString *)pageId;
参数解释:
emojiString:[OK]了吗 (整个需要转义的文本都放进来)
size:表情(其实就是imageview)展示的尺寸大小
isWebp:这里我们内部做了是否转换成webp格式的操作,因为webp格式图片会更小,展示更流畅
font:表情转义后字体大小,跟自己控件中的其他普通文本字体相同就OK
pageId:为了埋点,因为所有页面都会走这个方法转义,要判断是哪个页面

看一下这个方法的实现:

//字符串文本 转 表情文本
- (NSAttributedString *)fetchEmojiContentWithEmojiString:(NSString *)emojiString isWebp:(BOOL)isWebp font:(UIFont *)font pageId:(NSString *)pageId{
    //通过正则表达式获取到匹配到的range
    NSArray *matchArray = [self fetchRangeWithContentString:emojiString];
    
    //用来存放字典,字典中存储的是图片和图片对应的位置
    NSMutableArray *imageArray = [NSMutableArray arrayWithCapacity:matchArray.count];

    //根据匹配范围来用图片进行相应的替换
    for(NSTextCheckingResult *match in matchArray) {
        //获取数组元素中得到range
        NSRange range = [match range];
        //获取原字符串中对应的值
        NSString *subStr = [emojiString substringWithRange:range];
        
        //把图片和图片对应的位置存入字典中
        NSMutableDictionary *imageDic = [NSMutableDictionary dictionaryWithCapacity:matchArray.count];
        [imageDic setObject:subStr forKey:@"emojiStr"];
        [imageDic setObject:[NSValue valueWithRange:range] forKey:@"range"];
        //把字典存入数组中
        [imageArray addObject:imageDic];
    }
    return [self fetchEmojiContentWithString:emojiString imageDataArr:imageArray size:size isWebp:isWebp font:font pageId:pageId];
}

//通过正则表达式匹配到范围
- (NSArray *)fetchRangeWithContentString:(NSString *)string{
    //正则表达式
    NSString * pattern = @"(\\[).*?(\\])";
    NSError *error = nil;
    NSRegularExpression *re = [[NSRegularExpression alloc] initWithPattern:pattern options:0 error:&error];
    if (!re) {
        NSLog(@"%@", [error localizedDescription]);
    }
    //通过正则表达式来匹配字符串
    return [re matchesInString:string options:0 range:NSMakeRange(0, string.length)];
}

//从后往前替换字符串
- (NSAttributedString *)fetchEmojiContentWithString:(NSString *)content imageDataArr:(NSArray *)imageDataArr size:(CGSize)size isWebp:(BOOL)isWebp font:(UIFont *)font pageId:(NSString *)pageId{
    
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:content];
    for (long int i = (imageDataArr.count - 1); i >= 0; i --) {
        NSDictionary *dictionary = [imageDataArr jy_objectAtIndex:i];
        NSString *subStr = dictionary[@"emojiStr"];
        NSRange range;
        [dictionary[@"range"] getValue:&range];
        
        if ([self.allEmojiDictionary.allKeys containsObject:subStr]) {
            JYMomentsEmojiModel *model = self.allEmojiDictionary[subStr];
            //这里的尺寸是转webp所需要的尺寸,跟外面传的尺寸不同
            //注意:URL中可能有中文,这样的话图片会失败,需要转一下
            NSURL *url = [NSURL URLWithString:[[model.url fetchThumbImageWidth:54 andHeight:54 isWebp:isWebp] stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]];
            UIImageView *insertImageView = [[UIImageView alloc] init];
            insertImageView.contentMode = UIViewContentModeScaleAspectFit;
            insertImageView.tag = [model.code integerValue];
            insertImageView.bounds = CGRectMake(0, 0, size.width, size.height);
            [insertImageView sd_setImageWithURL:url completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
                
            }];
            
            NSMutableAttributedString *imageStr = [NSMutableAttributedString yy_attachmentStringWithContent:insertImageView contentMode:UIViewContentModeScaleToFill attachmentSize:CGSizeMake(size.width, size.height) alignToFont:font alignment:YYTextVerticalAlignmentCenter];
            //进行替换
            [attributedString jy_replaceCharactersInRange:range withAttributedString:imageStr];
            
            if (pageId.length > 0) {
            //埋点操作
            }
        }
    }
    return attributedString;
}

重点注意:转义成富文本插入的时候其实是一个Unicode码占位符\U0000fffc 是1个长度的字符
也就是说 [高兴] 转 其实是4个字符串长度转成1个字符串长度,所以在转的时候会导致整个文本的长度变的,变的话正则匹配到的range也就对不上了,所以替换的时候要注意是从后面往前面替换
重点注意:可能文本中有个别文案要个性化设置(字体颜色改变等),那么我们先处理完我们的表情转义后再去做那些操作,防止原始字符串被破坏,转义表情的时候受阻!!!
重点注意:富文本设值,就不要用text了,用attributeString设值。一般用的是YYLabel而不用UILabel,UILabel展示不出来。输入框编辑可以用YYTextView

4、表情键盘以及键盘切换

看看表情键盘,其实就知道是用UICollectionview做的,有最近使用表情就用两个区,这个应该不难,有一定开发基础的都能实现

需要注意的是:单独创建一个UIView(下文成为emojiKeyboardView),UICollectionview作为子视图,因为表情键盘上还有一个删除按钮是作为UIView的子视图固定在右下角呢。

切换表情键盘和系统键盘的方法很简单:我们是把emojiKeyboardView作为textview的inputView,这样的话其实emojiKeyboardView就成了键盘
切换的时候分三步:
1、需要先回收键盘
2、然后inputview转换成对应的view(表情键盘:emojiKeyboardView!系统键盘:nil)
3、唤起键盘(textView becomeFirstResponder)
代码如下:

//里面一些变量标识可先忽略,关注主要逻辑
- (void)changeKeyboardType:(BOOL)isFaceKeyboard{
    self.isClickFaceButton = YES;
//第一步
    [self.tagTextView endEditing:YES];
    if (isFaceKeyboard) {
        self.isFaceKeyboard = YES;
//第二步
        self.tagTextView.inputView = self.emojiKeyboardView;
    }else{
        self.isFaceKeyboard = NO;
//第二步
        self.tagTextView.inputView = nil;
    }
//第三步 延时处理是因为,不延时的话,键盘上面的输入框编辑那一栏会抖动,原因不详,延时是能解决问题的
    @weakify(self);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        @strongify(self);
        [self.tagTextView  becomeFirstResponder];
    });
}

注意:self.emojiKeyboardView 表情键盘这个view,高度是自己可以控制的,如果你设置成600那么切换键盘的时候,系统键盘的高度和表情键盘的高度就不一样了,最好是高度和系统键盘的高度一致,那么在切换键盘的时候,顶部的输入框编辑栏就不会上下乱跳,(当然小红书的表情键盘高度是比着系统键盘很低,可能就是固定死高度)不管你系统键盘高度多少(系统键盘和搜狗键盘高度还不一样),表情键盘一直不变。这样逻辑上就比较简单,
如果想要做成跟系统键盘高度一样,做法是监听系统键盘弹起和收起的方法,获取到键盘高度,然后赋值给表情键盘
注意注意注意:如果你表情键盘初始化的时候高度设置是300,系统键盘比如说是400,那么在切换键盘的时候,监听的键盘弹起回收方法中获取的高度也是会变的,也就是说你变成表情键盘的时候,你这个表情面板view就是键盘,所以要想设置表情面板高度成为系统键盘高度,就要在成为系统键盘的时候记录下系统键盘的高度,然后在切换的时候,重新设置一下表情面板的高度,这个时机就是系统键盘回收的时候,这时候我们拿着系统键盘的高度,重新初始化一下我们的emojiKeyboardView,高度设置成系统键盘以后,我们在切换成表情键盘,弹起的时候高度就是正确的高度了。

代码如下:

监听键盘的两个通知
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillhide:) name:UIKeyboardWillHideNotification object:nil];
- (void)keyboardWillShow:(NSNotification *)notification{
    NSDictionary *userInfo = notification.userInfo;
    double duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
    CGRect keyboardF = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
    CGFloat keyboardH = keyboardF.size.height;
    [UIView animateWithDuration:duration animations:^{
        self.bottomEditView.height = 53 + keyboardH;
        self.bottomEditView.bottom = self.view.bottom;
    } completion:^(BOOL finished) {
    }];
}

- (void)keyboardWillhide:(NSNotification *)notification{
    NSDictionary *userInfo = notification.userInfo;
    double duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
    CGRect keyboardF = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
    CGFloat keyboardH = keyboardF.size.height;
    if (!self.isFaceKeyboard) {
        [self initFacekeyboardWithHeight:keyboardH];
    }
    [UIView animateWithDuration:duration animations:^{
        if (!self.isClickFaceButton) {
            self.bottomEditView.bottom = self.view.bottom+keyboardH+53;
        }
    } completion:^(BOOL finished) {
        self.isClickFaceButton = NO;
    }];
}

- (void)initFacekeyboardWithHeight:(CGFloat)viewHeight{
    if (self.emojiKeyboardView) {
        [self.emojiKeyboardView removeFromSuperview];
        self.emojiKeyboardView = nil;
    }
    self.emojiKeyboardView = [[JYMomentsEmojiKeyboardView alloc] initWithFrame:CGRectMake(0, self.view.height - [UIDevice safeAreaInsetsBottomHeight], kScreenWidth, viewHeight) pageType:JYUseKeyBoardPageTypePublishFeed];
    self.emojiKeyboardView.delegate = self;
}

注意注意注意:键盘上的编辑栏,保证视觉上编辑栏底部挨着键盘顶部就可以了,如果这样的话就要在键盘底部单独再加一个遮罩,笔者这里是直接把编辑栏做成高度是键盘高度+视觉的编辑栏高度(因为在切换键盘的时候键盘回收弹起过程中,如果下面没有遮罩,编辑栏又要停留在原处的时候,会露出底部控制器的试图),不管怎么做,在切换键盘的时候不要去掉这个遮罩,等真正最终回收键盘的时候一定要注意这个遮罩的处理,要不然就遮罩停留在那里,就成了bug

注意注意注意:有个问题可能会被忽略,就是键盘弹起状态时,如果系统截图,会出现”寻求帮助“悬浮按钮,点击这个按钮,再次回来的时候,就会出现键盘回收了遮罩还在的情况。对于这种键盘异常回收的情况,解决方法是viewWillAppear中设置[textview becomeFirstResponder]

5、最近使用表情逻辑开发

最近使用表情模块

可以看出最近使用表情是系统键盘弹出时带的,
实现的方式有两种:
1、作为独立的自定义随着系统键盘一起弹起撤销,要做一些判断是否撤销弹起的逻辑
2、作为键盘的辅助试图,也就是TextView.inputAccessoryView,这样的话在切换键盘的时候只要设置TextView.inputAccessoryView为nil或者自定义view即可,如果不遇到奇葩问题的话,这种方式会少很多判断逻辑

自定义最近使用表情面板

需求是固定7个表情平均分摊屏幕宽度,为了扩展性,使用UIview上面加一个UIScrollview,利用for循环添加UIImageView,因为UIImageView需要做点击或者长按操作,需要关联着表情model,所以我们自定义一个UIImageView,添加属性表情model和一个位置属性bigImageViewMinX(用于长按的时候预览试图添加位置定位)
预览试图也是自定义一个UIview,子视图是一个底图容器UIImageView和大表情容器UIImageView以及一个表情名字label


悬浮样式
//自定义图片
NS_ASSUME_NONNULL_BEGIN

@interface JYMomentsEmojiDiyImageView : UIImageView

@property (nonatomic,strong) JYMomentsEmojiModel *model;
@property (nonatomic,assign) CGFloat bigImageViewMinX;
@end

NS_ASSUME_NONNULL_END
//自定义最近使用表情面板 .h文件
NS_ASSUME_NONNULL_BEGIN

@interface JYMomentsEmojiRecentlyUseView : UIView

///表情点击操作
@property (nonatomic,copy) void(^tapAction)(JYMomentsEmojiModel *model);
//刷新最近使用表情列表
- (void)updateUI;
@end

NS_ASSUME_NONNULL_END


//自定义最近使用表情面板 .m文件
@interface JYMomentsEmojiRecentlyUseView()
@property (nonatomic,strong) UIScrollView *bgScrollView;
@property (nonatomic,strong) JYMomentsEmojiBigBackGroundView *bigbgView;
@end

@implementation JYMomentsEmojiRecentlyUseView

#pragma mark --lifeCycle
- (instancetype)initWithFrame:(CGRect)frame{
    if (self = [super initWithFrame:frame]) {
        [self setupViews];
        [self updateUI];
    }
    return self;
}

#pragma mark --private method
- (void)setupViews{
    self.backgroundColor = [UIColor colorWithHexString:@"FFFFFF"];
    [self addSubview:self.bgScrollView];
    [self.bgScrollView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.edges.mas_equalTo(0);
    }];
}

#pragma mark --public method
- (void)updateUI{
    [self.bgScrollView removeAllSubviews];
    
    NSArray *emojis = [[JYMomentsEmojiManager sharedInstance] fetchRecentlyEmojiArr];
    NSMutableArray *emojisDicArray = [NSMutableArray arrayWithArray:emojis];
    if (emojisDicArray.count < 7) {
        NSDictionary *allEmojiDic = [[JYMomentsEmojiManager sharedInstance] fetchAllEmojiDictionary];
        NSArray *names = [[JYMomentsEmojiManager sharedInstance] fetchAllEmojiNamesArray];
        for (NSString *name in names) {
            JYMomentsEmojiModel *model = allEmojiDic[name];
            
            BOOL isHave = NO;
            for (JYMomentsEmojiModel *item in emojis) {
                if ([item.code isEqualToString:model.code]) {
                    isHave = YES;
                    break;
                }
            }
            
            if (!isHave) {
                [emojisDicArray addObject:model];
            }
            
            if (emojisDicArray.count >= 7) {
                break;
            }
        }
    }
    
    for (int i = 0; i < emojisDicArray.count; i ++) {
        JYMomentsEmojiModel *model = [emojisDicArray jy_objectAtIndex:i];
        CGFloat leftSpace = 12;
        //kScreenWidth - 24 - 34 * 7
        CGFloat space = (kScreenWidth - 262) / 6;
        JYMomentsEmojiDiyImageView *diyImagView = [[JYMomentsEmojiDiyImageView alloc] initWithFrame:CGRectMake(leftSpace + (i * (34 + space)), 0, 34, 34)];
        diyImagView.model = model;
        diyImagView.bigImageViewMinX = leftSpace + (i * (34 + space)) - 8;
        [diyImagView sd_setImageWithURL:[NSURL URLWithString:[model.url stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]]];
        diyImagView.userInteractionEnabled = YES;
        
        @weakify(self)
        //点击事件
        UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithActionBlock:^(id  _Nonnull sender) {
            @strongify(self)
            if (self.tapAction) {
                [JYTracker clickEventTrackWithPageId:@"community_new_momentpage" categoryId:@"alpha" buttonName:@"community_HAHA_choice" businessProperties:@{@"numHaha":model.code}];
                self.tapAction(model);
            }
        }];
        [diyImagView addGestureRecognizer:tap];
        //长按事件
        UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressClick:)];
        longPress.minimumPressDuration = 0.5;
        [diyImagView addGestureRecognizer:longPress];
        
        [self.bgScrollView addSubview:diyImagView];
    }
}

- (void)longPressClick:(UIGestureRecognizer *)gesture{
//获取到被长按的试图,拿到试图后,通过之前记录的bigImageViewMinX属性就可以准确的定位添加预览图了
    JYMomentsEmojiDiyImageView *diyView = (JYMomentsEmojiDiyImageView *)gesture.view;
    [self addSubview:self.bigbgView];
    [self.bigbgView configViewWithModel:diyView.model];
    [self.bigbgView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.left.equalTo(@(diyView.bigImageViewMinX));
        make.bottom.equalTo(@(-12));
        make.width.mas_equalTo(50);
        make.height.mas_equalTo(93);
    }];
    if (gesture.state == UIGestureRecognizerStateEnded) {
        if (self.bigbgView.superview) {
            [self.bigbgView removeFromSuperview];
            self.bigbgView = nil;
        }
    }
}

#pragma mark --lazyload
- (UIScrollView *)bgScrollView{
    if (!_bgScrollView) {
        _bgScrollView = [[UIScrollView alloc] initWithFrame:CGRectZero];
        _bgScrollView.backgroundColor = [UIColor whiteColor];
        _bgScrollView.contentSize = CGSizeMake(kScreenWidth, 45);
    }
    return _bgScrollView;
}

- (JYMomentsEmojiBigBackGroundView *)bigbgView{
    if (!_bigbgView) {
        _bigbgView = [[JYMomentsEmojiBigBackGroundView alloc] initWithFrame:CGRectZero];
    }
    return _bigbgView;
}
@end

上面是UI方面的处理,下面是功能逻辑的处理

功能逻辑

点击表情键盘上的表情,或者最近使用面板表情,会使用工具类中的方法先记录到本地

//评论模块中点击表情后临时存储最近使用表情排序处理(不存储)
- (void)temporaryStorageEmoji:(JYMomentsEmojiModel *)emojiModel;

等键盘最终回收的时候,做一次本地存储

//存储最近使用表情数组
- (void)saveRecentlyEmojiArr;

当然我们在构建最近使用表情模块面板以及更新面板的时候需要获取本地最新存储的最近使用表情数据

//获取最近使用表情数组
- (NSArray<JYMomentsEmojiModel *> *)fetchRecentlyEmojiArr;

逻辑是:点击表情的时候,工具类中用一个临时数组来记录这些点击过的表情model,如果点击的是同一个model,就会删除原来的,然后把最新的添加到数组的第一个位置,也就是说在一个临时存放到本地存储的的周期内,临时数组会自己判重,没有的话添加,有的话就移动到第一位
回收键盘操作后(切换键盘的过程回收键盘不算,这就需要自己去判断是否是最终回收键盘)存储临时数组里面的model,这里还需要拿之前存储的最近使用表情数据来和临时数组里的model判重,最终将判重后的表情model数组存储到本地。model数组存储也需要转成NSData类型,存储后把临时数组置空。
以上三个接口代码实现:

//评论模块中点击后临时存储最近使用表情
- (void)temporaryStorageEmoji:(JYMomentsEmojiModel *)emojiModel{
    BOOL isHave = NO;
    NSInteger index = 0;
    for (JYMomentsEmojiModel *model in self.tempArray) {
        //根据表情名字判断是否是同一个表情去重处理
        if ([emojiModel.code isEqualToString:model.code]) {
            isHave = YES;
            index = [self.tempArray indexOfObject:model];
            break;
        }
    }
    if (!isHave) {
        [self.tempArray jy_insertObject:emojiModel atIndex:0];
    }else{
        [self.tempArray jy_removeObjectAtIndex:index];
        [self.tempArray jy_insertObject:emojiModel atIndex:0];
    }
    //截取前7个存储
    if (self.tempArray.count > kMomentsEmojiRecentlyNumber) {
        self.tempArray = [[self.tempArray jy_subarrayWithRange:NSMakeRange(0, kMomentsEmojiRecentlyNumber)] mutableCopy];
    }
}
//存储最近使用表情数组
- (void)saveRecentlyEmojiArr{
    BOOL isHave = NO;
    NSInteger index = 0;
    NSArray *dataArr = [self fetchRecentlyEmojiArr];
    NSMutableArray *arr = [NSMutableArray arrayWithArray:dataArr];
    for (JYMomentsEmojiModel *item in self.tempArray) {
        for (JYMomentsEmojiModel *model in dataArr) {
            if ([item.code isEqualToString:model.code]) {
                isHave = YES;
                index = [dataArr indexOfObject:model];
                break;
            }
        }
        if (arr.count > index && isHave) {
            [arr removeObjectAtIndex:index];
        }
    }
    

    [self.tempArray addObjectsFromArray:arr];
    //截取前7个存储
    if (self.tempArray.count > kMomentsEmojiRecentlyNumber) {
        self.tempArray = [[self.tempArray jy_subarrayWithRange:NSMakeRange(0, kMomentsEmojiRecentlyNumber)] mutableCopy];
    }
    if (self.tempArray.count > 0) {
        NSMutableArray *arr = [NSMutableArray array];
        for (JYMomentsEmojiModel *model in self.tempArray) {
            NSData *data = [NSKeyedArchiver archivedDataWithRootObject:model];
            [arr addObject:data];
        }
        [[NSUserDefaults standardUserDefaults] setObject:arr forKey:kMomentsEmojiRecentlyUseArray];
        [[NSUserDefaults standardUserDefaults] synchronize];
        //保存完一波后清空
        [self.tempArray removeAllObjects];
    }
}
//获取最近使用表情数组
- (NSArray<JYMomentsEmojiModel *> *)fetchRecentlyEmojiArr{
    NSMutableArray *arr = [NSMutableArray new];
    NSArray *dataArr = [[NSUserDefaults standardUserDefaults] objectForKey:kMomentsEmojiRecentlyUseArray];
    for (NSData *data in dataArr) {
        [arr addObject:[NSKeyedUnarchiver unarchiveObjectWithData:data]];
    }
    return arr;
}

注意:当点击相同表情的时候,判重直接通过数组中是否包含model对象的话,可能会有问题,会判别成两个model,所以准确的处理是拿model的唯一标识来判重,这样的话无非是在深一层去判断

6、键盘上表情点击,长按展示浮窗动图、删除键功能

点击事件也就是表情键盘中didselect代理方法以及最近使用表情view中的点击事件,长按方法在上面最近使用表情一栏中已经讲到,表情键盘中的方式也类似,甚至更简单一点,获取到对应的位置,然后添加预览试图,下面是在表情键盘中的长按操作

- (void)longpressAction:(NSIndexPath *)indexPath{
    JYMomentsDefaultEmojiCell *cell = (JYMomentsDefaultEmojiCell *)[self.keyboardView cellForItemAtIndexPath:indexPath];
    JYMomentsEmojiModel *model = nil;
    if (indexPath.section == 0) {
         model = self.recentlyEmojiArr[indexPath.item];
    }else{
         NSString *name = self.allEmojiNamesArray[indexPath.item];
         model = self.allEmojiDictionary[name];
    }
    
    //长按表情操作
    UIWindow *window = [[[UIApplication sharedApplication] windows] lastObject];
    CGRect rect = [cell.imageView convertRect:cell.imageView.bounds toView:window];
    
    self.bigbgView.frame = CGRectMake(rect.origin.x - 8, rect.origin.y-93+34, 50, 93);
    [self.bigbgView configViewWithModel:model];
    [window addSubview:self.bigbgView];
}

- (void)cancelLonepressAction:(NSIndexPath *)indexPath{
    if (self.bigbgView.superview) {
        [self.bigbgView removeFromSuperview];
        self.bigbgView = nil;
    }
}
点击事件:
- (void)addEmojiStringWithModel:(JYMomentsEmojiModel *_Nullable)emojiModel{
    NSRange range = [self selectedRange];
    NSMutableAttributedString *mutableAttri = [[NSMutableAttributedString alloc] initWithAttributedString:self.tagTextView.attributedText];
    NSAttributedString *attributedString = [[JYMomentsEmojiManager sharedInstance] imageStringFromModel:emojiModel isWebp:YES font:[UIFont f15]];
    [mutableAttri insertAttributedString:attributedString atIndex:range.location];
    mutableAttri.yy_font = [UIFont f15];
    mutableAttri.yy_color = [UIColor color_333333];
    self.tagTextView.attributedText = [mutableAttri copy];
    self.tagTextView.selectedRange = NSMakeRange(range.location+1, 0);
    [self.tagTextView scrollRangeToVisible:self.tagTextView.selectedRange];
}
//删除事件
- (void)subtractEmojiString{
//系统键盘的删除事件处理
    [self.tagTextView deleteBackward];
    self.tagTextView.selectedRange = [self selectedRange];
    [self.tagTextView scrollRangeToVisible:self.tagTextView.selectedRange];
}

注意:因为添加表情的时候是整个富文本替换原来的富文本,此时如果不设置TextView的selectRange那么就有问题,也就是光标会有问题,要设置正确的光标很重要,设置完正确的光标位置,然后[self.tagTextView scrollRangeToVisible:self.tagTextView.selectedRange];使用这句代码可以让输入框不抖动

7、复制粘贴功能(包含粘贴时长度大于字数限制时截取处理)

更改可粘贴富文本的属性

_tagTextView.allowsPasteAttributedString = YES;
默认复制富文本的属性是yes,粘贴富文本属性为NO,如果这里不设置,那么就粘贴不过来,只能粘贴的是普通文本

自定义TextView继承与YYTextView,用于hook复制粘贴方法

如果直接使用YYTextView,是使用系统的复制粘贴功能,那么如果复制的表情过多的话,会卡主线程3s左右,体验很差
如果继承YYTextView,在复制方法里,将富文本转成普通字符串(=>[高兴])存在粘贴板里,剪切的方法跟复制类似,只是要将剪切那部分给删掉,粘贴的时候再将普通文本转成表情文本([高兴]=>)
自定义TextView代码如下:

.h文件
#import <YYText/YYText.h>

NS_ASSUME_NONNULL_BEGIN

@interface JYMomentsSubmitTextView : YYTextView
- (instancetype)initWithFrame:(CGRect)frame font:(UIFont *)font color:(UIColor *)color;
@end

NS_ASSUME_NONNULL_END


.m文件
//
//  JYMomentsSubmitTextView.m
//  JYMomentsModule
//
//  Created by huorui16130 on 2022/4/12.
//

#import "JYMomentsSubmitTextView.h"
#import "JYMomentsEmojiManager.h"

@interface JYMomentsSubmitTextView()

@property (nonatomic,strong) UIFont *textFont;
@property (nonatomic,strong) UIColor *contentColor;
@end

@implementation JYMomentsSubmitTextView

/*
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect {
    // Drawing code
}
*/

- (instancetype)initWithFrame:(CGRect)frame font:(UIFont *)font color:(UIColor *)color{
    if (self = [super initWithFrame:frame]) {
//        self.interactiveSuperScrollView =
        self.textFont = font;
        self.contentColor = color;
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willShowEditMenu:) name:UIMenuControllerWillShowMenuNotification object:nil];
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didHideEditMenu:) name:UIMenuControllerDidHideMenuNotification object:nil];
    }
    return self;
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

-(void)willShowEditMenu:(id)sender
{
    self.canCancelContentTouches = NO;
    self.delaysContentTouches = NO;
}

-(void)didHideEditMenu:(NSNotification *)notifi
{
    self.canCancelContentTouches = YES;
    self.delaysContentTouches = YES;
}

- (BOOL)canBecomeFirstResponder {
    return YES;
}

- (void)cut:(id)sender{
    NSAttributedString *attri = [[JYMomentsEmojiManager sharedInstance] exchangeTextFromEmojiTextView:self textRange:self.selectedRange];
    UIPasteboard *pastboard = [UIPasteboard generalPasteboard];
    pastboard.string = attri.string;
    NSMutableAttributedString *mutableAttri = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText];
    NSRange insertRange = self.selectedRange;
    [mutableAttri jy_replaceCharactersInRange:self.selectedRange withString:@""];
    self.attributedText = [mutableAttri copy];
    self.selectedRange = NSMakeRange(insertRange.location, 0);
    [self scrollRangeToVisible:NSMakeRange(insertRange.location, 0)];
}

- (void)copy:(id)sender {
    
    NSAttributedString *attri = [[JYMomentsEmojiManager sharedInstance] exchangeTextFromEmojiTextView:self textRange:self.selectedRange];
    UIPasteboard *pastboard = [UIPasteboard generalPasteboard];
    pastboard.string = attri.string;
}

- (void)paste:(id)sender{
    UIPasteboard *pastboard = [UIPasteboard generalPasteboard];
    NSAttributedString *attri = [[JYMomentsEmojiManager sharedInstance] fetchEmojiContentWithEmojiString:pastboard.string isWebp:YES font:[UIFont f18] pageId:@""];
    NSMutableAttributedString *mutableAtt = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText];
    NSRange insertRange = self.selectedRange;
    if (self.tag == 300) {//评论输入框
        NSAttributedString *att = [[JYMomentsEmojiManager sharedInstance] exchangeTextFromEmojiTextView:self];
        if (att.length + pastboard.string.length > 140) {
            NSString *insertString = [pastboard.string substringToIndex:140-att.length];
            NSAttributedString *endStr = [[NSAttributedString alloc] initWithString:insertString];
            [mutableAtt jy_replaceCharactersInRange:insertRange withAttributedString:endStr];
        }else{
            [mutableAtt jy_replaceCharactersInRange:insertRange withAttributedString:attri];
        }
    }else{
        [mutableAtt jy_replaceCharactersInRange:insertRange withAttributedString:attri];
    }
    mutableAtt.yy_font = self.textFont;
    mutableAtt.yy_color = self.contentColor;
    self.attributedText = [mutableAtt copy];
    self.selectedRange = NSMakeRange(insertRange.location+attri.length, 0);
    [self scrollRangeToVisible:self.selectedRange];
}

@end

注意注意注意:键盘添加表情或者字符的时候我们可以通过TextView代理方法来获取长度,判断是否超过字数限制,是否可以再添加
粘贴的话,可以通过这个粘贴的方法,算一下字符串长度是否达到最大,多的话,我们也可以截图,通常我们长度是按照转成普通文本([高兴])来算的,所以计算的时候要注意,如果用表情的字符长度计算没有超过最大字数限制,但是实际上转成普通文本形式已经远远超过字数限制就会出现问题

8、输入框高度有高度限制时的动态变化逻辑

#pragma mark - textView高度
- (void)configTextViewHeightWithAnimation:(BOOL)animation {
    @try{
        CGFloat textViewHeight = [self getTopicTextHeight];
        if (textViewHeight < kMinTextViewHeight) {
            [self.tagTextView mas_updateConstraints:^(MASConstraintMaker *make) {
                make.height.mas_equalTo(kMinTextViewHeight);
            }];
        } else if (textViewHeight > kMaxTextViewHeight) {
            [self.tagTextView mas_updateConstraints:^(MASConstraintMaker *make) {
                make.height.mas_equalTo(kMaxTextViewHeight);
            }];
        } else {
            if (textViewHeight - self.tagTextView.height != self.textViewFont.lineHeight) {
                if (!animation) {
                    [self.tagTextView mas_updateConstraints:^(MASConstraintMaker *make) {
                        make.height.mas_equalTo(textViewHeight + self.textViewFont.lineHeight);
                    }];
                } else {
                    [UIView animateWithDuration:0.5 animations:^{
                        [self.tagTextView mas_updateConstraints:^(MASConstraintMaker *make) {
                            make.height.mas_equalTo(textViewHeight + self.textViewFont.lineHeight);
                        }];
                        [self.tagTextView.superview layoutIfNeeded];
                    }];
                }
            }
        }
        [self.tagTextView scrollRangeToVisible:self.tagTextView.selectedRange];
    }@catch (NSException *exception) {
        NSLog(@"moments catched a exception: %@",exception.description);
    }
    
}

- (CGFloat)getTopicTextHeight {
    CGRect rect = [kSafeStr(self.tagTextView.attributedText.string) boundingRectWithSize:CGSizeMake(kScreenWidth - 20, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:15]} context:nil];
    return rect.size.height;
}

9、输入框输入或者粘贴的时候,上下抖动问题的处理

实际上在输入表情或者删除,复制粘贴等操作时,使用scrollRangeToVisible方法是可以解决抖动问题的,如果还抖可能就是因为,我们在添加完字符后,使用完scrollRangeToVisible方法后,又计算TextView高度动态改变了高度这时就又有了偏差,所以在计算和设置TextView的高度的方法最后再加一次这个代码,就能解决掉

10、上传服务器

最后的步骤就是将输入框中的文本,上传服务器
例如输入框中是:了,出不了门,玩玩游戏,还是挺的
我们通过方法
[[JYMomentsEmojiManager sharedInstance] exchangeTextFromEmojiTextView:self.textView];
获取到富文本字符串:[下雨]了,出不了门,玩玩游戏,还是挺[开心]的
然后就可以上传服务器了

补充:如果TextView是独立的在VC上而不是在键盘顶部,比如发布页页面层级
VC--->UIScrollView--->TextView,可能我们在复制或者剪切的时候,拖动选中区域,跟其他滑动手势会冲突导致划不动,可以如下处理,代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self addNotification];
    [self keepGestureRecognizer];
}
- (void)addNotification{
//监听UIMenuController的显示和隐藏
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willShowEditMenu:) name:UIMenuControllerWillShowMenuNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didHideEditMenu:) name:UIMenuControllerDidHideMenuNotification object:nil];
}
-(void)willShowEditMenu:(id)sender
{
    self.mainScrollView.canCancelContentTouches = NO;
    self.mainScrollView.delaysContentTouches = NO;
}

-(void)didHideEditMenu:(NSNotification *)notifi
{
    self.mainScrollView.canCancelContentTouches = YES;
    self.mainScrollView.delaysContentTouches = YES;
}

#pragma mark - 保证scrollview的手势状态和textview手势状态保持一致
- (void)keepGestureRecognizer {
    __weak typeof(self) weakSelf = self;
    [self.tagTextView addObserverBlockForKeyPath:@"panGestureRecognizer.enabled" block:^(id  _Nonnull obj, id  _Nonnull oldVal, id  _Nonnull newVal) {
        weakSelf.mainScrollView.panGestureRecognizer.enabled = weakSelf.tagTextView.panGestureRecognizer.enabled;
    }];
}
- (void)dealloc {
//记得删除观察着
    [self.tagTextView removeObserver:self forKeyPath:@"panGestureRecognizer.enabled"];
}

将工具类文件放到最后,方便整体预览

这里是一个工具类,通过对外提供接口,可以知道我们需要什么样的功能

#import <Foundation/Foundation.h>
@class JYMomentsEmojiModel;

NS_ASSUME_NONNULL_BEGIN

@interface JYMomentsEmojiManager : NSObject

//临时最近使用的表情数组
@property (nonatomic,copy) NSArray *recentlyEmojiArray;
+ (instancetype)sharedInstance;

//********************存储表情模块**********************************
//存储JSON文件
- (void)saveEmojiDataWithDictionary:(NSDictionary *)dictionary;
//使用本地JSON文件
- (void)useLocalEmojiData;
//获取所有表情
- (NSDictionary *)fetchAllEmojiDictionary;
//获取所有表情name数组
- (NSArray *)fetchAllEmojiNamesArray;

//********************最近使用表情模块**********************************
//评论模块中点击表情后临时存储最近使用表情排序处理(不存储)
- (void)temporaryStorageEmoji:(JYMomentsEmojiModel *)emojiModel;
//存储最近使用表情数组
- (void)saveRecentlyEmojiArr;
//获取最近使用表情数组
- (void)fetchRecentlyEmojiArr;
- (NSMutableArray<JYMomentsEmojiModel *> *)getRecentlyEmojiArray;

//********************转换表情模块**********************************
//表情字符串原文本转表情文本 (例如:[OK]了吗 => 👌🏻了吗) pageid是为了埋点
- (NSAttributedString *)fetchEmojiContentWithEmojiString:(NSString *)emojiString isWebp:(BOOL)isWebp font:(UIFont *)font pageId:(NSString *)pageId;
//通过 表情模型 获取 表情文本字符串(例如:👌🏻 => [OK])
- (NSAttributedString *)imageStringFromModel:(JYMomentsEmojiModel *)model isWebp:(BOOL)isWebp font:(UIFont *)font;
//通过输入框表情文本转换成字符串文本输出(例如:👌🏻了吗 => [OK]了吗)
- (NSAttributedString *)exchangeTextFromEmojiTextView:(YYTextView *)textView;
- (NSAttributedString *)exchangeTextFromEmojiTextView:(YYTextView *)textView textRange:(NSRange)textRange;
@end

NS_ASSUME_NONNULL_END
#import "JYMomentsEmojiManager.h"
#import "JYMomentsEmojiModel.h"
#import <SDWebImage/UIImageView+WebCache.h>
#import <YYImage/YYImage.h>

static NSString *const storeLocalEmojiPath = @"emoji";
static NSString *const storeEmojiPath = @"/hellobike_moments_emojiDictionary";
@interface JYMomentsEmojiManager()
//表情字典
@property (nonatomic,copy) NSDictionary *allEmojiDictionary;
@property (nonatomic,strong) NSMutableArray *recentlyEmojiArr;
@property (nonatomic,strong) NSArray *emojiNames;
@end

@implementation JYMomentsEmojiManager

+ (instancetype)sharedInstance{
    static JYMomentsEmojiManager *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[JYMomentsEmojiManager alloc] init];
    });
    return instance;
}

#pragma mark ---public Method
//使用本地JSON文件
- (void)useLocalEmojiData{
    NSString *path = [JYMomentsBundle() pathForResource:storeLocalEmojiPath ofType:@"json"];
    NSData *jsonData = [[NSData alloc] initWithContentsOfFile:path];
    NSError *error;
    NSDictionary *jsonObj = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers error:&error];
    if (jsonObj && !error) {
        self.emojiNames = jsonObj[@"emojiNames"];
        [[NSUserDefaults standardUserDefaults] setObject:self.emojiNames forKey:kMomentsEmojiNamesArray];
        [[NSUserDefaults standardUserDefaults] synchronize];
        self.allEmojiDictionary = [self dictionaryToModelWithDictionary:jsonObj[@"emojis"]];
    }
}
//存储表情包数据文件
- (void)saveEmojiDataWithDictionary:(NSDictionary *)dictionary{
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSString *filepath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
    NSString *newPath = [filepath stringByAppendingPathComponent:storeEmojiPath];
    if ([fileManager fileExistsAtPath:newPath]) {
        [fileManager removeItemAtPath:newPath error:nil];
    }
    //存储数据
    self.allEmojiDictionary = [self dictionaryToModelWithDictionary:dictionary[@"emojis"]];
    self.emojiNames = dictionary[@"emojiNames"];
    [[NSUserDefaults standardUserDefaults] setObject:self.emojiNames forKey:kMomentsEmojiNamesArray];
    [[NSUserDefaults standardUserDefaults] synchronize];
    
    NSData *data = [NSKeyedArchiver archivedDataWithRootObject:self.allEmojiDictionary];
    [data writeToFile:newPath atomically:YES];
    
    //存储版本号
    [[NSUserDefaults standardUserDefaults] setInteger:[dictionary[@"emojiVersion"] intValue] forKey:kMomentsEmojiVersion];
    [[NSUserDefaults standardUserDefaults] synchronize];
}

//评论模块中点击后临时存储最近使用表情
- (void)temporaryStorageEmoji:(JYMomentsEmojiModel *)emojiModel{
    BOOL isHave = NO;
    NSInteger index = 0;
    for (JYMomentsEmojiModel *model in self.recentlyEmojiArr) {
        //根据表情名字判断是否是同一个表情去重处理
        if ([emojiModel.code isEqualToString:model.code]) {
            isHave = YES;
            index = [self.recentlyEmojiArr indexOfObject:model];
            break;
        }
    }
    if (!isHave) {
        [self.recentlyEmojiArr jy_insertObject:emojiModel atIndex:0];
    }else{
        [self.recentlyEmojiArr jy_removeObjectAtIndex:index];
        [self.recentlyEmojiArr jy_insertObject:emojiModel atIndex:0];
    }
    //截取前7个存储
    if (self.recentlyEmojiArr.count > kMomentsEmojiRecentlyNumber) {
        self.recentlyEmojiArr = [[self.recentlyEmojiArr jy_subarrayWithRange:NSMakeRange(0, kMomentsEmojiRecentlyNumber)] mutableCopy];
    }
}
//存储最近使用表情数组
- (void)saveRecentlyEmojiArr{
    if (self.recentlyEmojiArr.count > 0) {
        NSMutableArray *arr = [NSMutableArray array];
        for (JYMomentsEmojiModel *model in self.recentlyEmojiArr) {
            NSData *data = [NSKeyedArchiver archivedDataWithRootObject:model];
            [arr addObject:data];
        }
        [[NSUserDefaults standardUserDefaults] setObject:arr forKey:kMomentsEmojiRecentlyUseArray];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
}

- (NSArray *)recentlyEmojiArray{
    return [self.recentlyEmojiArr copy];
}

//JSON文件更新同步更新最近使用表情的model
- (void)refreshRecentlyEmojiArrWithArray:(NSArray *)array{
    if (array.count > 0) {
        self.recentlyEmojiArr = [array mutableCopy];
        NSMutableArray *arr = [NSMutableArray array];
        for (JYMomentsEmojiModel *model in array) {
            NSData *data = [NSKeyedArchiver archivedDataWithRootObject:model];
            [arr addObject:data];
        }
        [[NSUserDefaults standardUserDefaults] setObject:arr forKey:kMomentsEmojiRecentlyUseArray];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
}
//获取最近使用表情数组
- (void)fetchRecentlyEmojiArr{
    self.recentlyEmojiArr = [self getRecentlyEmojiArray];
}

- (NSMutableArray<JYMomentsEmojiModel *> *)getRecentlyEmojiArray{
    NSMutableArray *arr = [NSMutableArray new];
    NSArray *dataArr = [[NSUserDefaults standardUserDefaults] objectForKey:kMomentsEmojiRecentlyUseArray];
    for (NSData *data in dataArr) {
        [arr addObject:[NSKeyedUnarchiver unarchiveObjectWithData:data]];
    }
    return arr;
}

//字符串文本 转 表情文本
- (NSAttributedString *)fetchEmojiContentWithEmojiString:(NSString *)emojiString isWebp:(BOOL)isWebp font:(UIFont *)font pageId:(NSString *)pageId{
    NSArray *matchArray = [self fetchRangeWithContentString:emojiString];
    
    //用来存放字典,字典中存储的是图片和图片对应的位置
    NSMutableArray *imageArray = [NSMutableArray arrayWithCapacity:matchArray.count];

    //根据匹配范围来用图片进行相应的替换
    for(NSTextCheckingResult *match in matchArray) {
        //获取数组元素中得到range
        NSRange range = [match range];
        //获取原字符串中对应的值
        NSString *subStr = [emojiString substringWithRange:range];
        
        //把图片和图片对应的位置存入字典中
        NSMutableDictionary *imageDic = [NSMutableDictionary dictionaryWithCapacity:matchArray.count];
        [imageDic setObject:subStr forKey:@"emojiStr"];
        [imageDic setObject:[NSValue valueWithRange:range] forKey:@"range"];
        //把字典存入数组中
        [imageArray addObject:imageDic];
    }
    return [self fetchEmojiContentWithString:emojiString imageDataArr:imageArray isWebp:isWebp font:font pageId:pageId];
}


//通过 表情模型 获取 表情文本字符串
- (NSAttributedString *)imageStringFromModel:(JYMomentsEmojiModel *)model isWebp:(BOOL)isWebp font:(UIFont *)font{
    
    NSURL *url = [NSURL URLWithString:[[model.url fetchThumbImageWidth:kEmojiWidth andHeight:kEmojiWidth isWebp:isWebp] stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]];
//    YYAnimatedImageView *insertImageView = [[YYAnimatedImageView alloc] init];
    UIImageView *insertImageView = [[UIImageView alloc] init];
    insertImageView.contentMode = UIViewContentModeScaleAspectFit;
    insertImageView.tag = [model.code integerValue];
    insertImageView.bounds = CGRectMake(0, 0, font.lineHeight, font.lineHeight);
    [insertImageView sd_setImageWithURL:url completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
        
    }];
    NSMutableAttributedString *imageStr = [NSMutableAttributedString yy_attachmentStringWithContent:insertImageView contentMode:UIViewContentModeScaleToFill attachmentSize:CGSizeMake(font.lineHeight, font.lineHeight) alignToFont:font alignment:YYTextVerticalAlignmentCenter];
    return imageStr;
}

//表情文本转换成字符串文本
- (NSAttributedString *)exchangeTextFromEmojiTextView:(YYTextView *)textView{
    NSArray *arr = textView.textLayout.attachments;
    NSArray<NSValue *> *values = textView.textLayout.attachmentRanges;
    
    NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:textView.attributedText];
    for (long int i = arr.count-1; i >= 0; i --) {
        NSValue *value = [values jy_objectAtIndex:i];
        NSRange range = value.rangeValue;
        
        YYTextAttachment *attachment = [arr jy_objectAtIndex:i];
        UIImageView *imageView = attachment.content;
        
        NSString *replaceStr = @"";
        for (NSString *key in self.allEmojiDictionary.allKeys) {
            JYMomentsEmojiModel *model = self.allEmojiDictionary[key];
            if ([model.code integerValue] == imageView.tag) {
                replaceStr = model.name;
                break;
            }
        }
        
        [mutableAttributedString jy_replaceCharactersInRange:range withString:replaceStr];
    }
    return [mutableAttributedString copy];
}

//表情文本转换成字符串文本
- (NSAttributedString *)exchangeTextFromEmojiTextView:(YYTextView *)textView textRange:(NSRange)textRange{
    YYTextView *tempTextView = [[YYTextView alloc] init];
    tempTextView.attributedText = textView.attributedText;;
    NSArray *arr = textView.textLayout.attachments;
    NSArray<NSValue *> *values = textView.textLayout.attachmentRanges;
    
    NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:tempTextView.attributedText];
    for (long int i = arr.count-1; i >= 0; i --) {
        NSValue *value = [values jy_objectAtIndex:i];
        NSRange range = value.rangeValue;
        
        if (textRange.location <= range.location && textRange.location + textRange.length >= range.location + range.length) {
            
            YYTextAttachment *attachment = [arr jy_objectAtIndex:i];
            UIImageView *imageView = attachment.content;
            NSString *replaceStr = @"";
            for (NSString *key in self.allEmojiDictionary.allKeys) {
                JYMomentsEmojiModel *model = self.allEmojiDictionary[key];
                if ([model.code integerValue] == imageView.tag) {
                    replaceStr = model.name;
                    break;
                }
            }
            [mutableAttributedString jy_replaceCharactersInRange:range withString:replaceStr];
        }
    }
    NSInteger replaceStringLength = mutableAttributedString.string.length - textView.attributedText.string.length + textRange.length;
    NSRange copyRange = NSMakeRange(textRange.location, replaceStringLength);
    NSAttributedString *attString = [mutableAttributedString jy_attributedSubstringFromRange:copyRange];
    return attString;
}

- (NSDictionary *)fetchAllEmojiDictionary{
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSString *filepath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
    NSString *newPath = [filepath stringByAppendingPathComponent:storeEmojiPath];
    if ([fileManager fileExistsAtPath:newPath]) {
        NSData *data = [NSData dataWithContentsOfFile:newPath];
        NSDictionary *dic = [NSKeyedUnarchiver unarchiveObjectWithData:data];
        if (dic && dic.allKeys.count > 0) {
            self.allEmojiDictionary = dic;
            return dic;
        }
    }
    [self useLocalEmojiData];
    return self.allEmojiDictionary;
}


//获取所有表情name数组
- (NSArray *)fetchAllEmojiNamesArray{
    return [[NSUserDefaults standardUserDefaults] objectForKey:kMomentsEmojiNamesArray];
}

#pragma mark --private Method
//从后往前替换字符串
- (NSAttributedString *)fetchEmojiContentWithString:(NSString *)content imageDataArr:(NSArray *)imageDataArr isWebp:(BOOL)isWebp font:(UIFont *)font pageId:(NSString *)pageId{
    
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:content];
    for (long int i = (imageDataArr.count - 1); i >= 0; i --) {
        NSDictionary *dictionary = [imageDataArr jy_objectAtIndex:i];
        NSString *subStr = dictionary[@"emojiStr"];
        NSRange range;
        [dictionary[@"range"] getValue:&range];
        
        if ([self.allEmojiDictionary.allKeys containsObject:subStr]) {
            JYMomentsEmojiModel *model = self.allEmojiDictionary[subStr];
            
            NSURL *url = [NSURL URLWithString:[[model.url fetchThumbImageWidth:kEmojiWidth andHeight:kEmojiWidth isWebp:isWebp] stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]];
            UIImageView *insertImageView = [[UIImageView alloc] init];
            insertImageView.contentMode = UIViewContentModeScaleAspectFit;
            insertImageView.tag = [model.code integerValue];
            insertImageView.bounds = CGRectMake(0, 0, font.lineHeight, font.lineHeight);
            [insertImageView sd_setImageWithURL:url completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
                
            }];
            NSMutableAttributedString *imageStr = [NSMutableAttributedString yy_attachmentStringWithContent:insertImageView contentMode:UIViewContentModeScaleToFill attachmentSize:CGSizeMake(font.lineHeight, font.lineHeight) alignToFont:font alignment:YYTextVerticalAlignmentCenter];
            //进行替换
            [attributedString jy_replaceCharactersInRange:range withAttributedString:imageStr];
            
            if (pageId.length > 0) {
                JYTrackExposeInfo *exposeinfo = [[JYTrackExposeInfo alloc] init];
                exposeinfo.categoryId = @"alpha";
                exposeinfo.pageId = kSafeStr(pageId);
                exposeinfo.moduleId = @"community_HAHA_exposure";
                exposeinfo.contentId = @"community_HAHA_exposure";
                exposeinfo.exposureTimes = @"1";
                [JYTracker exposeEventTrackWithExposeInfo:exposeinfo businessInfo:@{@"numHaha":model.code}];
            }
        }
    }
    return attributedString;
}

//通过正则表达式匹配到范围
- (NSArray *)fetchRangeWithContentString:(NSString *)string{
    //正则表达式
    NSString * pattern = @"(\\[).*?(\\])";
    NSError *error = nil;
    NSRegularExpression *re = [[NSRegularExpression alloc] initWithPattern:pattern options:0 error:&error];
    if (!re) {
        NSLog(@"%@", [error localizedDescription]);
    }
    //通过正则表达式来匹配字符串
    return [re matchesInString:string options:0 range:NSMakeRange(0, string.length)];
}

//字典中套的字典转模型
- (NSDictionary *)dictionaryToModelWithDictionary:(NSDictionary *)dictionary{
    __block NSMutableArray *muteablArr = [NSMutableArray arrayWithArray:self.recentlyEmojiArr];
    NSMutableDictionary *dict = [NSMutableDictionary dictionary];
    for (NSString *key in dictionary.allKeys) {
        if (key.length > 0 && ![dict.allKeys containsObject:key]) {
            JYMomentsEmojiModel *model = [JYMomentsEmojiModel mj_objectWithKeyValues:dictionary[key]];
            
            [self.recentlyEmojiArr enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                JYMomentsEmojiModel *item = (JYMomentsEmojiModel *)obj;
                if ([model.name isEqualToString:item.name]) {
                    [muteablArr jy_replaceObjectAtIndex:idx withObject:model];
                    *stop = YES;
                }
            }];
            [dict setObject:model forKey:key];
        }
    }
    [self refreshRecentlyEmojiArrWithArray:muteablArr];
    return [dict copy];
}

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

推荐阅读更多精彩内容