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 判断是否包含非法字符,是否超长(可使用正则表达式处理)
......
}
这种方式就是在文本绘制之前会走的代理方法,我们可以在里面将非法字符扼杀在摇篮中。
提前监听在使用索引功能时弊端
但是在处理带索引输入的时候,会出现下图情况:
看到了么,我们此刻是输入中文,而被选中的字符(也就是我们的拼音)已经输入在了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』详尽总结
- iOS11.3小心升级!后悔药没得吃
- iPhone 7要不要升级iOS 11.3?看完轻松决定
- 苹果iOS12即将发布:这4款老iphone却无法升级
- 高中语文实用类文本题型考点大汇总,拯救不会答题的你
- iOS启动原理(一)
- iOS Xcode9 封装生成.framework
- 出席联合国教科文会议 搜狗输入法引领国际青年品“汉字之美
- 降级查询!官方已关闭iOS11.2.6验证,无法降级系统了
- iOS 11.3小心升级!后悔药没得吃
- iOS 12 概念图,纯暗黑模式亮了