FJFTextInputIntercepter拦截器(通知)
FJFTextInputIntercepter拦截器(通知和代理)
我们经常会遇到这样的需求,比如手机输入框限制11
位数字,个人简介最多不超过英文最多100
个字,中文最多50
个字,个人昵称不能使用emoji
表情等等。
在上一篇文章了解了编码的基础上,我们来看下如何解决这些需求问题。
一. 如何判断emoji
表情
我们可以知道emoji
表情其实是由一个或多个Unicode
编码点组成的字符串,而且emoji
表情对应这一定的码元范围。
因此这里如果要判断一个字符串里面是否包含emoji
表情,就要解决两个问题:
- 如何准确的将该字符串分为独立相关子字符串
- 如何判断子字符串是否为
emoji
1.如何准确的将该字符串分为独立相关子字符串
在iOS中NSString
可以通过enumerateSubstringsInRange:options:usingBlock:
方法。这个方法把Unicode
抽象的地方隐藏了,能让你更轻松的循环字符串里面的组合字符串,单词,行,句子,段落。
你甚至可以加上NSStringEnumerationLocalized
这个选项,这样可以在确定词语间和句子间的边界时把用户所在区域考虑进去。要遍历单个字符,可以将参数指定为NSStringEnumerationByComposedCharacterSequences
按字符顺序,依次遍历出相关子字符串。
这里表明了苹果想让我们把字符串看做子字符串的集合,因为:
1.单个unichar
太小,不足以代表一个真正的Unicode
字符。
2.一些字符由多个unicode
码点组成。
2.如何判断子字符串是否为emoji
emoji
表情对应着一定的码元范围,因此可以通过的判断字符的unicode
编码来判断改字符是否为emoji
编码。
+ (BOOL)validateContainsEmoji:(NSString *)string {
__block BOOL returnValue = NO;
[string enumerateSubstringsInRange:NSMakeRange(0, [string length])
options:NSStringEnumerationByComposedCharacterSequences
usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
const unichar hs = [substring characterAtIndex:0];
if (0xd800 <= hs && hs <= 0xdbff) {
if (substring.length > 1) {
const unichar ls = [substring characterAtIndex:1];
const int uc = ((hs - 0xd800) * 0x400) + (ls - 0xdc00) + 0x10000;
if (0x1d000 <= uc && uc <= 0x1f77f) {
returnValue = YES;
}
}
} else if (substring.length > 1) {
const unichar ls = [substring characterAtIndex:1];
if (ls == 0x20e3) {
returnValue = YES;
}
} else {
if (0x2100 <= hs && hs <= 0x27ff) {
returnValue = YES;
} else if (0x2B05 <= hs && hs <= 0x2b07) {
returnValue = YES;
} else if (0x2934 <= hs && hs <= 0x2935) {
returnValue = YES;
} else if (0x3297 <= hs && hs <= 0x3299) {
returnValue = YES;
} else if (hs == 0xa9 || hs == 0xae || hs == 0x303d || hs == 0x3030 || hs == 0x2b55 || hs == 0x2b1c || hs == 0x2b1b || hs == 0x2b50) {
returnValue = YES;
}
}
}];
return returnValue;
}
但这里有个问题,就是emoji
对应的码元范围会随着系统版本的而改变,因为每次版本更新可能会添加新的emoji
表情,因此这个判断方法,需要一直更新,那有没有一种好的方法可以长期有效判断呢。
在我们长期的印象中,emoji
表情都是带有色彩的,苹果键盘自带的emoji
表情,从现在看来一直都是带有色彩的,而常规的文本一般都是黑色的,因此这里可以有如下解决方案:
我们创建一个背景色为黑色
UILabel
,然后将字符串赋值给该UILabel
,接着利用Core Graphic
生成该UILabel
的截图。对截图进行解析获取对应的像素的颜色值,依次遍历每个像素里面的颜色值
RGB
,接着将RGB
颜色值转换为HSB
(H:色相 S:饱和度 B:亮度
),通过B值
亮度是否大于0
,来判断当前是否包含emoji
表情。
具体实现如下:
+ (BOOL)fjf_stringContainsEmoji:(NSString *)string {
//argument can be character or entire string
UILabel *characterRender = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 1, 1)];
characterRender.text = string;
characterRender.backgroundColor = [UIColor blackColor];//needed to remove subpixel rendering colors
[characterRender sizeToFit];
CGRect rect = [characterRender bounds];
UIGraphicsBeginImageContextWithOptions(rect.size,YES,0.0f);
CGContextRef contextSnap = UIGraphicsGetCurrentContext();
[characterRender.layer renderInContext:contextSnap];
UIImage *capturedImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
CGImageRef imageRef = [capturedImage CGImage];
NSUInteger width = CGImageGetWidth(imageRef);
NSUInteger height = CGImageGetHeight(imageRef);
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
unsigned char *rawData = (unsigned char*) calloc(height * width * 4, sizeof(unsigned char));
NSUInteger bytesPerPixel = 4;
NSUInteger bytesPerRow = bytesPerPixel * width;
NSUInteger bitsPerComponent = 8;
CGContextRef context = CGBitmapContextCreate(rawData, width, height,
bitsPerComponent, bytesPerRow, colorSpace,
kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGColorSpaceRelease(colorSpace);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
CGContextRelease(context);
BOOL colorPixelFound = NO;
int x = 0;
int y = 0;
while (y < height && !colorPixelFound) {
while (x < width && !colorPixelFound) {
NSUInteger byteIndex = (bytesPerRow * y) + x * bytesPerPixel;
CGFloat red = (CGFloat)rawData[byteIndex];
CGFloat green = (CGFloat)rawData[byteIndex+1];
CGFloat blue = (CGFloat)rawData[byteIndex+2];
CGFloat h, s, b, a;
UIColor *c = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
[c getHue:&h saturation:&s brightness:&b alpha:&a];
b /= 255.0f;
if (b > 0) {
colorPixelFound = YES;
}
x++;
}
x = 0;
y++;
}
return colorPixelFound;
}
当然这个方法只适合对少量的字符串,因为如果字符串比较长,利用该方法进行解析判断会耗费CPU
资源。
因此我们可以结合上面的emoji
对应的码元范围和下面是否包含颜色来判断,对应字符串是否包含emoji
表情,这样准确性会高点,但对于一些第三方的键盘如搜狗输入法里面的一些表情,还是不能很好过滤。
如果是swift
语言,因为Swift 5.0
,它带有一个新的Unicode.Scalar.Properties
类,我们可以利用这个类的方法,向Character
和String
类添加一些帮助属性。这里会:
- 检查一个字符是否恰好是将作为表情符号显示的一个标量
- 检测一个字符是否由多个标量组成,这些标量是否将被组成一个表情符号。
extension String {
/// 是否为单个emoji表情
var isSingleEmoji: Bool {
returncount==1&&containsEmoji
}
/// 包含emoji表情
var containsEmoji: Bool {
returncontains{ $0.isEmoji}
}
/// 只包含emoji表情
var containsOnlyEmoji: Bool {
return!isEmpty&&!contains{!$0.isEmoji}
}
/// 提取emoji表情字符串
var emojiString: String {
returnemojis.map{String($0) }.reduce("",+)
}
/// 提取emoji表情数组
varemojis: [Character] {
returnfilter{ $0.isEmoji}
}
/// 提取单元编码标量
var emojiScalars: [UnicodeScalar] {
returnfilter{ $0.isEmoji}.flatMap{ $0.unicodeScalars}
}
}
extension Character {
/// 简单的emoji是一个标量,以emoji的形式呈现给用户
var isSimpleEmoji: Bool {
guard let firstProperties = unicodeScalars.first?.properties else {
return false
}
return unicodeScalars.count == 1 &&
(firstProperties.isEmojiPresentation ||
firstProperties.generalCategory == .otherSymbol)
}
/// 检查标量是否将合并到emoji中
var isCombinedIntoEmoji: Bool {
return unicodeScalars.count > 1 &&
unicodeScalars.contains { $0.properties.isJoinControl || $0.properties.isVariationSelector }
}
/// 是否为emoji表情
/// - Note: http://stackoverflow.com/questions/30757193/find-out-if-character-in-string-is-emoji
var isEmoji: Bool {
return isSimpleEmoji || isCombinedIntoEmoji
}
}
同样swift
上的该方法对于第三方键盘上的部分表情判断也没办法做到百分百准确。
二. 如何正确算出中英文字符串长度
比如一段个人简介中经常是禁止输入表情,但允许输入中英文,如果中文要算2
个字符,英文算1
个字符,如何准确的算出,该字符串的长度。
因为这里的汉字算2
个字节,英文算1
个字节,因此应该使用GB_18030_2000
编码来计算字符串的长度。
GB_18030_2000
主要有以下特点:
- 采用变长多字节编码,每个字可以由
1个
、2个
或4个
字节组成。 - 编码空间庞大,最多可定义
161万
个字符。 - 完全支持
Unicode
,无需动用造字区即可支持中国国内少数民族文字、中日韩和繁体汉字以及emoji等字符。
GB_18030_2000
编码:
单字节部分: 对应着
ASCII
码的字母和符号双字节部分: 对应绝大部分的汉字
四字节部分: 收录除了双字节字符外的汉字,还包括
CJK
统一汉字扩充A
在内的GB 13000.1
中的全部字符
我们常用的汉字是3500
个,都包含在双字节部分,因此使用GB_18030_2000
来计算字符串长度可以完美的解决我们的需求。
三. 输入框拦截器(FJFTextInputIntercepter)
基于以上的知识,我写了一个输入框拦截器FJFTextInputIntercepter
,该拦截器可以通过设置对应的参数来对输入框的输入进行限制:
// decimalPlaces 小数 位数
// (当intercepterNumberType 为FJFTextInputIntercepterNumberTypeDecimal 有用)
@property (nonatomic, assign) NSUInteger decimalPlaces;
// inputBlock 输入 回调处理
@property (nonatomic, copy) FJFTextInputIntercepterBlock inputBlock;
// beyoudLimitBlock 超过限制 最大 字符数 回调
@property (nonatomic, copy) FJFTextInputIntercepterBlock beyondLimitBlock;
// emojiAdmitted 是否 允许 输入 表情
@property (nonatomic, assign, getter=isEmojiAdmitted) BOOL emojiAdmitted;
// intercepterNumberType 数字 类型
// FJFTextInputIntercepterNumberTypeNone 默认
// FJFTextInputIntercepterNumberTypeNumberOnly 只允许 输入 数字,emojiAdmitted,decimalPlaces 不起作用
// FJFTextInputIntercepterNumberTypeDecimal 分数 emojiAdmitted 不起作用 decimalPlaces 小数 位数
@property (nonatomic, assign) FJFTextInputIntercepterNumberType intercepterNumberType;
/**
doubleBytePerChineseCharacter 为 NO
字母、数字、汉字都是1个字节 表情是两个字节
doubleBytePerChineseCharacter 为 YES
不允许 输入表情 一个汉字是否代表两个字节 default YES
允许 输入表情 一个汉字代表3个字节 表情代表 4个字节
*/
@property (nonatomic, assign, getter=isDoubleBytePerChineseCharacter) BOOL doubleBytePerChineseCharacter;
这里我用了两种方式来写这个拦截器:
一种是通过输入框的
delegate
方法和输入框文本变化通知,两种方式结合来对输入框的输入进行限制,这种逻辑相对比较简单,因为可以在代理方法里面对输入的字符进行拦截判断。一种是只通过输入框的文本变化通知来对输入框的输入进行限制,这种方式比较麻烦,因为文本变化通知这时候字符已经输入了,因此需要做额外的处理。
1. 通过输入框的delegate
方法和输入框文本变化通知来拦截
- 在输入框的代理方法里面判断当前输入的字符是否满足限制,如果满足限制就可以输入,不满足就不允许输入:
#pragma mark - Delegate Methods
- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string {
NSString *primaryLanguage = [textField.textInputMode primaryLanguage];
return [self isAllowedInputWithReplaceRange:range replaceText:string previousText:textField.text primaryLanguage:primaryLanguage];
}
- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
NSString *primaryLanguage = [textView.textInputMode primaryLanguage];
return [self isAllowedInputWithReplaceRange:range replaceText:text previousText:textView.text primaryLanguage:primaryLanguage];
}
- (BOOL)isAllowedInputWithReplaceRange:(NSRange)replaceRange
replaceText:(NSString *)replaceText
previousText:(NSString *)previousText
primaryLanguage:(NSString *)primaryLanguage {
NSString *newString = [previousText stringByReplacingCharactersInRange:replaceRange withString:replaceText];
// 如果是删除 直接返回true
if (newString.length < previousText.length) {
return true;
}
// 是否 允许 输入
if ([self isAllowedInputWithReplaceText:replaceText previousText:previousText primaryLanguage:primaryLanguage] == false) {
return false;
}
// 是否 超出 限制
if ([self isBeyondLimtWithInputText:newString]) {
if (self.beyondLimitBlock) {
self.beyondLimitBlock(self, previousText);
}
return false;
}
return true;
}
2. 只通过输入框文本变化通知来拦截
- 因为输入框文本变化通知的时候,新的字符已经输入了,因此需要维护一个
先前文本previousText
的变量,然后将现在的输入框文本inputText
和先前文本previousText
做比对,找出此次新增的字符串replaceText
。
// 新添加的字符
- (NSString *)differentTextWithInputText:(NSString *)inputText
previousText:(NSString *)previousText {
// 如果是删除 直接返回true
if (inputText.length < previousText.length) {
return @"";
}
NSString *differentText = nil;
NSMutableArray <NSValue *> *inputSubMarray = [NSMutableArray array];
NSMutableArray <NSValue *> *preSubMarray = [NSMutableArray array];
[inputText enumerateSubstringsInRange:NSMakeRange(0, inputText.length) options:NSStringEnumerationByComposedCharacterSequences usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
[inputSubMarray addObject:[NSValue valueWithRange:substringRange]];
}];
[previousText enumerateSubstringsInRange:NSMakeRange(0, previousText.length) options:NSStringEnumerationByComposedCharacterSequences usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
[preSubMarray addObject:[NSValue valueWithRange:substringRange]];
}];
__block NSValue *startValue = nil;
[inputSubMarray enumerateObjectsUsingBlock:^(NSValue * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSRange subTextRange = [obj rangeValue];
NSString *subText = [inputText substringWithRange:subTextRange];
if (idx < preSubMarray.count) {
NSRange preSubTextRange = [preSubMarray[idx] rangeValue];
NSString *preSubText = [previousText substringWithRange:preSubTextRange];
if ([subText isEqualToString:preSubText] == false) {
startValue = obj;
*stop = true;
}
} else {
startValue = obj;
*stop = true;
}
}];
NSRange startRange = [startValue rangeValue];
if (startRange.location + startRange.length == inputText.length) {
differentText = [inputText substringWithRange:startRange];
} else {
__block NSValue *endValue = nil;
NSArray <NSValue *> *inputReverseSubArray = [[inputSubMarray reverseObjectEnumerator] allObjects];
NSArray <NSValue *> *preReverseSubArray = [[preSubMarray reverseObjectEnumerator] allObjects];
[preReverseSubArray enumerateObjectsUsingBlock:^(NSValue * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSRange preTextRange = [obj rangeValue];
NSString *preSubText = [previousText substringWithRange:preTextRange];
NSValue *inputValue = inputReverseSubArray[idx];
if (preTextRange.location >= startRange.location) {
NSRange inputTextRange = [inputValue rangeValue];
NSString *inputSubText = [inputText substringWithRange:inputTextRange];
if ([preSubText isEqualToString:inputSubText] == false) {
endValue = inputValue;
*stop = true;
}
} else {
endValue = inputValue;
*stop = true;
}
}];
NSRange endRange = [endValue rangeValue];
NSInteger differLength = endRange.location + endRange.length - startRange.location;
NSRange differRange = NSMakeRange(startRange.location, differLength);
differentText = [inputText substringWithRange:differRange];
}
return differentText;
}
- 通过此次新增的字符串
replaceText
去判断是否满足输入限制的要求,如果不满足,直接将先前文本previousText
赋值给当前输入框,然后重新定位光标的位置(因为有可能是在中间进行输入)。
- (void)updateTextViewWithTextView:(UITextView *)textView {
NSString *inputText = textView.text;
NSString *primaryLanguage = [textView.textInputMode primaryLanguage];
NSInteger corsorStartPos = [textView offsetFromPosition:textView.beginningOfDocument toPosition:textView.selectedTextRange.start];
// 如果 之前 文本 超出 字符限制
if ([self isBeyondLimtWithInputText:self.previousText]) {
textView.text = [self handleInputTextWithInputText:inputText];
self.previousText = textView.text;
}
// 如果 当前字符串 小于 之前字符串(可能删除,也可能是特殊...造成)
if (inputText.length < self.previousText.length) {
if ([self isSpecialDotWithInputText:inputText previousText:self.previousText]) {
NSInteger replaceTextLength = self.previousText.length - inputText.length;
textView.text = self.previousText;
[FJFTextInputIntercepter cursorLocation:textView index:corsorStartPos + replaceTextLength];
}
}
// 不允许 输入
else if ([self isAllowedInputWithInputText:inputText previousText:self.previousText primaryLanguage:primaryLanguage] == false) {
NSInteger replaceTextLength = inputText.length - self.previousText.length;
textView.text = self.previousText;
[FJFTextInputIntercepter cursorLocation:textView index:corsorStartPos - replaceTextLength];
}
self.previousText = textView.text;
if (self.inputBlock) {
self.inputBlock(self, textView.text);
}
}
- 然后对一些异常情况进行处理兼容,比如说苹果输入法遇到三个'.',会直接变成
"…"
,本来应该是3
个字符长度,就变成了1
个字符长度,对这些情况需要处理兼容。
// 释放 是特殊的点点符号
- (BOOL)isSpecialDotWithInputText:(NSString *)inputText
previousText:(NSString *)previousText {
// 如果 当前字符串 小于 之前输入字符串
if (inputText.length < previousText.length) {
NSString *replaceText = [self differentTextWithInputText:previousText previousText:inputText];
if (replaceText.length > 1) {
if (self.intercepterNumberType == FJFTextInputIntercepterNumberTypeDecimal ||
self.intercepterNumberType == FJFTextInputIntercepterNumberTypeNumberOnly) {
if ([inputText containsString:@"…"]) {
return true;
}
} else {
__block BOOL isSpecialDot = true;
[replaceText enumerateSubstringsInRange:NSMakeRange(0, replaceText.length) options:NSStringEnumerationByComposedCharacterSequences usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
if ([substring isEqualToString:@"."] == false) {
isSpecialDot = false;
*stop = true;
}
}];
return isSpecialDot;
}
}
}
return false;
}