iOS 文本输入控制(献上框架)

一、痛点

我们在业务开发中,往往会遇到需要限制文本输入的需求,比如只能输入数字、不能输入空格,稍微复杂一点的比如小数点后最多两位的价格输入。当然,若你的正则表达式玩儿得很溜,这些并不是难题。但是我们仍然需要设置代理、实现代理,然后写上一堆的判断逻辑,总是有一些奇奇怪怪的问题导致最终结果不能很快完美呈现。

于是,我写下这篇文章,总结一下关于UITextFieldUITextView输入控制的那些事儿,并且还献上一个框架。

DEMO地址带用法

该框架在挺久之前就已经做出来了,发出来过后有些朋友挺感兴趣,但是就是bug比较多。所以这些天重构了一下,修复了很多问题,优化了体验。

二、解决办法

对于UITextField监听文本变化的方式一般分为两种,一种是输入已经绘制到界面上之后,一种是还未绘制之前。

之后

[textfield addTarget:self action:@selector(textChange:) forControlEvents:UIControlEventEditingChanged];

- (void)textChange:(id)obj {
    NSLog(@"%@", [obj valueForKey:@"text"]);
}

对于这种方法,我们能对已经绘制到textfield的文本进行一些逻辑判断,经过替换、移除、截取等操作就能实现对文本的控制。
当我们设定了某些不能输入的字符,就需要查找出来移除,然后若对长度有要求,还得再次判断,字符串替换过程有些复杂,而且还会造成不可控的字符改变(用户可能是无意识的)。

之前

textfield.delegate = self;

- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string {
        //计算如果允许输入的结果字符串
        NSString *nowStr = [textField valueForKey:@"text"];
        NSMutableString *resultStr = [NSMutableString stringWithString:nowStr];
        if (string.length == 0) {
            //删除
            [resultStr deleteCharactersInRange:range];
        } else {
            if (range.length == 0) {
                //插入
                [resultStr insertString:string atIndex:range.location];
            } else {
                //替换
                [resultStr replaceCharactersInRange:range withString:string];
            }
        }
        //根据拿到的 resultStr 判断是否包含非法字符,是否超长(可使用正则表达式处理)
        ......
}

这种方式就是在文本绘制之前会走的代理方法,我们可以在里面将非法字符扼杀在摇篮中。

提前监听在使用索引功能时弊端

但是在处理带索引输入的时候,会出现下图情况:

iOS 文本输入控制(献上框架)_第1张图片

看到了么,我们此刻是输入中文,而被选中的字符(也就是我们的拼音)已经输入在了textFiled里面,它仍然会走textField: shouldChangeCharactersInRange: replacementString:代理方法和- (void)textChange:(id)obj回调。
以下两种情况,在代理方法里面处理会出现问题:

  • 在这里判断了长度:比如限制最多输入8个字符,我们还想在打几个拼音就会看到textFiled里面文本内容不会增加了,也就是无法继续输入,因为此时jian shu已经占了8个字符,而我们可能是想输入8个汉字。
  • 在这里限制了非法字符:比如在该代理方法限制空格为非法字符,那么在输入到jian s的时候,就会出现点击无反应,因为此时已经有非法字符出现,文本不允许录入。而当我们想要退格的时候,发现仍然不能动,此刻已经是非法状态。

所以,这种情况只能在上述的 [textfield addTarget:self action:@selector(textChange:) forControlEvents:UIControlEventEditingChanged];方式处理。代码大致如下:

- (void)textChange:(id)obj {
    //无选中字符情况
    if ([obj valueForKey:@"markedTextRange"] == nil) {
        NSString *currentText = [obj valueForKey:@"text"];
        //去除非法字符-空格
        if ([currentText containsString:@" "]) {
            currentText = [currentText stringByReplacingOccurrencesOfString:@" " withString:@""];
        }
        //判断是否超长
        if (currentText.length > 8) {
            [obj setValue:[currentText substringToIndex:8] forKey:@"text"];
        } else {
            [obj setValue:currentText forKey:@"text"];
        }
    }
}

点击索引字符不走代理监听方法

就在上图中,若我们点击索引栏的建树等字符时,textField会直接绘制,而此刻发现textField: shouldChangeCharactersInRange: replacementString:代理方法没有回调(在使用索引输入英文单词时一样)。

这种情况我们就得按照业务需求处理。

若需要输入英文或者中午的描述性字符的时候,一般做的非法字符限制比较少,更多的是做长度限制,就使用[textfield addTarget:self action:@selector(textChange:) forControlEvents:UIControlEventEditingChanged];方式处理(点击索引字符会走该方法)。

若只能输入英文、特殊字符、数字等,就将键盘的索引关掉,并且将键盘种类更改,让用户不能切换到中文键盘(因为中文键盘自带索引,关不掉),方法如下:

//关索引
tf.autocorrectionType = UITextAutocorrectionTypeNo;
//换键盘
tf..keyboardType = UIKeyboardTypeASCIICapable;

UITextView 的处理方法和 UITextField 的处理差不多,这里就不在赘述。

结论

由此可见,对文本输入的控制需要在两种监听文本输入方法间灵活处理,为了提高开发效率,本人对其做了封装,下面解释一下YBInputControl框架的设计思路和设计模式。

三、YBInputControl 框架解读(难点是方法重定向)

DEMO地址带用法

首先,为了减少耦合,使用了分类的方式,给UITextFieldUITextView添加了一个属性:

@interface UITextField (YBInputControl) 
@property (nonatomic, strong, nullable) YBInputControlProfile *yb_inputCP;
@end
@interface UITextView (YBInputControl) 
@property (nonatomic, strong, nullable) YBInputControlProfile *yb_inputCP;
@end

YBInputControlProfile类包含了一系列的配置:

/** 限制输入长度,NSUIntegerMax表示不限制(默认不限制) */
@property (nonatomic, assign) NSUInteger maxLength;
/** 限制输入的文本类型(单选,在内部其实是配置了regularStr属性) */
@property (nonatomic, assign) YBTextControlType textControlType;
/** 限制输入的正则表达式字符串 */
@property (nonatomic, copy, nullable) NSString *regularStr;
/** 文本变化回调(observer为UITextFiled或UITextView)*/
@property (nonatomic, copy, nullable) void(^textChanged)(id observe);
/** 添加文本变化监听 */
- (void)addTargetOfTextChange:(id)target action:(SEL)action;
......

当然,现在你不用知道内部实现,从结构的设计来看,应该很轻松的想到使用方法就是给 yb_inputCP 属性赋值,YBInputControlProfile类包含了诸如长度、文本限制类型、直接输入正则表达式,文本变化回调等,文本现在类型目前加的不多,大概观感是这样的:

typedef NS_ENUM(NSInteger, YBTextControlType) {
    YBTextControlType_none, //无限制
    
    YBTextControlType_number,   //数字
    YBTextControlType_letter,   //字母(包含大小写)
    YBTextControlType_letterSmall,  //小写字母
    YBTextControlType_letterBig,    //大写字母
    YBTextControlType_number_letterSmall,   //数字+小写字母
    YBTextControlType_number_letterBig, //数字+大写字母
    YBTextControlType_number_letter,    //数字+字母
    
    YBTextControlType_excludeInvisible, //去除不可见字符(包括空格、制表符、换页符等)
    YBTextControlType_price,    //价格(小数点后最多输入两位)
};

这里我也考虑过使用多选枚举处理,但是后来发现使用体验并不好,所以还是搞成单选,多列举一些也不碍事。

大致的结构就是这样,很简单,下面解析一下内部实现(主要实现 UITextField 和 UITextView 差不多)。

UITextField分类中yb_inputCPgettersetter实现如下:

- (void)setYb_inputCP:(YBInputControlProfile *)yb_inputCP {
    @synchronized(self) {
        if (yb_inputCP && [yb_inputCP isKindOfClass:YBInputControlProfile.self]) {
            objc_setAssociatedObject(self, key_Profile, yb_inputCP, OBJC_ASSOCIATION_RETAIN);
            
            self.delegate = self;
            self.keyboardType = yb_inputCP.keyboardType;
            self.autocorrectionType = yb_inputCP.autocorrectionType;
            yb_inputCP.textChangeInvocation || yb_inputCP.textChanged ? [self addTarget:self action:@selector(textFieldDidChange:) forControlEvents : UIControlEventEditingChanged]:nil;
        } else {
            objc_setAssociatedObject(self, key_Profile, nil, OBJC_ASSOCIATION_RETAIN);
        }
    }
}
- (YBInputControlProfile *)yb_inputCP {
    return objc_getAssociatedObject(self, key_Profile);
}

代码逻辑很简单,既是对当前textFiled关联一个yb_inputCP属性,并且将代理设为自己self.delegate = self;,其实到这里大概也能猜到,该框架主要是通过分类里面的代理回调做功能。

但是有一个问题值得注意,框架是通过接收来自UITextFieldDelegate代理的方法,如果使用者在外部也想要获取某些代理回调怎么办,如果不采用特殊处理,要么框架功能失效,要么使用者懵逼为何拿不到回调。

所以,接下来要讲解的是重点思想。

方法重定向

首先,我大概说明一下OC中给一个对象发送消息是个什么过程:

  • 遍历当前类的方法列表,找到该方法并且执行IMP方法体(有缓存机制提高查找效率)。
  • 如果没找到该方法,runtime会尝试在+resolveInstanceMethod: 或者 +resolveClassMethod:中处理该方法。若方法返回YESruntime会重新尝试发送这个消息。
  • +resolve...方法返回NOruntime会走-forwardingTargetForSelector:方法允许你返回一个方法接受者(意味着可以更改方法接受者)。
  • -forwardingTargetForSelector:方法返回的对象无效,runtime会走methodSignatureForSelector:方法尝试获取一个方法体对象(NSMethodSignature),若该方法没有有效的返回值,就会报异常unrecognized selector sent to instance
  • methodSignatureForSelector:方法返回了一个有效的方法体,runtime会走-forwardInvocation:方法尝试发送消息,当然这里也可以使用-doesNotRecognizeSelector:方法抛出异常。

现在,框架需要做的事情是让内部和外部能同时获取到代理回调,也就是要做到多代理消息分发。目前可以考虑的是:
第一,在-forwardingTargetForSelector:方法中处理,但是该方法只支持对一个对象的消息转发。
第二,在-forwardInvocation:方法中处理,里面可以给任意多的对象发送消息,显然,这正是我们需要的。

方法重定向实现多代理消息分发

ps:之前使用的是繁琐的代理方法转发方式,不够优雅,而使用方法重定向的方式做明细优雅很多。

结合到框架的业务需求,这里本人考虑的是使用一个中间代理类作为textFiled.delegate,如下:

@interface YBInputControlTempDelegate : NSObject 
@property (nonatomic, weak) id delegate_inside;
@property (nonatomic, weak) id delegate_outside;
@property (nonatomic, strong) Protocol *protocol;
@end

delegate_inside即为textFiled自身,delegate_outside即为使用者自己在外部设置的代理:textFiled.delegate = anyInstaceprotocol为代理对象,中间某个环节需要用到这个runtime层面的实例。

看到这里,会想到何时将textFiled的代理设置为这个中间代理YBInputControlTempDelegate呢?代码如下:

+ (void)load {
    if ([NSStringFromClass(self) isEqualToString:@"UITextField"]) {
        Method m1 = class_getInstanceMethod(self, @selector(setDelegate:));
        Method m2 = class_getInstanceMethod(self, @selector(customSetDelegate:));
        if (m1 && m2) {
            method_exchangeImplementations(m1, m2);
        }
    }
}
- (void)customSetDelegate:(id)delegate {
    @synchronized(self) {
        if (objc_getAssociatedObject(self, key_Profile)) {
            YBInputControlTempDelegate *tempDelegate = [YBInputControlTempDelegate new];
            tempDelegate.delegate_inside = self;
            if (self.delegate && delegate == self) {
                tempDelegate.delegate_outside = self.delegate;
            } else {
                tempDelegate.delegate_outside = delegate;
            }
            [self customSetDelegate:tempDelegate];
            objc_setAssociatedObject(self, key_tempDelegate, tempDelegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        } else {
            [self customSetDelegate:delegate];
        }
    }
}

这里的核心逻辑就是 textFiled.delegate= tempDelegate。只要你使用该框架给当前textFiled赋值了配置属性yb_inputCP,就说明你是想要使用该框架的功能的,那么接下来你的setDelegate:操作都会被我“移花接木”,值得注意的是objc_setAssociatedObject(self, key_tempDelegate, tempDelegate, OBJC_ASSOCIATION_RETAIN);这句代码必不可少,否则YBInputControlTempDelegate实例会在该次runloop循环结束时释放。

现在基础设施都配置好了,剩下的就是写消息转发的逻辑了,这些逻辑都是在YBInputControlTempDelegate类里面。

首先,需要重写respondsToSelector:方法:

- (BOOL)respondsToSelector:(SEL)aSelector {
    struct objc_method_description des = protocol_getMethodDescription(self.protocol, aSelector, NO, YES);
    if (des.types == NULL) {
        return [super respondsToSelector:aSelector];
    }
    if ([self.delegate_inside respondsToSelector:aSelector] || [self.delegate_outside respondsToSelector:aSelector]) {
        return YES;
    }
    return [super respondsToSelector:aSelector];
}

第一步通过protocol_getMethodDescription()判断aSelector是否是我们需要转发的代理,若不是,那么继续走默认逻辑,若是,就判断实际需要回调的两个对象self.delegate_insideself.delegate_outside是否实现了当前方法,若其中有一个实现了,都返回YES

然后,就是做具体的消息转发逻辑了:

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL sel = anInvocation.selector;
    BOOL isResponds = NO;
    if ([self.delegate_inside respondsToSelector:sel]) {
        isResponds = YES;
        [anInvocation invokeWithTarget:self.delegate_inside];
    }
    if ([self.delegate_outside respondsToSelector:sel]) {
        isResponds = YES;
        [anInvocation invokeWithTarget:self.delegate_outside];
    }
    if (!isResponds) {
        [self doesNotRecognizeSelector:sel];
    }
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    NSMethodSignature *sig_inside = [self.delegate_inside methodSignatureForSelector:aSelector];
    NSMethodSignature *sig_outside = [self.delegate_outside methodSignatureForSelector:aSelector];
    NSMethodSignature *result_sig = sig_inside?:sig_outside?:nil;
    return result_sig;
}

YBInputControlTempDelegate类里面没有实现UITextFieldDelegate代理的任何方法,从而所有的代理方法都可以分发出去。接下来只需要在@implementation UITextField (YBInputControl)实现部分做该框架的核心逻辑就OK了:

- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string {
    return yb_shouldChangeCharactersIn(textField, range, string);
}
- (void)textFieldDidChange:(UITextField *)textField {
    yb_textDidChange(textField);
}

特别注意:有些代理方法是有返回值的,比如textField: shouldChangeCharactersInRange: replacementString:方法,在框架的延展里面需要做逻辑,然后返回一个BOOL值判断是否可以输入,若外部也监听了该代理方法,实际上发送该消息整个逻辑完成过后,返回的是更后面的那个返回值,也就是[anInvocation invokeWithTarget:self.delegate_outside];的返回值,也就是外部使用者写的返回值,这就导致了框架内部的功能失效。(解决方法在github里面有讲,只是在对应方法调用一下框架方法就行了)

UITextView不能使用该方案

其实,采用这种处理办法可能会带来某些隐患。

UITextField的代理是@protocol UITextFieldDelegate ,它是继承NSObject代理,而NSObject代理中的方法是在 UITextField中实现的,而这里继承也是为了外部能调用出NSObject代理下的方法。所以,设置UITextFieldDelegate代理,不存在需要实现额外的包括其父代理的方法。

况且,UITextField的父类是UIControl,向上追溯也没有类带有delegate属性,也就是说,UITextFieldsetDelegate:方法实现中理论上是没有关于父类同样delegate属性和代理方法的处理。

UITextView中,没有使用这种方法。

@protocol UITextViewDelegate 可见,UITextViewDelegate代理有着父代理,里面包含了大量需要处理的代理方法。

而且其父类是UIScrollViewUIScrollView中有着delegate属性,在UITextViewsetDelegate:中肯定会有着对父类代理的操作,这里面的逻辑不得而知,所以这里不能使用代理转接的思路强行插入逻辑(做过测验,UITextView这么做运行中会有一些中间类找不到setDelegate:方法而崩溃,具体原因还没来得及探究)。

四、尾声

总的来说,该小框架的核心功能很简单,但是为了少改动使用者以往的习惯,使用了方法重定向实现多代理分发(包括之前不那么优雅的代理方法转发),提高了使用者的接受度。这当中使用到了runtime的几个方法和处理了方法调用周期,从技术上说不算难,但是为了实现某个需求而深入探究本质将这些点结合起来,就不是一件容易的事。

本文主要讲解了一种解决问题的思路,为了提高一点用户体验度而大费周章的做技术上的功课,这正是写代码给别人用与写代码给自己用的区别,谨以此文抛砖引玉,欢迎大家一起交流。

DEMO地址带用法

你可能感兴趣的:(iOS 文本输入控制(献上框架))