背景
这是个常见场景:textField或者包含textField的控件需要在键盘弹出的时候随之上移,不然就会被键盘遮挡。
既然是常见的,为了提高开发效率,也为了遵循DRY原则,我们就有必要实现一个公共控件。实现这个功能并不复杂,更有意义的是在这个实现过程中的一些总结和思考。下面首先讲一下实现过程,之后再附上总结。
实现
在键盘弹出和收起的时候,会收到两个全局的系统通知:UIKeyboardWillShowNotification和UIKeyboardWillHideNotification,并且通知的userInfo中包含有键盘高度和键盘展开及收起的动画时间。键盘高度可以推算出上移的高度,而上移下移动画时间与键盘展开收起动画时间保持一致可以使得动画更加流畅。
一般来说,需要上移的高度就是textField底部和键盘顶部的距离,不过也有一些场景需要上移更多的距离,比如,textField下方还有个确认按钮,那这种情况可能需要把确认按钮也移到键盘的上方,此时一共需要上移的高度,就应该是键盘顶部与textField底部之间的距离,加上textField底部与确认按钮底部的距离。
一般情况下直接上移整个keyWindow即可,不过也有一些场景是需要移动一个特定的view,比如承载textField的容器。
考虑到以上因素,我们来做一个比较灵活的可定制的防止键盘遮挡textField,通过UITextField子类来实现。代码如下:
#import <UIKit/UIKit.h>
@interface LHWAutoAdjustKeyboardTextField : UITextField
//上移后,textField需要额外高于键盘顶部的距离,默认为0
@property (nonatomic, assign) CGFloat offset;
//需要向上移动的view,默认为keyWindow
@property (nonatomic, weak) UIView *movingView;
@end
#import "LHWAutoAdjustKeyboardTextField.h"
@interface LHWAutoAdjustKeyboardTextField()
@end
@implementation LHWAutoAdjustKeyboardTextField
#import "LHWAutoAdjustKeyboardTextField.h"
@interface LHWAutoAdjustKeyboardTextField()
@property (nonatomic, assign) CGRect originalFrame;
@end
@implementation LHWAutoAdjustKeyboardTextField
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self onInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self onInit];
}
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillShowNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillHideNotification object:nil];
}
- (void)onInit {
[self addKeyboardNotifications];
_movingView = [UIApplication sharedApplication].keyWindow;
_originalFrame = CGRectZero;
}
- (void)addKeyboardNotifications {
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil];
}
- (void)keyboardWillShow: (NSNotification *)notification {
if (self.isFirstResponder) {
CGPoint relativePoint = [self convertPoint: CGPointZero toView: [UIApplication sharedApplication].keyWindow];
CGFloat keyboardHeight = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue].size.height;
CGFloat overstep = CGRectGetHeight(self.frame) + relativePoint.y + keyboardHeight - CGRectGetHeight([UIScreen mainScreen].bounds);
overstep += self.offset;
if (CGRectEqualToRect(self.originalFrame, CGRectZero)) {
self.originalFrame = self.movingView.frame;
}
if (overstep > 0) {
CGFloat duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
CGRect frame = self.originalFrame;
frame.origin.y -= overstep;
[UIView animateWithDuration: duration delay: 0 options: UIViewAnimationOptionCurveLinear animations: ^{
self.movingView.frame = frame;
} completion: nil];
}
}
}
- (void)keyboardWillHide: (NSNotification *)notification {
if (self.isFirstResponder) {
CGFloat duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
[UIView animateWithDuration: duration delay: 0 options: UIViewAnimationOptionCurveLinear animations: ^{
self.movingView.frame = self.originalFrame;
} completion: nil];
self.originalFrame = CGRectZero;
}
}
@end
总结
总结下实现过程中值得注意的几个细节:
(1)为什么选择用继承而不是用分类?首先我们要明白分类的主要目标在于扩展功能,而非数据。本例中除了需要拓展UITextField的功能,还需要保存额外的数据(offset,movingView以及originalFrame),因此更适合用继承。
有同学可能会有疑问了,用runtime的关联对象可以为分类添加属性啊扩展数据啊,嗯,确实可以,但是关联对象不到不得已或者是调试场景下,尽量不要使用,因为很容易引发奇怪的内存管理问题。
(2)在dealloc函数里要移除对键盘事件的通知,不然在iOS8系统会crash,这也是不考虑用分类实现的另一个原因,在分类中override已有方法是非常危险的,尤其是dealloc这种控制生命周期的函数。
分类的方法加入原有类这一操作是在运行期系统加载分类时完成的,所以很可能会覆盖原有类的实现,如果有多个分类同时实现了名字一样的方法,结果就是以最后一次的覆盖为准。因此,在实现分类方法时,仅仅避免覆写已有方法还不够,最好还要加上前缀,来避免工程中其他地方的某个分类和你的分类起了一样的名字,不然出现bug后会很难定位;
(3)本自定义类的前缀是LHW,三个字母开头,因为苹果宣称保留所有两个字母前缀的权利,所以AFNetworking、SDWebImage等等这些著名开源库严格来说命名是不符合苹果规范的;
(4)上移的view,这个属性要定义成weak的,因为很可能这个view就是textField的superView,如果不声明成weak,将会导致循环引用。
很多人对weak的理解仅仅局限在防止循环引用的层面上,其实weak有更深层次的含义。在本例中,即便不会引发循环引用,上移的view也更适合于声明成weak的,因为这个类对于上移view是仅仅知道就可以的弱关联关系,而不是一种拥有或者持有的强关联关系。考虑另外一个相似的场景:在可以方便使用block回调的UIAlertController出现以前,当一个VC实现多个alertView的代理回调时,我们常常通过属性保存这些alertView来区分(用tag区分是很不优雅的做法)。
#import "FooVC.h"
@interface FooVC() <UIAlertViewDelegate>
@property(nonatomic, weak) UIAlertView *alertViewA;
@property(nonatomic, weak) UIAlertView *alertViewB;
@property(nonatomic, weak) UIAlertView *alertViewC;
@end
@implementation FooVC
- (void)showAlertABC {
UIAlertView *alertViewA = [[UIAlertView alloc] initWithTitle:@"" message:@"我是弹窗A" delegate:self cancelButtonTitle:@"取消" otherButtonTitles:@"doAThing", nil];
self.alertViewA = alertViewA;
UIAlertView *alertViewB = [[UIAlertView alloc] initWithTitle:@"" message:@"我是弹窗B" delegate:self cancelButtonTitle:@"取消" otherButtonTitles:@"doBThing", nil];
self.alertViewB = alertViewB;
UIAlertView *alertViewC = [[UIAlertView alloc] initWithTitle:@"" message:@"我是弹窗C" delegate:self cancelButtonTitle:@"取消" otherButtonTitles:@"doCThing", nil];
self.alertViewC = alertViewC;
[alertViewA show];
[alertViewB show];
[alertViewC show];
}
- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex {
if (alertView == self.alertViewA) {
[self doAThing];
} else if (alertView == self.alertViewB) {
[self doBThing];
} else if (alertView == self.alertViewC) {
[self doCThing];
}
}
@end
此时就应该声明成weak而非strong,声明成weak的好处是VC不会干扰这些alertView原本的生命周期,如果声明成strong的,相当于强行延长了这些alertView的生命周期,直到VC释放时,他们才能释放,这样做显然是不合理的。
(5)设计公用控件时,要尽可能多考虑各种使用场景,抽象出可定制部分,如本例中的offset和movingView,如果一开始没考虑这些,把原本需要定制的元素在代码中写死,等到未来需要时,就不得不改动原有的实现,违背了设计模式的开闭原则,非常不好。
网友评论
1.视图上移成功后单击空白地方让TextField失去第一响应者,键盘会下移,但是下移并没有回到原来的位置,后来打断点发现keyboardWillShow这个方法在键盘上移过程中执行了2次,导致self. originalFrame并不是原始位置而是上移后的位置,所以下移的时候根据self. originalFrame是有问题的
2.只有第一次会上移,也就是说在TextField失去第一响应者之后,如果再次单击TextField,视图不会上移。
1.采用CGRectEqualToRect(self.originalFrame, CGRectZero)来判断确实可以解决originalFrame由于2次调用keyboardWillShow而导致的问题,但是overstep也会出现调用2次keyboardWillShow的问题
测试环境:iphone X系统11.4和 iphone6系统10.3.3、 iphone系统自带软键盘、Xcode9.2
测试结果:均出现在第一次单击弹框时,会调用2次keyboardWillShow,第一次调用完毕后键盘升起,视图跟随上移,第二次调用完毕后键盘依旧升起状态,但是此时overstep变小导致视图会下移。所以2次调用完毕的最终结果就是:键盘升起而视图没有上移。但是随后单击键盘的return键让键盘消失后,再次单击弹框,不会出现2次调用keyboardWillShow的情况,可以正常上移、下移。
测试Demo: https://github.com/wangxj4268/-.git
2.采用完善后的代码,确实没有再出现只能上移一次的问题,可以正常上、下移动
关于问题1,之前确实没注意到可能会调用两次的情况,查了下资料只有iOS 9系统是这样?对此我对orginalFrame添加了个状态判断来避免下移位置后不对的问题;
关于问题2,我没能复现,如果你发现了原因,可以告诉我;
另外我还补上了设置默认movingView的代码。