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

博客更新日志

2018年3月16日 更新:消息转发逻辑,放弃了之前的代理方法转发方式,改用方法重定向实现多代理消息分发;更改了部分说明。

一、痛点

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

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

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 文本输入控制(献上框架)

看到了么,我们此刻是输入中文,而被选中的字符(也就是我们的拼音)已经输入在了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地址带用法

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

    @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_inputCP的getter和setter实现如下:

    - (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:中处理该方法。若方法返回YES,runtime会重新尝试发送这个消息。

  • 若+resolve...方法返回NO,runtime会走-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 = anyInstace,protocol为代理对象,中间某个环节需要用到这个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 (delegate != self) {

                    tempDelegate.delegate_outside = delegate;

                }

                [self customSetDelegate:tempDelegate];

                objc_setAssociatedObject(self, key_tempDelegate, tempDelegate, OBJC_ASSOCIATION_RETAIN);

            } 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_inside和self.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属性,也就是说,UITextField的setDelegate:方法实现中理论上是没有关于父类同样delegate属性和代理方法的处理。

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

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

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

    四、尾声

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

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

    DEMO地址带用法

    作者:indulge_in

    链接:http://www.jianshu.com/p/0e527df5c1ef

    相关推荐:

  • iOS学习之入门组件化

  • iOS Flexbox 布局优化

  • iOS多线程:『GCD』详尽总结

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