iOS:輕量可定製的防鍵盤遮擋textField實現總結
背景
這是個常見場景: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]; } elseif (alertView == self.alertViewC) { [self doCThing]; } } @end
此時就應該宣告成weak而非strong,宣告成weak的好處是VC不會干擾這些alertView原本的生命週期,如果宣告成strong的,相當於強行延長了這些alertView的生命週期,直到VC釋放時,他們才能釋放,這樣做顯然是不合理的。
(5)設計公用控制元件時,要儘可能多考慮各種使用場景,抽象出可定製部分 ,如本例中的offset和movingView,如果一開始沒考慮這些,把原本需要定製的元素在程式碼中寫死,等到未來需要時,就不得不改動原有的實現,違背了設計模式的開閉原則,非常不好。