成都商务服务交流群

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

Cocoa开发者社区2019-02-27 13:06:02

博客更新日志

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 判断是否包含非法字符,是否超长(可使用正则表达式处理)

        ......

}


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


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


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



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

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


相关推荐:

  • iOS学习之入门组件化

  • iOS Flexbox 布局优化

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

Copyright © 成都商务服务交流群@2017