iOS 输入框如何限制字符长度和emoji

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类,我们可以利用这个类的方法,向CharacterString类添加一些帮助属性。这里会:

  • 检查一个字符是否恰好是将作为表情符号显示的一个标量
  • 检测一个字符是否由多个标量组成,这些标量是否将被组成一个表情符号。
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;
}

四.阅读延伸

Unicode与JavaScript详解
从Emoji的限制到Unicode编码

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

推荐阅读更多精彩内容